From 261f0b971c6c3e518551d4b20c86114957a0b576 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 15 Jul 2020 21:35:33 +0200 Subject: [PATCH 001/362] Update frontend to 20200715.1 (#37888) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7373273db21..6bf6c9992a0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200715.0"], + "requirements": ["home-assistant-frontend==20200715.1"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3f0d07ad3e6..27fc4ee5d09 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 hass-nabucasa==0.34.7 -home-assistant-frontend==20200715.0 +home-assistant-frontend==20200715.1 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index adf22a7d02a..daac1810b79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -724,7 +724,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200715.0 +home-assistant-frontend==20200715.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4020bcc387..459bced30c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200715.0 +home-assistant-frontend==20200715.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 9db6318122c3046574dcf96188e5ad2396bcc4d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jul 2020 10:38:08 -1000 Subject: [PATCH 002/362] Remove support for legacy logbook events created before 0.112 (#37822) * Remove support for legacy logbook events created before 0.112 Reduce the complexity of the logbook code. This should also have a small performance boost. * None is the default --- homeassistant/components/logbook/__init__.py | 49 ++----------- tests/components/logbook/test_init.py | 73 ++++++++++---------- 2 files changed, 40 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index ac9c6b1ba3e..37caa3a8533 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -215,15 +215,13 @@ class LogbookView(HomeAssistantView): return await hass.async_add_job(json_events) -def humanify(hass, events, entity_attr_cache, prev_states=None): +def humanify(hass, events, entity_attr_cache): """Generate a converted list of events into Entry objects. Will try to group events if possible: - if 2+ sensor updates in GROUP_BY_MINUTES, show last - if Home Assistant stop and start happen in same minute call it restarted """ - if prev_states is None: - prev_states = {} # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( @@ -270,12 +268,6 @@ def humanify(hass, events, entity_attr_cache, prev_states=None): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.entity_id - - # Skip events that have not changed state - if entity_id in prev_states and prev_states[entity_id] == event.state: - continue - - prev_states[entity_id] = event.state domain = event.domain if ( @@ -385,16 +377,10 @@ def _get_events( .outerjoin(old_state, (States.old_state_id == old_state.state_id)) # The below filter, removes state change events that do not have # and old_state, new_state, or the old and - # new state are the same for v8 schema or later. + # new state. # - # If the events/states were stored before v8 schema, we relay on the - # prev_states dict to remove them. - # - # When all data is schema v8 or later, the check for EMPTY_JSON_OBJECT - # can be removed. .filter( (Events.event_type != EVENT_STATE_CHANGED) - | (Events.event_data != EMPTY_JSON_OBJECT) | ( (States.state_id.isnot(None)) & (old_state.state_id.isnot(None)) @@ -438,18 +424,12 @@ def _get_events( entity_filter | (Events.event_type != EVENT_STATE_CHANGED) ) - # When all data is schema v8 or later, prev_states can be removed - prev_states = {} - return list(humanify(hass, yield_events(query), entity_attr_cache, prev_states)) + return list(humanify(hass, yield_events(query), entity_attr_cache)) def _keep_event(hass, event, entities_filter): if event.event_type == EVENT_STATE_CHANGED: entity_id = event.entity_id - # Do not report on new entities - # Do not report on entity removal - if not event.has_old_and_new_state: - return False elif event.event_type in HOMEASSISTANT_EVENTS: entity_id = f"{HA_DOMAIN}." elif event.event_type in hass.data[DOMAIN] and ATTR_ENTITY_ID not in event.data: @@ -640,25 +620,6 @@ class LazyEventPartialState: ) return self._time_fired_isoformat - @property - def has_old_and_new_state(self): - """Check the json data to see if new_state and old_state is present without decoding.""" - # Delete this check once all states are saved in the v8 schema - # format or later (they have the old_state_id column). - - # New events in v8+ schema format - if self._row.event_data == EMPTY_JSON_OBJECT: - # Events are already pre-filtered in sql - # to exclude missing old and new state - # if they are in v8+ format - return True - - # Old events not in v8 schema format - return ( - '"old_state": {' in self._row.event_data - and '"new_state": {' in self._row.event_data - ) - class EntityAttributeCache: """A cache to lookup static entity_id attribute. @@ -684,9 +645,7 @@ class EntityAttributeCache: if current_state: # Try the current state as its faster than decoding the # attributes - self._cache[entity_id][attribute] = current_state.attributes.get( - attribute, None - ) + self._cache[entity_id][attribute] = current_state.attributes.get(attribute) else: # If the entity has been removed, decode the attributes # instead diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 8b527219818..1ee05eb89ab 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -572,43 +572,6 @@ class TestComponentLogbook(unittest.TestCase): entries[5], pointC, "included", domain="light", entity_id=entity_id4 ) - def test_exclude_attribute_changes(self): - """Test if events of attribute changes are filtered.""" - pointA = dt_util.utcnow() - pointB = pointA + timedelta(minutes=1) - pointC = pointB + timedelta(minutes=1) - entity_attr_cache = logbook.EntityAttributeCache(self.hass) - - state_off = ha.State("light.kitchen", "off", {}, pointA, pointA).as_dict() - state_100 = ha.State( - "light.kitchen", "on", {"brightness": 100}, pointB, pointB - ).as_dict() - state_200 = ha.State( - "light.kitchen", "on", {"brightness": 200}, pointB, pointC - ).as_dict() - - eventA = self.create_state_changed_event_from_old_new( - "light.kitchen", pointB, state_off, state_100 - ) - eventB = self.create_state_changed_event_from_old_new( - "light.kitchen", pointC, state_100, state_200 - ) - - entities_filter = convert_include_exclude_filter( - logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}})[logbook.DOMAIN] - ) - events = [ - e - for e in (eventA, eventB) - if logbook._keep_event(self.hass, e, entities_filter) - ] - entries = list(logbook.humanify(self.hass, events, entity_attr_cache)) - - assert len(entries) == 1 - self.assert_entry( - entries[0], pointB, "kitchen", domain="light", entity_id="light.kitchen" - ) - def test_home_assistant_start_stop_grouped(self): """Test if HA start and stop events are grouped. @@ -1835,6 +1798,42 @@ async def test_exclude_removed_entities(hass, hass_client): assert response_json[2]["entity_id"] == entity_id2 +async def test_exclude_attribute_changes(hass, hass_client): + """Test if events of attribute changes are filtered.""" + await hass.async_add_executor_job(init_recorder_component, hass) + await async_setup_component(hass, "logbook", {}) + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + hass.states.async_set("light.kitchen", STATE_OFF) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 100}) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 200}) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 300}) + hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) + + await hass.async_block_till_done() + + await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_block_till_done() + + client = await hass_client() + + # Today time 00:00:00 + start = dt_util.utcnow().date() + start_date = datetime(start.year, start.month, start.day) + + # Test today entries without filters + response = await client.get(f"/api/logbook/{start_date.isoformat()}") + assert response.status == 200 + response_json = await response.json() + + assert len(response_json) == 2 + assert response_json[0]["domain"] == "homeassistant" + assert response_json[1]["message"] == "turned on" + assert response_json[1]["entity_id"] == "light.kitchen" + + class MockLazyEventPartialState(ha.Event): """Minimal mock of a Lazy event.""" From 0bfcd8c2ab4aa50d9b5ac927199951bc787afc48 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Wed, 15 Jul 2020 16:49:58 -0400 Subject: [PATCH 003/362] Refactor bond tests (#37868) --- tests/components/bond/common.py | 64 +++++++++++++++++++++++++++- tests/components/bond/test_cover.py | 23 +++++----- tests/components/bond/test_fan.py | 63 +++++++++++---------------- tests/components/bond/test_init.py | 25 ++++++----- tests/components/bond/test_light.py | 36 +++++++--------- tests/components/bond/test_switch.py | 20 ++++----- 6 files changed, 139 insertions(+), 92 deletions(-) diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 2a3f727f8bd..2aebc3aa2ac 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -51,8 +51,8 @@ async def setup_platform( return_value=[bond_device_id], ), patch( "homeassistant.components.bond.Bond.getDevice", return_value=discovered_device - ), patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={} + ), patch_bond_device_state( + return_value={} ), patch( "homeassistant.components.bond.Bond.getProperties", return_value=props ): @@ -60,3 +60,63 @@ async def setup_platform( await hass.async_block_till_done() return mock_entry + + +def patch_bond_turn_on(): + """Patch Bond API turnOn command.""" + return patch("homeassistant.components.bond.Bond.turnOn") + + +def patch_bond_turn_off(): + """Patch Bond API turnOff command.""" + return patch("homeassistant.components.bond.Bond.turnOff") + + +def patch_bond_set_speed(): + """Patch Bond API setSpeed command.""" + return patch("homeassistant.components.bond.Bond.setSpeed") + + +def patch_bond_set_flame(): + """Patch Bond API setFlame command.""" + return patch("homeassistant.components.bond.Bond.setFlame") + + +def patch_bond_open(): + """Patch Bond API open command.""" + return patch("homeassistant.components.bond.Bond.open") + + +def patch_bond_close(): + """Patch Bond API close command.""" + return patch("homeassistant.components.bond.Bond.close") + + +def patch_bond_hold(): + """Patch Bond API hold command.""" + return patch("homeassistant.components.bond.Bond.hold") + + +def patch_bond_set_direction(): + """Patch Bond API setDirection command.""" + return patch("homeassistant.components.bond.Bond.setDirection") + + +def patch_turn_light_on(): + """Patch Bond API turnLightOn command.""" + return patch("homeassistant.components.bond.Bond.turnLightOn") + + +def patch_turn_light_off(): + """Patch Bond API turnLightOff command.""" + return patch("homeassistant.components.bond.Bond.turnLightOff") + + +def patch_bond_device_state(return_value=None): + """Patch Bond API getDeviceState command.""" + if return_value is None: + return_value = {} + + return patch( + "homeassistant.components.bond.Bond.getDeviceState", return_value=return_value + ) diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index 9e1f87e8c0d..dacb612add9 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -15,9 +15,14 @@ from homeassistant.const import ( from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + patch_bond_close, + patch_bond_device_state, + patch_bond_hold, + patch_bond_open, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -40,7 +45,7 @@ async def test_open_cover(hass: core.HomeAssistant): """Tests that open cover command delegates to API.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch("homeassistant.components.bond.Bond.open") as mock_open: + with patch_bond_open() as mock_open, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -55,7 +60,7 @@ async def test_close_cover(hass: core.HomeAssistant): """Tests that close cover command delegates to API.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch("homeassistant.components.bond.Bond.close") as mock_close: + with patch_bond_close() as mock_close, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -70,7 +75,7 @@ async def test_stop_cover(hass: core.HomeAssistant): """Tests that stop cover command delegates to API.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch("homeassistant.components.bond.Bond.hold") as mock_hold: + with patch_bond_hold() as mock_hold, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, @@ -85,9 +90,7 @@ async def test_update_reports_open_cover(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports cover is open.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"open": 1} - ): + with patch_bond_device_state(return_value={"open": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -98,9 +101,7 @@ async def test_update_reports_closed_cover(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports cover is closed.""" await setup_platform(hass, COVER_DOMAIN, shades("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"open": 0} - ): + with patch_bond_device_state(return_value={"open": 0}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 0c2df04e2a9..b518f72f326 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -17,9 +17,15 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + patch_bond_device_state, + patch_bond_set_direction, + patch_bond_set_speed, + patch_bond_turn_off, + patch_bond_turn_on, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed @@ -69,32 +75,25 @@ async def test_entity_non_standard_speed_list(hass: core.HomeAssistant): fan.SPEED_HIGH, ] - with patch("homeassistant.components.bond.Bond.turnOn"), patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed_low: - await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) - mock_set_speed_low.assert_called_once_with("test-device-id", speed=1) + with patch_bond_device_state(): + with patch_bond_turn_on(), patch_bond_set_speed() as mock_set_speed_low: + await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) + mock_set_speed_low.assert_called_once_with("test-device-id", speed=1) - with patch("homeassistant.components.bond.Bond.turnOn"), patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed_medium: - await turn_fan_on(hass, "fan.name_1", fan.SPEED_MEDIUM) - mock_set_speed_medium.assert_called_once_with("test-device-id", speed=3) + with patch_bond_turn_on(), patch_bond_set_speed() as mock_set_speed_medium: + await turn_fan_on(hass, "fan.name_1", fan.SPEED_MEDIUM) + mock_set_speed_medium.assert_called_once_with("test-device-id", speed=3) - with patch("homeassistant.components.bond.Bond.turnOn"), patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed_high: - await turn_fan_on(hass, "fan.name_1", fan.SPEED_HIGH) - mock_set_speed_high.assert_called_once_with("test-device-id", speed=6) + with patch_bond_turn_on(), patch_bond_set_speed() as mock_set_speed_high: + await turn_fan_on(hass, "fan.name_1", fan.SPEED_HIGH) + mock_set_speed_high.assert_called_once_with("test-device-id", speed=6) async def test_turn_on_fan(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on, patch( - "homeassistant.components.bond.Bond.setSpeed" - ) as mock_set_speed: + with patch_bond_turn_on() as mock_turn_on, patch_bond_set_speed() as mock_set_speed, patch_bond_device_state(): await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) mock_set_speed.assert_called_once() @@ -105,7 +104,7 @@ async def test_turn_off_fan(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + with patch_bond_turn_off() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "fan.name_1"}, blocking=True, ) @@ -118,10 +117,7 @@ async def test_update_reports_fan_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports fan power is on.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"power": 1, "speed": 1}, - ): + with patch_bond_device_state(return_value={"power": 1, "speed": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -132,10 +128,7 @@ async def test_update_reports_fan_off(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports fan power is off.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"power": 0, "speed": 1}, - ): + with patch_bond_device_state(return_value={"power": 0, "speed": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -146,10 +139,7 @@ async def test_update_reports_direction_forward(hass: core.HomeAssistant): """Tests that update command sets correct direction when Bond API reports fan direction is forward.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"direction": Directions.FORWARD}, - ): + with patch_bond_device_state(return_value={"direction": Directions.FORWARD}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -160,10 +150,7 @@ async def test_update_reports_direction_reverse(hass: core.HomeAssistant): """Tests that update command sets correct direction when Bond API reports fan direction is reverse.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"direction": Directions.REVERSE}, - ): + with patch_bond_device_state(return_value={"direction": Directions.REVERSE}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -174,7 +161,7 @@ async def test_set_fan_direction(hass: core.HomeAssistant): """Tests that set direction command delegates to API.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch("homeassistant.components.bond.Bond.setDirection") as mock_set_direction: + with patch_bond_set_direction() as mock_set_direction, patch_bond_device_state(): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 23c11199879..7f6250abd37 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -12,6 +12,11 @@ from tests.async_mock import patch from tests.common import MockConfigEntry +def patch_setup_entry(domain: str): + """Patch async_setup_entry for specified domain.""" + return patch(f"homeassistant.components.bond.{domain}.async_setup_entry") + + async def test_async_setup_no_domain_config(hass: HomeAssistant): """Test setup without configuration is noop.""" result = await async_setup_component(hass, DOMAIN, {}) @@ -25,14 +30,12 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch( - "homeassistant.components.bond.cover.async_setup_entry" - ) as mock_cover_async_setup_entry, patch( - "homeassistant.components.bond.fan.async_setup_entry" - ) as mock_fan_async_setup_entry, patch( - "homeassistant.components.bond.light.async_setup_entry" - ) as mock_light_async_setup_entry, patch( - "homeassistant.components.bond.switch.async_setup_entry" + with patch_setup_entry("cover") as mock_cover_async_setup_entry, patch_setup_entry( + "fan" + ) as mock_fan_async_setup_entry, patch_setup_entry( + "light" + ) as mock_light_async_setup_entry, patch_setup_entry( + "switch" ) as mock_switch_async_setup_entry: result = await setup_bond_entity( hass, @@ -72,9 +75,9 @@ async def test_unload_config_entry(hass: HomeAssistant): domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch("homeassistant.components.bond.cover.async_setup_entry"), patch( - "homeassistant.components.bond.fan.async_setup_entry" - ): + with patch_setup_entry("cover"), patch_setup_entry("fan"), patch_setup_entry( + "light" + ), patch_setup_entry("switch"): result = await setup_bond_entity(hass, config_entry) assert result is True await hass.async_block_till_done() diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index fc9edf64727..edcdc3e63bd 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -10,9 +10,16 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + patch_bond_device_state, + patch_bond_set_flame, + patch_bond_turn_off, + patch_bond_turn_on, + patch_turn_light_off, + patch_turn_light_on, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -44,7 +51,7 @@ async def test_turn_on_light(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) - with patch("homeassistant.components.bond.Bond.turnLightOn") as mock_turn_light_on: + with patch_turn_light_on() as mock_turn_light_on, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -59,9 +66,7 @@ async def test_turn_off_light(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.turnLightOff" - ) as mock_turn_light_off: + with patch_turn_light_off() as mock_turn_light_off, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -76,9 +81,7 @@ async def test_update_reports_light_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the light is on.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"light": 1} - ): + with patch_bond_device_state(return_value={"light": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -89,9 +92,7 @@ async def test_update_reports_light_is_off(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the light is off.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"light": 0} - ): + with patch_bond_device_state(return_value={"light": 0}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -104,9 +105,7 @@ async def test_turn_on_fireplace(hass: core.HomeAssistant): hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" ) - with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on, patch( - "homeassistant.components.bond.Bond.setFlame" - ) as mock_set_flame: + with patch_bond_turn_on() as mock_turn_on, patch_bond_set_flame() as mock_set_flame, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -123,7 +122,7 @@ async def test_turn_off_fireplace(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" await setup_platform(hass, LIGHT_DOMAIN, fireplace("name-1")) - with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + with patch_bond_turn_off() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -138,10 +137,7 @@ async def test_flame_converted_to_brightness(hass: core.HomeAssistant): """Tests that reported flame level (0..100) converted to HA brightness (0...255).""" await setup_platform(hass, LIGHT_DOMAIN, fireplace("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", - return_value={"power": 1, "flame": 50}, - ): + with patch_bond_device_state(return_value={"power": 1, "flame": 50}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 121bb505cdb..b2d77150907 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -10,9 +10,13 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import setup_platform +from .common import ( + patch_bond_device_state, + patch_bond_turn_off, + patch_bond_turn_on, + setup_platform, +) -from tests.async_mock import patch from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) @@ -35,7 +39,7 @@ async def test_turn_on_switch(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) - with patch("homeassistant.components.bond.Bond.turnOn") as mock_turn_on: + with patch_bond_turn_on() as mock_turn_on, patch_bond_device_state(): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -50,7 +54,7 @@ async def test_turn_off_switch(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) - with patch("homeassistant.components.bond.Bond.turnOff") as mock_turn_off: + with patch_bond_turn_off() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -65,9 +69,7 @@ async def test_update_reports_switch_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the device is on.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"power": 1} - ): + with patch_bond_device_state(return_value={"power": 1}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -78,9 +80,7 @@ async def test_update_reports_switch_is_off(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the device is off.""" await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) - with patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value={"power": 0} - ): + with patch_bond_device_state(return_value={"power": 0}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() From 4ef581622decde2a15d45ff6391b01f0fb373537 Mon Sep 17 00:00:00 2001 From: Sly Gryphon Date: Thu, 16 Jul 2020 10:05:31 +1000 Subject: [PATCH 004/362] Feature/izone temperature precision (#37669) * Change to precision tenths for current temp --- homeassistant/components/izone/climate.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 69c005b345b..cd14d1cadcf 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_EXCLUDE, PRECISION_HALVES, + PRECISION_TENTHS, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -250,7 +251,7 @@ class ControllerDevice(ClimateEntity): @property def precision(self) -> float: """Return the precision of the system.""" - return PRECISION_HALVES + return PRECISION_TENTHS @property def device_state_attributes(self): @@ -266,7 +267,7 @@ class ControllerDevice(ClimateEntity): self.hass, self._controller.temp_setpoint, self.temperature_unit, - self.precision, + PRECISION_HALVES, ), } @@ -494,7 +495,7 @@ class ZoneDevice(ClimateEntity): @property def precision(self): """Return the precision of the system.""" - return PRECISION_HALVES + return PRECISION_TENTHS @property def hvac_mode(self): From 16e5d0279481cfad95f785f94003282fa410e6e8 Mon Sep 17 00:00:00 2001 From: mdegat01 Date: Thu, 16 Jul 2020 03:42:02 -0400 Subject: [PATCH 005/362] Add `ignore_attributes` option to influxdb (#37747) * Added ignore_attributes option and tests * adjusted config for overlapping customization with ignore attrs --- homeassistant/components/influxdb/__init__.py | 21 ++- homeassistant/components/influxdb/const.py | 1 + tests/components/influxdb/test_init.py | 142 ++++++++++++++++++ 3 files changed, 159 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index 86a2242944d..555a268b62a 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -51,6 +51,7 @@ from .const import ( CONF_DB_NAME, CONF_DEFAULT_MEASUREMENT, CONF_HOST, + CONF_IGNORE_ATTRIBUTES, CONF_ORG, CONF_OVERRIDE_MEASUREMENT, CONF_PASSWORD, @@ -142,7 +143,10 @@ def validate_version_specific_config(conf: Dict) -> Dict: _CUSTOMIZE_ENTITY_SCHEMA = vol.Schema( - {vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string} + { + vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, + vol.Optional(CONF_IGNORE_ATTRIBUTES): vol.All(cv.ensure_list, [cv.string]), + } ) _INFLUX_BASE_SCHEMA = INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( @@ -154,6 +158,9 @@ _INFLUX_BASE_SCHEMA = INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend( vol.Optional(CONF_TAGS_ATTRIBUTES, default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional(CONF_IGNORE_ATTRIBUTES, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), vol.Optional(CONF_COMPONENT_CONFIG, default={}): vol.Schema( {cv.entity_id: _CUSTOMIZE_ENTITY_SCHEMA} ), @@ -182,6 +189,7 @@ def _generate_event_to_json(conf: Dict) -> Callable[[Dict], str]: tags_attributes = conf.get(CONF_TAGS_ATTRIBUTES) default_measurement = conf.get(CONF_DEFAULT_MEASUREMENT) override_measurement = conf.get(CONF_OVERRIDE_MEASUREMENT) + global_ignore_attributes = set(conf[CONF_IGNORE_ATTRIBUTES]) component_config = EntityValues( conf[CONF_COMPONENT_CONFIG], conf[CONF_COMPONENT_CONFIG_DOMAIN], @@ -211,9 +219,8 @@ def _generate_event_to_json(conf: Dict) -> Callable[[Dict], str]: _include_state = True include_uom = True - measurement = component_config.get(state.entity_id).get( - CONF_OVERRIDE_MEASUREMENT - ) + entity_config = component_config.get(state.entity_id) + measurement = entity_config.get(CONF_OVERRIDE_MEASUREMENT) if measurement in (None, ""): if override_measurement: measurement = override_measurement @@ -241,10 +248,14 @@ def _generate_event_to_json(conf: Dict) -> Callable[[Dict], str]: if _include_value: json[INFLUX_CONF_FIELDS][INFLUX_CONF_VALUE] = _state_as_value + ignore_attributes = set(entity_config.get(CONF_IGNORE_ATTRIBUTES, [])) + ignore_attributes.update(global_ignore_attributes) for key, value in state.attributes.items(): if key in tags_attributes: json[INFLUX_CONF_TAGS][key] = value - elif key != CONF_UNIT_OF_MEASUREMENT or include_uom: + elif ( + key != CONF_UNIT_OF_MEASUREMENT or include_uom + ) and key not in ignore_attributes: # If the key is already in fields if key in json[INFLUX_CONF_FIELDS]: key = f"{key}_" diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index 1c7a9a0bfaa..a9115c3fc68 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -28,6 +28,7 @@ CONF_COMPONENT_CONFIG = "component_config" CONF_COMPONENT_CONFIG_GLOB = "component_config_glob" CONF_COMPONENT_CONFIG_DOMAIN = "component_config_domain" CONF_RETRY_COUNT = "max_retries" +CONF_IGNORE_ATTRIBUTES = "ignore_attributes" CONF_LANGUAGE = "language" CONF_QUERIES = "queries" diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 02a98a527ac..7e2a9f5d9c3 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -1059,6 +1059,148 @@ async def test_event_listener_component_override_measurement( write_api.reset_mock() +@pytest.mark.parametrize( + "mock_client, config_ext, get_write_api, get_mock_call", + [ + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + _get_write_api_mock_v1, + influxdb.DEFAULT_API_VERSION, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + _get_write_api_mock_v2, + influxdb.API_VERSION_2, + ), + ], + indirect=["mock_client", "get_mock_call"], +) +async def test_event_listener_ignore_attributes( + hass, mock_client, config_ext, get_write_api, get_mock_call +): + """Test the event listener with overridden measurements.""" + config = { + "ignore_attributes": ["ignore"], + "component_config": { + "sensor.fake_humidity": {"ignore_attributes": ["id_ignore"]} + }, + "component_config_glob": { + "binary_sensor.*motion": {"ignore_attributes": ["glob_ignore"]} + }, + "component_config_domain": { + "climate": {"ignore_attributes": ["domain_ignore"]} + }, + } + config.update(config_ext) + handler_method = await _setup(hass, mock_client, config, get_write_api) + + test_components = [ + { + "domain": "sensor", + "id": "fake_humidity", + "attrs": {"glob_ignore": 1, "domain_ignore": 1}, + }, + { + "domain": "binary_sensor", + "id": "fake_motion", + "attrs": {"id_ignore": 1, "domain_ignore": 1}, + }, + { + "domain": "climate", + "id": "fake_thermostat", + "attrs": {"id_ignore": 1, "glob_ignore": 1}, + }, + ] + for comp in test_components: + entity_id = f"{comp['domain']}.{comp['id']}" + state = MagicMock( + state=1, + domain=comp["domain"], + entity_id=entity_id, + object_id=comp["id"], + attributes={ + "ignore": 1, + "id_ignore": 1, + "glob_ignore": 1, + "domain_ignore": 1, + }, + ) + event = MagicMock(data={"new_state": state}, time_fired=12345) + fields = {"value": 1} + fields.update(comp["attrs"]) + body = [ + { + "measurement": entity_id, + "tags": {"domain": comp["domain"], "entity_id": comp["id"]}, + "time": 12345, + "fields": fields, + } + ] + handler_method(event) + hass.data[influxdb.DOMAIN].block_till_done() + + write_api = get_write_api(mock_client) + assert write_api.call_count == 1 + assert write_api.call_args == get_mock_call(body) + write_api.reset_mock() + + +@pytest.mark.parametrize( + "mock_client, config_ext, get_write_api, get_mock_call", + [ + ( + influxdb.DEFAULT_API_VERSION, + BASE_V1_CONFIG, + _get_write_api_mock_v1, + influxdb.DEFAULT_API_VERSION, + ), + ( + influxdb.API_VERSION_2, + BASE_V2_CONFIG, + _get_write_api_mock_v2, + influxdb.API_VERSION_2, + ), + ], + indirect=["mock_client", "get_mock_call"], +) +async def test_event_listener_ignore_attributes_overlapping_entities( + hass, mock_client, config_ext, get_write_api, get_mock_call +): + """Test the event listener with overridden measurements.""" + config = { + "component_config": {"sensor.fake": {"override_measurement": "units"}}, + "component_config_domain": {"sensor": {"ignore_attributes": ["ignore"]}}, + } + config.update(config_ext) + handler_method = await _setup(hass, mock_client, config, get_write_api) + + state = MagicMock( + state=1, + domain="sensor", + entity_id="sensor.fake", + object_id="fake", + attributes={"ignore": 1}, + ) + event = MagicMock(data={"new_state": state}, time_fired=12345) + body = [ + { + "measurement": "units", + "tags": {"domain": "sensor", "entity_id": "fake"}, + "time": 12345, + "fields": {"value": 1}, + } + ] + handler_method(event) + hass.data[influxdb.DOMAIN].block_till_done() + + write_api = get_write_api(mock_client) + assert write_api.call_count == 1 + assert write_api.call_args == get_mock_call(body) + write_api.reset_mock() + + @pytest.mark.parametrize( "mock_client, config_ext, get_write_api, get_mock_call", [ From 93c6a9cd96cad036a465210f41d6d377d3e284e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 16 Jul 2020 10:08:05 +0200 Subject: [PATCH 006/362] Fix swapped variables deprecation in log message (#37901) --- homeassistant/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 2ffa69b4ff2..a327ca630f8 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -511,8 +511,8 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non elif LEGACY_CONF_WHITELIST_EXTERNAL_DIRS in config: _LOGGER.warning( "Key %s has been replaced with %s. Please update your config", - CONF_ALLOWLIST_EXTERNAL_DIRS, LEGACY_CONF_WHITELIST_EXTERNAL_DIRS, + CONF_ALLOWLIST_EXTERNAL_DIRS, ) hac.allowlist_external_dirs.update( set(config[LEGACY_CONF_WHITELIST_EXTERNAL_DIRS]) From 37a70c73a505f67e5f6c39336c528623205cd747 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Thu, 16 Jul 2020 08:31:15 -0700 Subject: [PATCH 007/362] Improve bond startup performance (#37900) --- homeassistant/components/bond/cover.py | 4 +--- homeassistant/components/bond/fan.py | 4 +--- homeassistant/components/bond/light.py | 6 ++---- homeassistant/components/bond/switch.py | 4 +--- homeassistant/components/bond/utils.py | 12 ++++++++---- tests/components/bond/common.py | 15 +++++++++++---- tests/components/bond/test_init.py | 12 +++++++----- 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 79ccfa9210e..809bf3d7da5 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -21,11 +21,9 @@ async def async_setup_entry( """Set up Bond cover devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - covers = [ BondCover(hub, device) - for device in devices + for device in hub.devices if device.type == DeviceTypes.MOTORIZED_SHADES ] diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 0d7013b4ccf..80ae5d7f6ac 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -32,11 +32,9 @@ async def async_setup_entry( """Set up Bond fan devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - fans = [ BondFan(hub, device) - for device in devices + for device in hub.devices if device.type == DeviceTypes.CEILING_FAN ] diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 949c5a54070..9a3e49952aa 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -26,18 +26,16 @@ async def async_setup_entry( """Set up Bond light devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - lights = [ BondLight(hub, device) - for device in devices + for device in hub.devices if device.type == DeviceTypes.CEILING_FAN and device.supports_light() ] async_add_entities(lights, True) fireplaces = [ BondFireplace(hub, device) - for device in devices + for device in hub.devices if device.type == DeviceTypes.FIREPLACE ] async_add_entities(fireplaces, True) diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index e7892272bbf..4768bbf8eda 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -21,11 +21,9 @@ async def async_setup_entry( """Set up Bond generic devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - devices = await hass.async_add_executor_job(hub.get_bond_devices) - switches = [ BondSwitch(hub, device) - for device in devices + for device in hub.devices if device.type == DeviceTypes.GENERIC_DEVICE ] diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 48fbcd80210..5e1360bcd41 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -59,15 +59,15 @@ class BondHub: """Initialize Bond Hub.""" self.bond: Bond = bond self._version: Optional[dict] = None + self._devices: Optional[List[BondDevice]] = None def setup(self): """Read hub version information.""" self._version = self.bond.getVersion() - def get_bond_devices(self) -> List[BondDevice]: - """Fetch all available devices using Bond API.""" + # Fetch all available devices using Bond API. device_ids = self.bond.getDeviceIds() - devices = [ + self._devices = [ BondDevice( device_id, self.bond.getDevice(device_id), @@ -75,7 +75,6 @@ class BondHub: ) for device_id in device_ids ] - return devices @property def bond_id(self) -> str: @@ -91,3 +90,8 @@ class BondHub: def fw_ver(self) -> str: """Return this hub firmware version.""" return self._version.get("fw_ver") + + @property + def devices(self) -> List[BondDevice]: + """Return a list of all devices controlled by this hub.""" + return self._devices diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 2aebc3aa2ac..780e235d5c9 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -46,10 +46,7 @@ async def setup_platform( with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch( "homeassistant.components.bond.Bond.getVersion", return_value=MOCK_HUB_VERSION - ), patch( - "homeassistant.components.bond.Bond.getDeviceIds", - return_value=[bond_device_id], - ), patch( + ), patch_bond_device_ids(return_value=[bond_device_id],), patch( "homeassistant.components.bond.Bond.getDevice", return_value=discovered_device ), patch_bond_device_state( return_value={} @@ -62,6 +59,16 @@ async def setup_platform( return mock_entry +def patch_bond_device_ids(return_value=None): + """Patch Bond API getDeviceIds command.""" + if return_value is None: + return_value = [] + + return patch( + "homeassistant.components.bond.Bond.getDeviceIds", return_value=return_value, + ) + + def patch_bond_turn_on(): """Patch Bond API turnOn command.""" return patch("homeassistant.components.bond.Bond.turnOn") diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 7f6250abd37..4d5fd9f4568 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .common import setup_bond_entity +from .common import patch_bond_device_ids, setup_bond_entity from tests.async_mock import patch from tests.common import MockConfigEntry @@ -30,7 +30,9 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch_setup_entry("cover") as mock_cover_async_setup_entry, patch_setup_entry( + with patch_bond_device_ids(), patch_setup_entry( + "cover" + ) as mock_cover_async_setup_entry, patch_setup_entry( "fan" ) as mock_fan_async_setup_entry, patch_setup_entry( "light" @@ -75,9 +77,9 @@ async def test_unload_config_entry(hass: HomeAssistant): domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch_setup_entry("cover"), patch_setup_entry("fan"), patch_setup_entry( - "light" - ), patch_setup_entry("switch"): + with patch_bond_device_ids(), patch_setup_entry("cover"), patch_setup_entry( + "fan" + ), patch_setup_entry("light"), patch_setup_entry("switch"): result = await setup_bond_entity(hass, config_entry) assert result is True await hass.async_block_till_done() From a224b944e9fcc8a1d2eb9b7d71ca8fa83f47282f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 16 Jul 2020 20:18:31 +0200 Subject: [PATCH 008/362] Updated frontend to 20200716.0 (#37910) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6bf6c9992a0..ad68adfd490 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200715.1"], + "requirements": ["home-assistant-frontend==20200716.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 27fc4ee5d09..972c80ea6bc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 hass-nabucasa==0.34.7 -home-assistant-frontend==20200715.1 +home-assistant-frontend==20200716.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index daac1810b79..76468fa970f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -724,7 +724,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200715.1 +home-assistant-frontend==20200716.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 459bced30c1..e2b2c32f484 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200715.1 +home-assistant-frontend==20200716.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 716cee69074aa391d61d3cda487b50872bc87346 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 16 Jul 2020 14:03:43 -0500 Subject: [PATCH 009/362] Fix automation & script restart mode bug (#37909) --- homeassistant/helpers/script.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 29d1acf0316..1ca13e22e9f 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -760,14 +760,16 @@ class Script: raise async def _async_stop(self, update_state): - await asyncio.wait([run.async_stop() for run in self._runs]) + aws = [run.async_stop() for run in self._runs] + if not aws: + return + await asyncio.wait(aws) if update_state: self._changed() async def async_stop(self, update_state: bool = True) -> None: """Stop running script.""" - if self.is_running: - await asyncio.shield(self._async_stop(update_state)) + await asyncio.shield(self._async_stop(update_state)) async def _async_get_condition(self, config): config_cache_key = frozenset((k, str(v)) for k, v in config.items()) From 33dc015083b728649c981df47a1587b55b542cca Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 16 Jul 2020 16:25:42 -0400 Subject: [PATCH 010/362] Fix ZHA electrical measurement sensor initialization (#37915) * Refactor cached ZHA channel reads. If doing a cached ZCL attribute read, do "only_from_cache" read for battery operated devices only. Mains operated devices will do a network read in case of a cache miss. * Use cached attributes for ZHA electrical measurement * Bump up ZHA zigpy dependency. --- .../components/zha/core/channels/base.py | 4 +- .../zha/core/channels/homeautomation.py | 28 ++++------ homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/test_sensor.py | 55 +++++++++++++++++++ 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 83accc5b86c..ebc2cd5cd0f 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -245,7 +245,7 @@ class ZigbeeChannel(LogMixin): self._cluster, [attribute], allow_cache=from_cache, - only_cache=from_cache, + only_cache=from_cache and not self._ch_pool.is_mains_powered, manufacturer=manufacturer, ) return result.get(attribute) @@ -260,7 +260,7 @@ class ZigbeeChannel(LogMixin): result, _ = await self.cluster.read_attributes( attributes, allow_cache=from_cache, - only_cache=from_cache, + only_cache=from_cache and not self._ch_pool.is_mains_powered, manufacturer=manufacturer, ) return result diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index d95180ce780..e18f4ae9c17 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -4,7 +4,7 @@ from typing import Optional import zigpy.zcl.clusters.homeautomation as homeautomation -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( CHANNEL_ELECTRICAL_MEASUREMENT, REPORT_CONFIG_DEFAULT, @@ -51,14 +51,6 @@ class ElectricalMeasurementChannel(ZigbeeChannel): REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},) - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType - ) -> None: - """Initialize Metering.""" - super().__init__(cluster, ch_pool) - self._divisor = None - self._multiplier = None - async def async_update(self): """Retrieve latest state.""" self.debug("async_update") @@ -80,7 +72,9 @@ class ElectricalMeasurementChannel(ZigbeeChannel): async def fetch_config(self, from_cache): """Fetch config from device and updates format specifier.""" - results = await self.get_attributes( + + # prime the cache + await self.get_attributes( [ "ac_power_divisor", "power_divisor", @@ -89,22 +83,20 @@ class ElectricalMeasurementChannel(ZigbeeChannel): ], from_cache=from_cache, ) - self._divisor = results.get( - "ac_power_divisor", results.get("power_divisor", self._divisor) - ) - self._multiplier = results.get( - "ac_power_multiplier", results.get("power_multiplier", self._multiplier) - ) @property def divisor(self) -> Optional[int]: """Return active power divisor.""" - return self._divisor or 1 + return self.cluster.get( + "ac_power_divisor", self.cluster.get("power_divisor", 1) + ) @property def multiplier(self) -> Optional[int]: """Return active power divisor.""" - return self._multiplier or 1 + return self.cluster.get( + "ac_power_multiplier", self.cluster.get("power_multiplier", 1) + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e469cc90cc4..24d9a0a3962 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -9,7 +9,7 @@ "zha-quirks==0.0.42", "zigpy-cc==0.4.4", "zigpy-deconz==0.9.2", - "zigpy==0.22.1", + "zigpy==0.22.2", "zigpy-xbee==0.12.1", "zigpy-zigate==0.6.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 76468fa970f..d42d86276ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2269,7 +2269,7 @@ zigpy-xbee==0.12.1 zigpy-zigate==0.6.1 # homeassistant.components.zha -zigpy==0.22.1 +zigpy==0.22.2 # homeassistant.components.zoneminder zm-py==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2b2c32f484..a318da8ed17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -999,4 +999,4 @@ zigpy-xbee==0.12.1 zigpy-zigate==0.6.1 # homeassistant.components.zha -zigpy==0.22.1 +zigpy==0.22.2 diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 064b0251e6b..25fecd2d82c 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -265,3 +265,58 @@ async def test_temp_uom( assert state is not None assert round(float(state.state)) == expected assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == uom + + +async def test_electrical_measurement_init( + hass, zigpy_device_mock, zha_device_joined, +): + """Test proper initialization of the electrical measurement cluster.""" + + cluster_id = homeautomation.ElectricalMeasurement.cluster_id + zigpy_device = zigpy_device_mock( + { + 1: { + "in_clusters": [cluster_id, general.Basic.cluster_id], + "out_cluster": [], + "device_type": 0x0000, + } + } + ) + cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + zha_device = await zha_device_joined(zigpy_device) + entity_id = await find_entity_id(DOMAIN, zha_device, hass) + + # allow traffic to flow through the gateway and devices + await async_enable_traffic(hass, [zha_device]) + + # test that the sensor now have a state of unknown + assert hass.states.get(entity_id).state == STATE_UNKNOWN + + await send_attributes_report(hass, cluster, {0: 1, 1291: 100, 10: 1000}) + assert int(hass.states.get(entity_id).state) == 100 + + channel = zha_device.channels.pools[0].all_channels["1:0x0b04"] + assert channel.divisor == 1 + assert channel.multiplier == 1 + + # update power divisor + await send_attributes_report(hass, cluster, {0: 1, 1291: 20, 0x0403: 5, 10: 1000}) + assert channel.divisor == 5 + assert channel.multiplier == 1 + assert hass.states.get(entity_id).state == "4.0" + + await send_attributes_report(hass, cluster, {0: 1, 1291: 30, 0x0605: 10, 10: 1000}) + assert channel.divisor == 10 + assert channel.multiplier == 1 + assert hass.states.get(entity_id).state == "3.0" + + # update power multiplier + await send_attributes_report(hass, cluster, {0: 1, 1291: 20, 0x0402: 6, 10: 1000}) + assert channel.divisor == 10 + assert channel.multiplier == 6 + assert hass.states.get(entity_id).state == "12.0" + + await send_attributes_report(hass, cluster, {0: 1, 1291: 30, 0x0604: 20, 10: 1000}) + assert channel.divisor == 10 + assert channel.multiplier == 20 + assert hass.states.get(entity_id).state == "60.0" From baa7bb69b31dfdba675b4cd12c63aafd5ea31ba6 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 17 Jul 2020 00:28:49 +0200 Subject: [PATCH 011/362] Fix unavailable when value is zero (#37918) --- homeassistant/components/netatmo/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index b3c2cb79675..6aaa7d08975 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -555,7 +555,7 @@ class NetatmoPublicSensor(Entity): @property def available(self): """Return True if entity is available.""" - return bool(self._state) + return self._state is not None def update(self): """Get the latest data from Netatmo API and updates the states.""" From 2d93f8eae81eda674d34d79a566dcd73004b2b1e Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Fri, 17 Jul 2020 02:26:29 +0200 Subject: [PATCH 012/362] Upgrade pysonos to 0.0.32 (#37923) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 7ce4af02e45..3a8ba58cc61 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["pysonos==0.0.31"], + "requirements": ["pysonos==0.0.32"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index d42d86276ce..2d2e22f5983 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1623,7 +1623,7 @@ pysnmp==4.4.12 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.31 +pysonos==0.0.32 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a318da8ed17..ecf468ca6b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -743,7 +743,7 @@ pysmartthings==0.7.1 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.31 +pysonos==0.0.32 # homeassistant.components.spc pyspcwebgw==0.4.0 From b6befa2e833d4179453edc85923b3e6f3c70525a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2020 14:50:06 -1000 Subject: [PATCH 013/362] Ensure a state change tracker setup from inside a state change listener does not fire immediately (#37924) Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/event.py | 37 +++++++----- tests/helpers/test_event.py | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 13 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c6eeffb974f..a2dfcff7699 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -135,7 +135,9 @@ track_state_change = threaded_listener_factory(async_track_state_change) @bind_hass def async_track_state_change_event( - hass: HomeAssistant, entity_ids: Iterable[str], action: Callable[[Event], None] + hass: HomeAssistant, + entity_ids: Union[str, Iterable[str]], + action: Callable[[Event], None], ) -> Callable[[], None]: """Track specific state change events indexed by entity_id. @@ -161,7 +163,7 @@ def async_track_state_change_event( if entity_id not in entity_callbacks: return - for action in entity_callbacks[entity_id]: + for action in entity_callbacks[entity_id][:]: try: hass.async_run_job(action, event) except Exception: # pylint: disable=broad-except @@ -173,13 +175,13 @@ def async_track_state_change_event( EVENT_STATE_CHANGED, _async_state_change_dispatcher ) + if isinstance(entity_ids, str): + entity_ids = [entity_ids] + entity_ids = [entity_id.lower() for entity_id in entity_ids] for entity_id in entity_ids: - if entity_id not in entity_callbacks: - entity_callbacks[entity_id] = [] - - entity_callbacks[entity_id].append(action) + entity_callbacks.setdefault(entity_id, []).append(action) @callback def remove_listener() -> None: @@ -247,7 +249,7 @@ def async_track_same_state( hass: HomeAssistant, period: timedelta, action: Callable[..., None], - async_check_same_func: Callable[[str, State, State], bool], + async_check_same_func: Callable[[str, Optional[State], Optional[State]], bool], entity_ids: Union[str, Iterable[str]] = MATCH_ALL, ) -> CALLBACK_TYPE: """Track the state of entities for a period and run an action. @@ -279,10 +281,12 @@ def async_track_same_state( hass.async_run_job(action) @callback - def state_for_cancel_listener( - entity: str, from_state: State, to_state: State - ) -> None: + def state_for_cancel_listener(event: Event) -> None: """Fire on changes and cancel for listener if changed.""" + entity: str = event.data["entity_id"] + from_state: Optional[State] = event.data.get("old_state") + to_state: Optional[State] = event.data.get("new_state") + if not async_check_same_func(entity, from_state, to_state): clear_listener() @@ -290,9 +294,16 @@ def async_track_same_state( hass, state_for_listener, dt_util.utcnow() + period ) - async_remove_state_for_cancel = async_track_state_change( - hass, entity_ids, state_for_cancel_listener - ) + if entity_ids == MATCH_ALL: + async_remove_state_for_cancel = hass.bus.async_listen( + EVENT_STATE_CHANGED, state_for_cancel_listener + ) + else: + async_remove_state_for_cancel = async_track_state_change_event( + hass, + [entity_ids] if isinstance(entity_ids, str) else entity_ids, + state_for_cancel_listener, + ) return clear_listener diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 7724a80e8b4..b0034ebaaa6 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -1011,3 +1011,104 @@ async def test_async_call_later(hass): assert p_action is action assert p_point == now + timedelta(seconds=3) assert remove is mock() + + +async def test_track_state_change_event_chain_multple_entity(hass): + """Test that adding a new state tracker inside a tracker does not fire right away.""" + tracker_called = [] + chained_tracker_called = [] + + chained_tracker_unsub = [] + tracker_unsub = [] + + @ha.callback + def chained_single_run_callback(event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + chained_tracker_called.append((old_state, new_state)) + + @ha.callback + def single_run_callback(event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + tracker_called.append((old_state, new_state)) + + chained_tracker_unsub.append( + async_track_state_change_event( + hass, ["light.bowl", "light.top"], chained_single_run_callback + ) + ) + + tracker_unsub.append( + async_track_state_change_event( + hass, ["light.bowl", "light.top"], single_run_callback + ) + ) + + hass.states.async_set("light.bowl", "on") + hass.states.async_set("light.top", "on") + await hass.async_block_till_done() + + assert len(tracker_called) == 2 + assert len(chained_tracker_called) == 1 + assert len(tracker_unsub) == 1 + assert len(chained_tracker_unsub) == 2 + + hass.states.async_set("light.bowl", "off") + await hass.async_block_till_done() + + assert len(tracker_called) == 3 + assert len(chained_tracker_called) == 3 + assert len(tracker_unsub) == 1 + assert len(chained_tracker_unsub) == 3 + + +async def test_track_state_change_event_chain_single_entity(hass): + """Test that adding a new state tracker inside a tracker does not fire right away.""" + tracker_called = [] + chained_tracker_called = [] + + chained_tracker_unsub = [] + tracker_unsub = [] + + @ha.callback + def chained_single_run_callback(event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + chained_tracker_called.append((old_state, new_state)) + + @ha.callback + def single_run_callback(event): + old_state = event.data.get("old_state") + new_state = event.data.get("new_state") + + tracker_called.append((old_state, new_state)) + + chained_tracker_unsub.append( + async_track_state_change_event( + hass, "light.bowl", chained_single_run_callback + ) + ) + + tracker_unsub.append( + async_track_state_change_event(hass, "light.bowl", single_run_callback) + ) + + hass.states.async_set("light.bowl", "on") + await hass.async_block_till_done() + + assert len(tracker_called) == 1 + assert len(chained_tracker_called) == 0 + assert len(tracker_unsub) == 1 + assert len(chained_tracker_unsub) == 1 + + hass.states.async_set("light.bowl", "off") + await hass.async_block_till_done() + + assert len(tracker_called) == 2 + assert len(chained_tracker_called) == 1 + assert len(tracker_unsub) == 1 + assert len(chained_tracker_unsub) == 2 From 93919dea88e88863a77cc00ee0079522f19c31c0 Mon Sep 17 00:00:00 2001 From: Perry Naseck Date: Thu, 16 Jul 2020 20:58:45 -0400 Subject: [PATCH 014/362] Add Firmata Integration (attempt 2) (#35591) --- .coveragerc | 7 + CODEOWNERS | 1 + homeassistant/components/firmata/__init__.py | 191 ++++++++++++++++++ .../components/firmata/binary_sensor.py | 59 ++++++ homeassistant/components/firmata/board.py | 144 +++++++++++++ .../components/firmata/config_flow.py | 57 ++++++ homeassistant/components/firmata/const.py | 24 +++ homeassistant/components/firmata/entity.py | 60 ++++++ .../components/firmata/manifest.json | 12 ++ homeassistant/components/firmata/pin.py | 153 ++++++++++++++ homeassistant/components/firmata/strings.json | 8 + homeassistant/components/firmata/switch.py | 75 +++++++ .../components/firmata/translations/en.json | 8 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/firmata/__init__.py | 1 + tests/components/firmata/test_config_flow.py | 92 +++++++++ 17 files changed, 898 insertions(+) create mode 100644 homeassistant/components/firmata/__init__.py create mode 100644 homeassistant/components/firmata/binary_sensor.py create mode 100644 homeassistant/components/firmata/board.py create mode 100644 homeassistant/components/firmata/config_flow.py create mode 100644 homeassistant/components/firmata/const.py create mode 100644 homeassistant/components/firmata/entity.py create mode 100644 homeassistant/components/firmata/manifest.json create mode 100644 homeassistant/components/firmata/pin.py create mode 100644 homeassistant/components/firmata/strings.json create mode 100644 homeassistant/components/firmata/switch.py create mode 100644 homeassistant/components/firmata/translations/en.json create mode 100644 tests/components/firmata/__init__.py create mode 100644 tests/components/firmata/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 1293f8a71f9..0ade0f20790 100644 --- a/.coveragerc +++ b/.coveragerc @@ -254,6 +254,13 @@ omit = homeassistant/components/fibaro/* homeassistant/components/filesize/sensor.py homeassistant/components/fints/sensor.py + homeassistant/components/firmata/__init__.py + homeassistant/components/firmata/binary_sensor.py + homeassistant/components/firmata/board.py + homeassistant/components/firmata/const.py + homeassistant/components/firmata/entity.py + homeassistant/components/firmata/pin.py + homeassistant/components/firmata/switch.py homeassistant/components/fitbit/sensor.py homeassistant/components/fixer/sensor.py homeassistant/components/fleetgo/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index 2d76eec1511..44345be6b37 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -128,6 +128,7 @@ homeassistant/components/ezviz/* @baqs homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes +homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff homeassistant/components/flick_electric/* @ZephireNZ homeassistant/components/flock/* @fabaff diff --git a/homeassistant/components/firmata/__init__.py b/homeassistant/components/firmata/__init__.py new file mode 100644 index 00000000000..b64a88cbf57 --- /dev/null +++ b/homeassistant/components/firmata/__init__.py @@ -0,0 +1,191 @@ +"""Support for Arduino-compatible Microcontrollers through Firmata.""" +import asyncio +from copy import copy +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv, device_registry as dr + +from .board import FirmataBoard +from .const import ( + CONF_ARDUINO_INSTANCE_ID, + CONF_ARDUINO_WAIT, + CONF_BINARY_SENSORS, + CONF_INITIAL_STATE, + CONF_NEGATE_STATE, + CONF_PIN, + CONF_PIN_MODE, + CONF_SAMPLING_INTERVAL, + CONF_SERIAL_BAUD_RATE, + CONF_SERIAL_PORT, + CONF_SLEEP_TUNE, + CONF_SWITCHES, + DOMAIN, + FIRMATA_MANUFACTURER, + PIN_MODE_INPUT, + PIN_MODE_OUTPUT, + PIN_MODE_PULLUP, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_CONFIGS = "board_configs" + +ANALOG_PIN_SCHEMA = vol.All(cv.string, vol.Match(r"^A[0-9]+$")) + +SWITCH_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PIN): vol.Any(cv.positive_int, ANALOG_PIN_SCHEMA), + # will be analog mode in future too + vol.Required(CONF_PIN_MODE): PIN_MODE_OUTPUT, + vol.Optional(CONF_INITIAL_STATE, default=False): cv.boolean, + vol.Optional(CONF_NEGATE_STATE, default=False): cv.boolean, + }, + required=True, +) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_PIN): vol.Any(cv.positive_int, ANALOG_PIN_SCHEMA), + # will be analog mode in future too + vol.Required(CONF_PIN_MODE): vol.Any(PIN_MODE_INPUT, PIN_MODE_PULLUP), + vol.Optional(CONF_NEGATE_STATE, default=False): cv.boolean, + }, + required=True, +) + +BOARD_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_SERIAL_PORT): cv.string, + vol.Optional(CONF_SERIAL_BAUD_RATE): cv.positive_int, + vol.Optional(CONF_ARDUINO_INSTANCE_ID): cv.positive_int, + vol.Optional(CONF_ARDUINO_WAIT): cv.positive_int, + vol.Optional(CONF_SLEEP_TUNE): vol.All( + vol.Coerce(float), vol.Range(min=0.0001) + ), + vol.Optional(CONF_SAMPLING_INTERVAL): cv.positive_int, + vol.Optional(CONF_SWITCHES): [SWITCH_SCHEMA], + vol.Optional(CONF_BINARY_SENSORS): [BINARY_SENSOR_SCHEMA], + }, + required=True, +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [BOARD_CONFIG_SCHEMA])}, extra=vol.ALLOW_EXTRA +) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the Firmata domain.""" + # Delete specific entries that no longer exist in the config + if hass.config_entries.async_entries(DOMAIN): + for entry in hass.config_entries.async_entries(DOMAIN): + remove = True + for board in config[DOMAIN]: + if entry.data[CONF_SERIAL_PORT] == board[CONF_SERIAL_PORT]: + remove = False + break + if remove: + await hass.config_entries.async_remove(entry.entry_id) + + # Setup new entries and update old entries + for board in config[DOMAIN]: + firmata_config = copy(board) + existing_entry = False + for entry in hass.config_entries.async_entries(DOMAIN): + if board[CONF_SERIAL_PORT] == entry.data[CONF_SERIAL_PORT]: + existing_entry = True + firmata_config[CONF_NAME] = entry.data[CONF_NAME] + hass.config_entries.async_update_entry(entry, data=firmata_config) + break + if not existing_entry: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=firmata_config, + ) + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Set up a Firmata board for a config entry.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + + _LOGGER.debug( + "Setting up Firmata id %s, name %s, config %s", + config_entry.entry_id, + config_entry.data[CONF_NAME], + config_entry.data, + ) + + board = FirmataBoard(config_entry.data) + + if not await board.async_setup(): + return False + + hass.data[DOMAIN][config_entry.entry_id] = board + + async def handle_shutdown(event) -> None: + """Handle shutdown of board when Home Assistant shuts down.""" + # Ensure board was not already removed previously before shutdown + if config_entry.entry_id in hass.data[DOMAIN]: + await board.async_reset() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_shutdown) + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={}, + identifiers={(DOMAIN, board.name)}, + manufacturer=FIRMATA_MANUFACTURER, + name=board.name, + sw_version=board.firmware_version, + ) + + if CONF_BINARY_SENSORS in config_entry.data: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") + ) + if CONF_SWITCHES in config_entry.data: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "switch") + ) + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Shutdown and close a Firmata board for a config entry.""" + _LOGGER.debug("Closing Firmata board %s", config_entry.data[CONF_NAME]) + + unload_entries = [] + if CONF_BINARY_SENSORS in config_entry.data: + unload_entries.append( + hass.config_entries.async_forward_entry_unload( + config_entry, "binary_sensor" + ) + ) + if CONF_SWITCHES in config_entry.data: + unload_entries.append( + hass.config_entries.async_forward_entry_unload(config_entry, "switch") + ) + results = [] + if unload_entries: + results = await asyncio.gather(*unload_entries) + results.append(await hass.data[DOMAIN].pop(config_entry.entry_id).async_reset()) + + return False not in results diff --git a/homeassistant/components/firmata/binary_sensor.py b/homeassistant/components/firmata/binary_sensor.py new file mode 100644 index 00000000000..4576b8dc69e --- /dev/null +++ b/homeassistant/components/firmata/binary_sensor.py @@ -0,0 +1,59 @@ +"""Support for Firmata binary sensor input.""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import CONF_NEGATE_STATE, CONF_PIN, CONF_PIN_MODE, DOMAIN +from .entity import FirmataPinEntity +from .pin import FirmataBinaryDigitalInput, FirmataPinUsedException + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Firmata binary sensors.""" + new_entities = [] + + board = hass.data[DOMAIN][config_entry.entry_id] + for binary_sensor in board.binary_sensors: + pin = binary_sensor[CONF_PIN] + pin_mode = binary_sensor[CONF_PIN_MODE] + negate = binary_sensor[CONF_NEGATE_STATE] + api = FirmataBinaryDigitalInput(board, pin, pin_mode, negate) + try: + api.setup() + except FirmataPinUsedException: + _LOGGER.error( + "Could not setup binary sensor on pin %s since pin already in use.", + binary_sensor[CONF_PIN], + ) + continue + name = binary_sensor[CONF_NAME] + binary_sensor_entity = FirmataBinarySensor(api, config_entry, name, pin) + new_entities.append(binary_sensor_entity) + + if new_entities: + async_add_entities(new_entities) + + +class FirmataBinarySensor(FirmataPinEntity, BinarySensorEntity): + """Representation of a binary sensor on a Firmata board.""" + + async def async_added_to_hass(self) -> None: + """Set up a binary sensor.""" + await self._api.start_pin(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Stop reporting a binary sensor.""" + await self._api.stop_pin() + + @property + def is_on(self) -> bool: + """Return true if binary sensor is on.""" + return self._api.is_on diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py new file mode 100644 index 00000000000..bae30014d63 --- /dev/null +++ b/homeassistant/components/firmata/board.py @@ -0,0 +1,144 @@ +"""Code to handle a Firmata board.""" +import logging +from typing import Union + +from pymata_express.pymata_express import PymataExpress +from pymata_express.pymata_express_serial import serial + +from homeassistant.const import CONF_NAME + +from .const import ( + CONF_ARDUINO_INSTANCE_ID, + CONF_ARDUINO_WAIT, + CONF_BINARY_SENSORS, + CONF_SAMPLING_INTERVAL, + CONF_SERIAL_BAUD_RATE, + CONF_SERIAL_PORT, + CONF_SLEEP_TUNE, + CONF_SWITCHES, +) + +_LOGGER = logging.getLogger(__name__) + +FirmataPinType = Union[int, str] + + +class FirmataBoard: + """Manages a single Firmata board.""" + + def __init__(self, config: dict): + """Initialize the board.""" + self.config = config + self.api = None + self.firmware_version = None + self.protocol_version = None + self.name = self.config[CONF_NAME] + self.switches = [] + self.binary_sensors = [] + self.used_pins = [] + + if CONF_SWITCHES in self.config: + self.switches = self.config[CONF_SWITCHES] + if CONF_BINARY_SENSORS in self.config: + self.binary_sensors = self.config[CONF_BINARY_SENSORS] + + async def async_setup(self, tries=0) -> bool: + """Set up a Firmata instance.""" + try: + _LOGGER.debug("Connecting to Firmata %s", self.name) + self.api = await get_board(self.config) + except RuntimeError as err: + _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) + return False + except serial.serialutil.SerialTimeoutException as err: + _LOGGER.error( + "Timeout writing to serial port for PyMata board %s: %s", self.name, err + ) + return False + except serial.serialutil.SerialException as err: + _LOGGER.error( + "Error connecting to serial port for PyMata board %s: %s", + self.name, + err, + ) + return False + + self.firmware_version = await self.api.get_firmware_version() + if not self.firmware_version: + _LOGGER.error( + "Error retrieving firmware version from Firmata board %s", self.name + ) + return False + + if CONF_SAMPLING_INTERVAL in self.config: + try: + await self.api.set_sampling_interval( + self.config[CONF_SAMPLING_INTERVAL] + ) + except RuntimeError as err: + _LOGGER.error( + "Error setting sampling interval for PyMata \ +board %s: %s", + self.name, + err, + ) + return False + + _LOGGER.debug("Firmata connection successful for %s", self.name) + return True + + async def async_reset(self) -> bool: + """Reset the board to default state.""" + _LOGGER.debug("Shutting down board %s", self.name) + # If the board was never setup, continue. + if self.api is None: + return True + + await self.api.shutdown() + self.api = None + + return True + + def mark_pin_used(self, pin: FirmataPinType) -> bool: + """Test if a pin is used already on the board or mark as used.""" + if pin in self.used_pins: + return False + self.used_pins.append(pin) + return True + + def get_pin_type(self, pin: FirmataPinType) -> tuple: + """Return the type and Firmata location of a pin on the board.""" + if isinstance(pin, str): + pin_type = "analog" + firmata_pin = int(pin[1:]) + firmata_pin += self.api.first_analog_pin + else: + pin_type = "digital" + firmata_pin = pin + return (pin_type, firmata_pin) + + +async def get_board(data: dict) -> PymataExpress: + """Create a Pymata board object.""" + board_data = {} + + if CONF_SERIAL_PORT in data: + board_data["com_port"] = data[CONF_SERIAL_PORT] + if CONF_SERIAL_BAUD_RATE in data: + board_data["baud_rate"] = data[CONF_SERIAL_BAUD_RATE] + if CONF_ARDUINO_INSTANCE_ID in data: + board_data["arduino_instance_id"] = data[CONF_ARDUINO_INSTANCE_ID] + + if CONF_ARDUINO_WAIT in data: + board_data["arduino_wait"] = data[CONF_ARDUINO_WAIT] + if CONF_SLEEP_TUNE in data: + board_data["sleep_tune"] = data[CONF_SLEEP_TUNE] + + board_data["autostart"] = False + board_data["shutdown_on_exception"] = True + board_data["close_loop_on_shutdown"] = False + + board = PymataExpress(**board_data) + + await board.start_aio() + return board diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py new file mode 100644 index 00000000000..a86d97e9e2e --- /dev/null +++ b/homeassistant/components/firmata/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow to configure firmata component.""" + +import logging + +from pymata_express.pymata_express_serial import serial + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME + +from .board import get_board +from .const import CONF_SERIAL_PORT, DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a firmata config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_import(self, import_config: dict): + """Import a firmata board as a config entry. + + This flow is triggered by `async_setup` for configured boards. + + This will execute for any board that does not have a + config entry yet (based on entry_id). It validates a connection + and then adds the entry. + """ + name = f"serial-{import_config[CONF_SERIAL_PORT]}" + import_config[CONF_NAME] = name + + # Connect to the board to verify connection and then shutdown + # If either fail then we cannot continue + _LOGGER.debug("Connecting to Firmata board %s to test connection", name) + try: + api = await get_board(import_config) + await api.shutdown() + except RuntimeError as err: + _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) + return self.async_abort(reason="cannot_connect") + except serial.serialutil.SerialTimeoutException as err: + _LOGGER.error( + "Timeout writing to serial port for PyMata board %s: %s", name, err + ) + return self.async_abort(reason="cannot_connect") + except serial.serialutil.SerialException as err: + _LOGGER.error( + "Error connecting to serial port for PyMata board %s: %s", name, err + ) + return self.async_abort(reason="cannot_connect") + _LOGGER.debug("Connection test to Firmata board %s successful", name) + + return self.async_create_entry( + title=import_config[CONF_NAME], data=import_config + ) diff --git a/homeassistant/components/firmata/const.py b/homeassistant/components/firmata/const.py new file mode 100644 index 00000000000..1ad3cbb8423 --- /dev/null +++ b/homeassistant/components/firmata/const.py @@ -0,0 +1,24 @@ +"""Constants for the Firmata component.""" +import logging + +LOGGER = logging.getLogger(__package__) + +CONF_ARDUINO_INSTANCE_ID = "arduino_instance_id" +CONF_ARDUINO_WAIT = "arduino_wait" +CONF_BINARY_SENSORS = "binary_sensors" +CONF_INITIAL_STATE = "initial" +CONF_NAME = "name" +CONF_NEGATE_STATE = "negate" +CONF_PIN = "pin" +CONF_PINS = "pins" +CONF_PIN_MODE = "pin_mode" +PIN_MODE_OUTPUT = "OUTPUT" +PIN_MODE_INPUT = "INPUT" +PIN_MODE_PULLUP = "PULLUP" +CONF_SAMPLING_INTERVAL = "sampling_interval" +CONF_SERIAL_BAUD_RATE = "serial_baud_rate" +CONF_SERIAL_PORT = "serial_port" +CONF_SLEEP_TUNE = "sleep_tune" +CONF_SWITCHES = "switches" +DOMAIN = "firmata" +FIRMATA_MANUFACTURER = "Firmata" diff --git a/homeassistant/components/firmata/entity.py b/homeassistant/components/firmata/entity.py new file mode 100644 index 00000000000..50ab58b9046 --- /dev/null +++ b/homeassistant/components/firmata/entity.py @@ -0,0 +1,60 @@ +"""Entity for Firmata devices.""" +from typing import Type + +from homeassistant.config_entries import ConfigEntry + +from .board import FirmataPinType +from .const import DOMAIN, FIRMATA_MANUFACTURER +from .pin import FirmataBoardPin + + +class FirmataEntity: + """Representation of a Firmata entity.""" + + def __init__(self, api): + """Initialize the entity.""" + self._api = api + + @property + def device_info(self) -> dict: + """Return device info.""" + return { + "connections": {}, + "identifiers": {(DOMAIN, self._api.board.name)}, + "manufacturer": FIRMATA_MANUFACTURER, + "name": self._api.board.name, + "sw_version": self._api.board.firmware_version, + } + + +class FirmataPinEntity(FirmataEntity): + """Representation of a Firmata pin entity.""" + + def __init__( + self, + api: Type[FirmataBoardPin], + config_entry: ConfigEntry, + name: str, + pin: FirmataPinType, + ): + """Initialize the pin entity.""" + super().__init__(api) + self._name = name + + location = (config_entry.entry_id, "pin", pin) + self._unique_id = "_".join(str(i) for i in location) + + @property + def name(self) -> str: + """Get the name of the pin.""" + return self._name + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique identifier for this device.""" + return self._unique_id diff --git a/homeassistant/components/firmata/manifest.json b/homeassistant/components/firmata/manifest.json new file mode 100644 index 00000000000..d894c0a440b --- /dev/null +++ b/homeassistant/components/firmata/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "firmata", + "name": "Firmata", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/firmata", + "requirements": [ + "pymata-express==1.13" + ], + "codeowners": [ + "@DaAwesomeP" + ] +} \ No newline at end of file diff --git a/homeassistant/components/firmata/pin.py b/homeassistant/components/firmata/pin.py new file mode 100644 index 00000000000..644986fb66c --- /dev/null +++ b/homeassistant/components/firmata/pin.py @@ -0,0 +1,153 @@ +"""Code to handle pins on a Firmata board.""" +import logging +from typing import Callable + +from homeassistant.core import callback + +from .board import FirmataBoard, FirmataPinType +from .const import PIN_MODE_INPUT, PIN_MODE_PULLUP + +_LOGGER = logging.getLogger(__name__) + + +class FirmataPinUsedException(Exception): + """Represents an exception when a pin is already in use.""" + + +class FirmataBoardPin: + """Manages a single Firmata board pin.""" + + def __init__(self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str): + """Initialize the pin.""" + self.board = board + self._pin = pin + self._pin_mode = pin_mode + self._pin_type, self._firmata_pin = self.board.get_pin_type(self._pin) + self._state = None + + def setup(self): + """Set up a pin and make sure it is valid.""" + if not self.board.mark_pin_used(self._pin): + raise FirmataPinUsedException(f"Pin {self._pin} already used!") + + +class FirmataBinaryDigitalOutput(FirmataBoardPin): + """Representation of a Firmata Digital Output Pin.""" + + def __init__( + self, + board: FirmataBoard, + pin: FirmataPinType, + pin_mode: str, + initial: bool, + negate: bool, + ): + """Initialize the digital output pin.""" + self._initial = initial + self._negate = negate + super().__init__(board, pin, pin_mode) + + async def start_pin(self) -> None: + """Set initial state on a pin.""" + _LOGGER.debug( + "Setting initial state for digital output pin %s on board %s", + self._pin, + self.board.name, + ) + api = self.board.api + # Only PIN_MODE_OUTPUT mode is supported as binary digital output + await api.set_pin_mode_digital_output(self._firmata_pin) + + if self._initial: + new_pin_state = not self._negate + else: + new_pin_state = self._negate + await api.digital_pin_write(self._firmata_pin, int(new_pin_state)) + self._state = self._initial + + @property + def is_on(self) -> bool: + """Return true if digital output is on.""" + return self._state + + async def turn_on(self) -> None: + """Turn on digital output.""" + _LOGGER.debug("Turning digital output on pin %s on", self._pin) + new_pin_state = not self._negate + await self.board.api.digital_pin_write(self._firmata_pin, int(new_pin_state)) + self._state = True + + async def turn_off(self) -> None: + """Turn off digital output.""" + _LOGGER.debug("Turning digital output on pin %s off", self._pin) + new_pin_state = self._negate + await self.board.api.digital_pin_write(self._firmata_pin, int(new_pin_state)) + self._state = False + + +class FirmataBinaryDigitalInput(FirmataBoardPin): + """Representation of a Firmata Digital Input Pin.""" + + def __init__( + self, board: FirmataBoard, pin: FirmataPinType, pin_mode: str, negate: bool + ): + """Initialize the digital input pin.""" + self._negate = negate + self._forward_callback = None + super().__init__(board, pin, pin_mode) + + async def start_pin(self, forward_callback: Callable[[], None]) -> None: + """Get initial state and start reporting a pin.""" + _LOGGER.debug( + "Starting reporting updates for input pin %s on board %s", + self._pin, + self.board.name, + ) + self._forward_callback = forward_callback + api = self.board.api + if self._pin_mode == PIN_MODE_INPUT: + await api.set_pin_mode_digital_input(self._pin, self.latch_callback) + elif self._pin_mode == PIN_MODE_PULLUP: + await api.set_pin_mode_digital_input_pullup(self._pin, self.latch_callback) + + new_state = bool((await self.board.api.digital_read(self._firmata_pin))[0]) + if self._negate: + new_state = not new_state + self._state = new_state + + await api.enable_digital_reporting(self._pin) + self._forward_callback() + + async def stop_pin(self) -> None: + """Stop reporting digital input pin.""" + _LOGGER.debug( + "Stopping reporting updates for digital input pin %s on board %s", + self._pin, + self.board.name, + ) + api = self.board.api + await api.disable_digital_reporting(self._pin) + + @property + def is_on(self) -> bool: + """Return true if digital input is on.""" + return self._state + + @callback + async def latch_callback(self, data: list) -> None: + """Update pin state on callback.""" + if data[1] != self._firmata_pin: + return + _LOGGER.debug( + "Received latch %d for digital input pin %d on board %s", + data[2], + self._firmata_pin, + self.board.name, + ) + new_state = bool(data[2]) + if self._negate: + new_state = not new_state + if self._state == new_state: + return + self._state = new_state + self._forward_callback() diff --git a/homeassistant/components/firmata/strings.json b/homeassistant/components/firmata/strings.json new file mode 100644 index 00000000000..68d7ae8c041 --- /dev/null +++ b/homeassistant/components/firmata/strings.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "cannot_connect": "Cannot connect to Firmata board during setup" + }, + "step": {} + } +} diff --git a/homeassistant/components/firmata/switch.py b/homeassistant/components/firmata/switch.py new file mode 100644 index 00000000000..ab67a6d6840 --- /dev/null +++ b/homeassistant/components/firmata/switch.py @@ -0,0 +1,75 @@ +"""Support for Firmata switch output.""" + +import logging + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_INITIAL_STATE, + CONF_NEGATE_STATE, + CONF_PIN, + CONF_PIN_MODE, + DOMAIN, +) +from .entity import FirmataPinEntity +from .pin import FirmataBinaryDigitalOutput, FirmataPinUsedException + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Firmata switches.""" + new_entities = [] + + board = hass.data[DOMAIN][config_entry.entry_id] + for switch in board.switches: + pin = switch[CONF_PIN] + pin_mode = switch[CONF_PIN_MODE] + initial = switch[CONF_INITIAL_STATE] + negate = switch[CONF_NEGATE_STATE] + api = FirmataBinaryDigitalOutput(board, pin, pin_mode, initial, negate) + try: + api.setup() + except FirmataPinUsedException: + _LOGGER.error( + "Could not setup switch on pin %s since pin already in use.", + switch[CONF_PIN], + ) + continue + name = switch[CONF_NAME] + switch_entity = FirmataSwitch(api, config_entry, name, pin) + new_entities.append(switch_entity) + + if new_entities: + async_add_entities(new_entities) + + +class FirmataSwitch(FirmataPinEntity, SwitchEntity): + """Representation of a switch on a Firmata board.""" + + async def async_added_to_hass(self) -> None: + """Set up a switch.""" + await self._api.start_pin() + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if switch is on.""" + return self._api.is_on + + async def async_turn_on(self, **kwargs) -> None: + """Turn on switch.""" + _LOGGER.debug("Turning switch %s on", self._name) + await self._api.turn_on() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off switch.""" + _LOGGER.debug("Turning switch %s off", self._name) + await self._api.turn_off() + self.async_write_ha_state() diff --git a/homeassistant/components/firmata/translations/en.json b/homeassistant/components/firmata/translations/en.json new file mode 100644 index 00000000000..68d7ae8c041 --- /dev/null +++ b/homeassistant/components/firmata/translations/en.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "cannot_connect": "Cannot connect to Firmata board during setup" + }, + "step": {} + } +} diff --git a/requirements_all.txt b/requirements_all.txt index 2d2e22f5983..3f572bb8f8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1445,6 +1445,9 @@ pylutron==0.2.5 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.firmata +pymata-express==1.13 + # homeassistant.components.mediaroom pymediaroom==0.6.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ecf468ca6b4..c3aae1e3b80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -670,6 +670,9 @@ pylutron-caseta==0.6.1 # homeassistant.components.mailgun pymailgunner==1.4 +# homeassistant.components.firmata +pymata-express==1.13 + # homeassistant.components.melcloud pymelcloud==2.5.2 diff --git a/tests/components/firmata/__init__.py b/tests/components/firmata/__init__.py new file mode 100644 index 00000000000..48e58cf5c36 --- /dev/null +++ b/tests/components/firmata/__init__.py @@ -0,0 +1 @@ +"""Tests for the Firmata integration.""" diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py new file mode 100644 index 00000000000..e77f219e320 --- /dev/null +++ b/tests/components/firmata/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the Firmata config flow.""" +from pymata_express.pymata_express_serial import serial + +from homeassistant import config_entries, setup +from homeassistant.components.firmata.const import CONF_SERIAL_PORT, DOMAIN +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch + + +async def test_import_cannot_connect_pymata(hass: HomeAssistant) -> None: + """Test we fail with an invalid board.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress.start_aio", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: + """Test we fail with an invalid board.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress.start_aio", + side_effect=serial.serialutil.SerialException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None: + """Test we fail with an invalid board.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress.start_aio", + side_effect=serial.serialutil.SerialTimeoutException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test we create an entry from config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "homeassistant.components.firmata.board.PymataExpress", autospec=True + ), patch( + "homeassistant.components.firmata.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.firmata.async_setup_entry", return_value=True + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_SERIAL_PORT: "/dev/nonExistent"}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "serial-/dev/nonExistent" + assert result["data"] == { + CONF_NAME: "serial-/dev/nonExistent", + CONF_SERIAL_PORT: "/dev/nonExistent", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From a6129467aae0ffab6da716216fe5a45694a7a4b5 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 16 Jul 2020 18:10:36 -0700 Subject: [PATCH 015/362] Add RGB light support to ozw (#37636) --- homeassistant/components/ozw/discovery.py | 2 + homeassistant/components/ozw/light.py | 136 ++++++- tests/components/ozw/conftest.py | 46 +++ tests/components/ozw/test_light.py | 334 +++++++++++++++++- .../fixtures/ozw/light_no_cw_network_dump.csv | 54 +++ tests/fixtures/ozw/light_no_rgb.json | 25 ++ .../ozw/light_no_rgb_network_dump.csv | 41 +++ .../fixtures/ozw/light_no_ww_network_dump.csv | 54 +++ tests/fixtures/ozw/light_rgb.json | 25 ++ tests/fixtures/ozw/light_wc_network_dump.csv | 54 +++ 10 files changed, 766 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/ozw/light_no_cw_network_dump.csv create mode 100644 tests/fixtures/ozw/light_no_rgb.json create mode 100644 tests/fixtures/ozw/light_no_rgb_network_dump.csv create mode 100644 tests/fixtures/ozw/light_no_ww_network_dump.csv create mode 100644 tests/fixtures/ozw/light_rgb.json create mode 100644 tests/fixtures/ozw/light_wc_network_dump.csv diff --git a/homeassistant/components/ozw/discovery.py b/homeassistant/components/ozw/discovery.py index 2eaaa3a2714..adcb102b7fe 100644 --- a/homeassistant/components/ozw/discovery.py +++ b/homeassistant/components/ozw/discovery.py @@ -194,6 +194,8 @@ DISCOVERY_SCHEMAS = ( const.DISC_SPECIFIC_DEVICE_CLASS: ( const_ozw.SPECIFIC_TYPE_POWER_SWITCH_MULTILEVEL, const_ozw.SPECIFIC_TYPE_SCENE_SWITCH_MULTILEVEL, + const_ozw.SPECIFIC_TYPE_COLOR_TUNABLE_BINARY, + const_ozw.SPECIFIC_TYPE_COLOR_TUNABLE_MULTILEVEL, const_ozw.SPECIFIC_TYPE_NOT_USED, ), const.DISC_VALUES: { diff --git a/homeassistant/components/ozw/light.py b/homeassistant/components/ozw/light.py index 640f675612c..c985a5b7f41 100644 --- a/homeassistant/components/ozw/light.py +++ b/homeassistant/components/ozw/light.py @@ -3,20 +3,37 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, ATTR_TRANSITION, + ATTR_WHITE_VALUE, DOMAIN as LIGHT_DOMAIN, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, + SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, + SUPPORT_WHITE_VALUE, LightEntity, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.color as color_util from .const import DATA_UNSUBSCRIBE, DOMAIN from .entity import ZWaveDeviceEntity _LOGGER = logging.getLogger(__name__) +ATTR_VALUE = "Value" +COLOR_CHANNEL_WARM_WHITE = 0x01 +COLOR_CHANNEL_COLD_WHITE = 0x02 +COLOR_CHANNEL_RED = 0x04 +COLOR_CHANNEL_GREEN = 0x08 +COLOR_CHANNEL_BLUE = 0x10 +TEMP_COLOR_MAX = 500 # mireds (inverted) +TEMP_COLOR_MIN = 154 +TEMP_COLOR_DIFF = TEMP_COLOR_MAX - TEMP_COLOR_MIN + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Z-Wave Light from Config Entry.""" @@ -24,7 +41,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_light(values): """Add Z-Wave Light.""" - light = ZwaveDimmer(values) + light = ZwaveLight(values) + async_add_entities([light]) hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( @@ -42,12 +60,16 @@ def byte_to_zwave_brightness(value): return 0 -class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): - """Representation of a Z-Wave dimmer.""" +class ZwaveLight(ZWaveDeviceEntity, LightEntity): + """Representation of a Z-Wave light.""" def __init__(self, values): """Initialize the light.""" super().__init__(values) + self._color_channels = None + self._hs = None + self._white = None + self._ct = None self._supported_features = SUPPORT_BRIGHTNESS # make sure that supported features is correctly set self.on_value_update() @@ -55,10 +77,29 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): @callback def on_value_update(self): """Call when the underlying value(s) is added or updated.""" - self._supported_features = SUPPORT_BRIGHTNESS if self.values.dimming_duration is not None: self._supported_features |= SUPPORT_TRANSITION + if self.values.color is None and self.values.color_channels is None: + return + + if self.values.color is not None: + self._supported_features |= SUPPORT_COLOR + + # Support Color Temp if both white channels + if (self.values.color_channels.value & COLOR_CHANNEL_WARM_WHITE) and ( + self.values.color_channels.value & COLOR_CHANNEL_COLD_WHITE + ): + self._supported_features |= SUPPORT_COLOR_TEMP + + # Support White value if only a single white channel + if ((self.values.color_channels.value & COLOR_CHANNEL_WARM_WHITE) != 0) ^ ( + (self.values.color_channels.value & COLOR_CHANNEL_COLD_WHITE) != 0 + ): + self._supported_features |= SUPPORT_WHITE_VALUE + + self._calculate_rgb_values() + @property def brightness(self): """Return the brightness of this light between 0..255. @@ -81,6 +122,21 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): """Flag supported features.""" return self._supported_features + @property + def hs_color(self): + """Return the hs color.""" + return self._hs + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white + + @property + def color_temp(self): + """Return the color temperature.""" + return self._ct + @callback def async_set_duration(self, **kwargs): """Set the transition time for the brightness value. @@ -118,6 +174,34 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): """Turn the device on.""" self.async_set_duration(**kwargs) + rgbw = None + white = kwargs.get(ATTR_WHITE_VALUE) + hs_color = kwargs.get(ATTR_HS_COLOR) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + + if hs_color is not None: + rgbw = "#" + for colorval in color_util.color_hs_to_RGB(*hs_color): + rgbw += f"{colorval:02x}" + rgbw += "0000" + # white LED must be off in order for color to work + + elif white is not None: + if self._color_channels & COLOR_CHANNEL_WARM_WHITE: + rgbw = f"#000000{white:02x}00" + else: + rgbw = f"#00000000{white:02x}" + + elif color_temp is not None: + cold = round((TEMP_COLOR_MAX - round(color_temp)) / TEMP_COLOR_DIFF * 255) + warm = 255 - cold + if warm < 0: + warm = 0 + rgbw = f"#000000{warm:02x}{cold:02x}" + + if rgbw and self.values.color: + self.values.color.send_value(rgbw) + # Zwave multilevel switches use a range of [0, 99] to control # brightness. Level 255 means to set it to previous value. if ATTR_BRIGHTNESS in kwargs: @@ -133,3 +217,47 @@ class ZwaveDimmer(ZWaveDeviceEntity, LightEntity): self.async_set_duration(**kwargs) self.values.primary.send_value(0) + + def _calculate_rgb_values(self): + # Color Channels + self._color_channels = self.values.color_channels.data[ATTR_VALUE] + + # Color Data String + data = self.values.color.data[ATTR_VALUE] + + # RGB is always present in the openzwave color data string. + rgb = [int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16)] + self._hs = color_util.color_RGB_to_hs(*rgb) + + # Parse remaining color channels. Openzwave appends white channels + # that are present. + index = 7 + temp_warm = 0 + temp_cold = 0 + + # Warm white + if self._color_channels & COLOR_CHANNEL_WARM_WHITE: + self._white = int(data[index : index + 2], 16) + temp_warm = self._white + + index += 2 + + # Cold white + if self._color_channels & COLOR_CHANNEL_COLD_WHITE: + self._white = int(data[index : index + 2], 16) + temp_cold = self._white + + # Calculate color temps based on white LED status + if temp_cold > 0: + self._ct = round(TEMP_COLOR_MAX - ((temp_cold / 255) * TEMP_COLOR_DIFF)) + # Only used if CW channel missing + elif temp_warm > 0: + self._ct = round(TEMP_COLOR_MAX - temp_warm) + + # If no rgb channels supported, report None. + if not ( + self._color_channels & COLOR_CHANNEL_RED + or self._color_channels & COLOR_CHANNEL_GREEN + or self._color_channels & COLOR_CHANNEL_BLUE + ): + self._hs = None diff --git a/tests/components/ozw/conftest.py b/tests/components/ozw/conftest.py index 14253e699f6..ec5610713c5 100644 --- a/tests/components/ozw/conftest.py +++ b/tests/components/ozw/conftest.py @@ -27,6 +27,30 @@ def light_data_fixture(): return load_fixture("ozw/light_network_dump.csv") +@pytest.fixture(name="light_no_rgb_data", scope="session") +def light_no_rgb_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_no_rgb_network_dump.csv") + + +@pytest.fixture(name="light_no_ww_data", scope="session") +def light_no_ww_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_no_ww_network_dump.csv") + + +@pytest.fixture(name="light_no_cw_data", scope="session") +def light_no_cw_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_no_cw_network_dump.csv") + + +@pytest.fixture(name="light_wc_data", scope="session") +def light_wc_only_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_wc_network_dump.csv") + + @pytest.fixture(name="cover_data", scope="session") def cover_data_fixture(): """Load cover MQTT data and return it.""" @@ -87,6 +111,28 @@ async def light_msg_fixture(hass): return message +@pytest.fixture(name="light_no_rgb_msg") +async def light_no_rgb_msg_fixture(hass): + """Return a mock MQTT msg with a light actuator message.""" + light_json = json.loads( + await hass.async_add_executor_job(load_fixture, "ozw/light_no_rgb.json") + ) + message = MQTTMessage(topic=light_json["topic"], payload=light_json["payload"]) + message.encode() + return message + + +@pytest.fixture(name="light_rgb_msg") +async def light_rgb_msg_fixture(hass): + """Return a mock MQTT msg with a light actuator message.""" + light_json = json.loads( + await hass.async_add_executor_job(load_fixture, "ozw/light_rgb.json") + ) + message = MQTTMessage(topic=light_json["topic"], payload=light_json["payload"]) + message.encode() + return message + + @pytest.fixture(name="switch_msg") async def switch_msg_fixture(hass): """Return a mock MQTT msg with a switch actuator message.""" diff --git a/tests/components/ozw/test_light.py b/tests/components/ozw/test_light.py index d485ca768c5..67eebdfdea7 100644 --- a/tests/components/ozw/test_light.py +++ b/tests/components/ozw/test_light.py @@ -4,7 +4,7 @@ from homeassistant.components.ozw.light import byte_to_zwave_brightness from .common import setup_ozw -async def test_light(hass, light_data, light_msg, sent_messages): +async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): """Test setting up config entry.""" receive_message = await setup_ozw(hass, fixture=light_data) @@ -149,3 +149,335 @@ async def test_light(hass, light_data, light_msg, sent_messages): state = hass.states.get("light.led_bulb_6_multi_colour_level") assert state is not None assert state.state == "off" + + # Test setting color_name + new_color = "blue" + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_name": new_color}, + blocking=True, + ) + assert len(sent_messages) == 9 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#0000ff0000", "ValueIDKey": 659341335} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#0000ff0000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["rgb_color"] == (0, 0, 255) + + # Test setting hs_color + new_color = [300, 70] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "hs_color": new_color}, + blocking=True, + ) + assert len(sent_messages) == 11 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#ff4cff0000", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#ff4cff0000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["hs_color"] == (300.0, 70.196) + + # Test setting rgb_color + new_color = [255, 154, 0] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "rgb_color": new_color}, + blocking=True, + ) + assert len(sent_messages) == 13 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#ff99000000", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#ff99000000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["rgb_color"] == (255, 153, 0) + + # Test setting xy_color + new_color = [0.52, 0.43] + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "xy_color": new_color}, + blocking=True, + ) + assert len(sent_messages) == 15 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#ffbb370000", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#ffbb370000" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["xy_color"] == (0.519, 0.429) + + # Test setting color temp + new_color = 465 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_temp": new_color}, + blocking=True, + ) + assert len(sent_messages) == 17 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#000000e51a", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#000000e51a" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["color_temp"] == 465 + + +async def test_no_rgb_light(hass, light_no_rgb_data, light_no_rgb_msg, sent_messages): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_no_rgb_data) + + # Test loaded no RGBW support (dimmer only) + state = hass.states.get("light.master_bedroom_l_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + new_brightness = 44 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.master_bedroom_l_level", "brightness": new_brightness}, + blocking=True, + ) + assert len(sent_messages) == 1 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == { + "Value": byte_to_zwave_brightness(new_brightness), + "ValueIDKey": 38371345, + } + + # Feedback on state + + light_no_rgb_msg.decode() + light_no_rgb_msg.payload["Value"] = byte_to_zwave_brightness(new_brightness) + light_no_rgb_msg.encode() + receive_message(light_no_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.master_bedroom_l_level") + assert state is not None + assert state.state == "on" + assert state.attributes["brightness"] == new_brightness + + +async def test_no_ww_light( + hass, light_no_ww_data, light_msg, light_rgb_msg, sent_messages +): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_no_ww_data) + + # Test loaded no ww support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + white_color = 190 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "white_value": white_color, + }, + blocking=True, + ) + assert len(sent_messages) == 2 + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#00000000be", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#00000000be" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["white_value"] == 190 + + +async def test_no_cw_light( + hass, light_no_cw_data, light_msg, light_rgb_msg, sent_messages +): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_no_cw_data) + + # Test loaded no cw support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + white_color = 190 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "white_value": white_color, + }, + blocking=True, + ) + assert len(sent_messages) == 2 + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#000000be00", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#000000be00" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["white_value"] == 190 + + +async def test_wc_light(hass, light_wc_data, light_msg, light_rgb_msg, sent_messages): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_wc_data) + + # Test loaded only white LED support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Turn on the light + new_color = 190 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_temp": new_color}, + blocking=True, + ) + assert len(sent_messages) == 2 + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#0000001be4", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#0000001be4" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["color_temp"] == 191 diff --git a/tests/fixtures/ozw/light_no_cw_network_dump.csv b/tests/fixtures/ozw/light_no_cw_network_dump.csv new file mode 100644 index 00000000000..4120bc34dce --- /dev/null +++ b/tests/fixtures/ozw/light_no_cw_network_dump.csv @@ -0,0 +1,54 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 29, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#0000000000", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_no_rgb.json b/tests/fixtures/ozw/light_no_rgb.json new file mode 100644 index 00000000000..85226b8a71a --- /dev/null +++ b/tests/fixtures/ozw/light_no_rgb.json @@ -0,0 +1,25 @@ +{ + "topic": "OpenZWave/1/node/2/instance/1/commandclass/38/value/38371345/", + "payload": { + "Label": "Level", + "Value": 0, + "Units": "", + "Min": 0, + "Max": 255, + "Type": "Byte", + "Instance": 1, + "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", + "Index": 0, + "Node": 2, + "Genre": "User", + "Help": "The Current Level of the Device", + "ValueIDKey": 38371345, + "ReadOnly": false, + "WriteOnly": false, + "ValueSet": false, + "ValuePolled": false, + "ChangeVerified": false, + "Event": "valueAdded", + "TimeStamp": 1579566891 + } +} diff --git a/tests/fixtures/ozw/light_no_rgb_network_dump.csv b/tests/fixtures/ozw/light_no_rgb_network_dump.csv new file mode 100644 index 00000000000..6febaab3667 --- /dev/null +++ b/tests/fixtures/ozw/light_no_rgb_network_dump.csv @@ -0,0 +1,41 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/2/,{ "NodeID": 2, "NodeQueryStage": "Complete", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0063:3031:4944", "ZWAProductURL": "", "ProductPic": "images/ge/12724-dimmer.png", "Description": "Transform any home into a smart home with the GE Z-Wave Smart Fan Control. The in-wall fan control easily replaces any standard in-wall switch remotely controls a ceiling fan in your home and features a three-speed control system. Your home will be equipped with ultimate flexibility with the GE Z-Wave Smart Fan Control, capable of being used by itself or with up to four GE add-on switches. Screw terminal installation provides improved space efficiency when replacing existing switches and the integrated LED indicator light allows you to easily locate the switch in a dark room. The GE Z-Wave Smart Fan Control is compatible with any Z-Wave certified gateway, providing access to many popular home automation systems. Take control of your home lighting with GE Z-Wave Smart Lighting Controls!", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2506/Binder2.pdf", "ProductPageURL": "http://www.ezzwave.com", "InclusionHelp": "1. Follow the instructions for your Z-Wave certified controller to include a device to the Z-Wave network. 2. Once the controller is ready to include your device, press and release the top or bottom of the smart fan control switch (rocker) to include it in the network. 3. Once your controller has confirmed the device has been included, refresh the Z-Wave network to optimize performance.", "ExclusionHelp": "1. Follow the instructions for your Z-Wave certified controller to exclude a device from the Z-Wave network. 2. Once the controller is ready to Exclude your device, press and release the top or bottom of the wireless smart switch (rocker) to exclude it from the network.", "ResetHelp": "1. Quickly press ON (Top) button three (3) times then immediately press the OFF (Bottom) button three (3) times. The LED will flash ON/OFF 5 times when completed successfully. Note: This should only be used in the event your network’s primary controller is missing or otherwise inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "In-Wall Smart Fan Control", "ProductPicBase64": "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" }, "Event": "nodeQueriesComplete", "TimeStamp": 1594407756, "NodeManufacturerName": "GE (Jasco Products)", "NodeProductName": "12724 3-Way Dimmer Switch", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0063", "NodeProductType": "0x4944", "NodeProductID": "0x3031", "NodeBaudRate": 40000, "NodeVersion": 4, "NodeGroups": 0, "NodeName": "Master_Bedroom_L", "NodeLocation": "Master Bedroom", "NodeDeviceTypeString": "Unknown Type (0x0000)", "NodeDeviceType": 0, "NodeRole": 0, "NodeRoleString": "Central Controller", "NodePlusType": 0, "NodePlusTypeString": "Z-Wave+ node", "Neighbors": [ 1, 6, 9, 10, 11, 12, 15, 16, 17, 18, 19, 20, 21, 23, 24, 26, 27, 28, 29, 30, 33 ], "Neighbors": [ 1, 6, 9, 10, 11, 12, 15, 16, 17, 18, 19, 20, 21, 23, 24, 26, 27, 28, 29, 30, 33 ]} +OpenZWave/1/node/2/instance/1/,{ "Instance": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/112/value/844424973910036/,{ "Label": "LED Light", "Value": { "List": [ { "Value": 0, "Label": "LED on when light off" }, { "Value": 1, "Label": "LED on when light on" }, { "Value": 2, "Label": "LED always off" } ], "Selected": "LED on when light on", "Selected_id": 1 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 2, "Genre": "Config", "Help": "Sets when the LED on the switch is lit.", "ValueIDKey": 844424973910036, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407754} +OpenZWave/1/node/2/instance/1/commandclass/112/value/1125899950620692/,{ "Label": "Invert Switch", "Value": { "List": [ { "Value": 0, "Label": "No" }, { "Value": 1, "Label": "Yes" } ], "Selected": "No", "Selected_id": 0 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 2, "Genre": "Config", "Help": "Change the top of the switch to OFF and the bottom of the switch to ON, if the switch was installed upside down.", "ValueIDKey": 1125899950620692, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407754} +OpenZWave/1/node/2/instance/1/commandclass/112/value/1970324880752657/,{ "Label": "Z-Wave Command Dim Step", "Value": 1, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 99, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 7, "Node": 2, "Genre": "Config", "Help": "Indicates how many levels the dimmer will change for each dimming step.", "ValueIDKey": 1970324880752657, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/2251799857463313/,{ "Label": "Z-Wave Command Dim Rate", "Value": 1, "Units": "x 10 milliseconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 8, "Node": 2, "Genre": "Config", "Help": "This value indicates in 10 millisecond resolution, how often the dim level will change. For example, if you set this parameter to 1, then every 10ms the dim level will change. If you set it to 255, then every 2.55 seconds the dim level will change.", "ValueIDKey": 2251799857463313, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/2533274834173969/,{ "Label": "Local Control Dim Step", "Value": 1, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 99, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 9, "Node": 2, "Genre": "Config", "Help": "Indicates how many levels the dimmer will change for each dimming step.", "ValueIDKey": 2533274834173969, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/2814749810884625/,{ "Label": "Local Control Dim Rate", "Value": 5, "Units": "x 10 milliseconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 10, "Node": 2, "Genre": "Config", "Help": "This value indicates in 10 millisecond resolution, how often the dim level will change. For example, if you set this parameter to 1, then every 10ms the dim level will change. If you set it to 255, then every 2.55 seconds the dim level will change.", "ValueIDKey": 2814749810884625, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/3096224787595281/,{ "Label": "ALL ON/ALL OFF Dim Step", "Value": 99, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 99, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 11, "Node": 2, "Genre": "Config", "Help": "Indicates how many levels the dimmer will change for each dimming step.", "ValueIDKey": 3096224787595281, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407755} +OpenZWave/1/node/2/instance/1/commandclass/112/value/3377699764305937/,{ "Label": "ALL ON/ALL OFF Dim Rate", "Value": 5, "Units": "x 10 milliseconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 12, "Node": 2, "Genre": "Config", "Help": "This value indicates in 10 millisecond resolution, how often the dim level will change. For example, if you set this parameter to 1, then every 10ms the dim level will change. If you set it to 255, then every 2.55 seconds the dim level will change.", "ValueIDKey": 3377699764305937, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407756} +OpenZWave/1/node/2/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/38371345/,{ "Label": "Level", "Value": 0, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 2, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 38371345, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407637} +OpenZWave/1/node/2/instance/1/commandclass/38/value/281475015082008/,{ "Label": "Bright", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 2, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475015082008, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/562949991792664/,{ "Label": "Dim", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 2, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562949991792664, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/844424976891920/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 2, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844424976891920, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/38/value/1125899953602577/,{ "Label": "Start Level", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 2, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125899953602577, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/39/value/46776340/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled", "Selected_id": 255 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 2, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 46776340, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407636} +OpenZWave/1/node/2/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/48005139/,{ "Label": "Loaded Config Revision", "Value": 9, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 2, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 48005139, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/281475024715795/,{ "Label": "Config File Revision", "Value": 9, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 2, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475024715795, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/562950001426451/,{ "Label": "Latest Available Config File Revision", "Value": 9, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 2, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950001426451, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/844424978137111/,{ "Label": "Device ID", "Value": "", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 2, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844424978137111, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/114/value/1125899954847767/,{ "Label": "Serial Number", "Value": "", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 2, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125899954847767, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/48021524/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal", "Selected_id": 0 }, "Units": "dB", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 2, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 48021524, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407636} +OpenZWave/1/node/2/instance/1/commandclass/115/value/281475024732177/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 2, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475024732177, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594407636} +OpenZWave/1/node/2/instance/1/commandclass/115/value/562950001442840/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 2, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950001442840, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/844424978153489/,{ "Label": "Test Node", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 2, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844424978153489, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1125899954864148/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal", "Selected_id": 0 }, "Units": "dB", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 2, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125899954864148, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1407374931574806/,{ "Label": "Frame Count", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 2, "Genre": "System", "Help": "How Many Messages to send to the Node for the Test", "ValueIDKey": 1407374931574806, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1688849908285464/,{ "Label": "Test", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 2, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688849908285464, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/1970324884996120/,{ "Label": "Report", "Value": false, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 2, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970324884996120, "ReadOnly": false, "WriteOnly": true, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/2251799861706772/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed", "Selected_id": 0 }, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 2, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251799861706772, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/115/value/2533274838417430/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 2, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533274838417430, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "CommandClassVersion": 1, "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/value/48332823/,{ "Label": "Library Version", "Value": "6", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 2, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 48332823, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/value/281475025043479/,{ "Label": "Protocol Version", "Value": "3.67", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 2, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475025043479, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} +OpenZWave/1/node/2/instance/1/commandclass/134/value/562950001754135/,{ "Label": "Application Version", "Value": "3.37", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 2, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950001754135, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594407617} diff --git a/tests/fixtures/ozw/light_no_ww_network_dump.csv b/tests/fixtures/ozw/light_no_ww_network_dump.csv new file mode 100644 index 00000000000..c001750973d --- /dev/null +++ b/tests/fixtures/ozw/light_no_ww_network_dump.csv @@ -0,0 +1,54 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 30, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#0000000000", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_rgb.json b/tests/fixtures/ozw/light_rgb.json new file mode 100644 index 00000000000..0945b77db2d --- /dev/null +++ b/tests/fixtures/ozw/light_rgb.json @@ -0,0 +1,25 @@ +{ + "topic": "OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/", + "payload": { + "Label": "Color", + "Value": "#000000FF00", + "Units": "#RRGGBBWWCW", + "Min": 0, + "Max": 0, + "Type": "String", + "Instance": 1, + "CommandClass": "COMMAND_CLASS_COLOR", + "Index": 0, + "Node": 39, + "Genre": "User", + "Help": "Color (in RGB format)", + "ValueIDKey": 659341335, + "ReadOnly": false, + "WriteOnly": false, + "ValueSet": false, + "ValuePolled": false, + "ChangeVerified": false, + "Event": "valueAdded", + "TimeStamp": 1579566891 + } +} diff --git a/tests/fixtures/ozw/light_wc_network_dump.csv b/tests/fixtures/ozw/light_wc_network_dump.csv new file mode 100644 index 00000000000..8d3031b0873 --- /dev/null +++ b/tests/fixtures/ozw/light_wc_network_dump.csv @@ -0,0 +1,54 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#0000000000", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file From aaf084d713ee31867c7ffdd0172ba556561c8fd6 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Thu, 16 Jul 2020 19:25:04 -0700 Subject: [PATCH 016/362] Apply feedback on bond integration (#37921) --- homeassistant/components/bond/light.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 9a3e49952aa..f3539416742 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -26,19 +26,19 @@ async def async_setup_entry( """Set up Bond light devices.""" hub: BondHub = hass.data[DOMAIN][entry.entry_id] - lights = [ + lights: List[Entity] = [ BondLight(hub, device) for device in hub.devices if device.type == DeviceTypes.CEILING_FAN and device.supports_light() ] - async_add_entities(lights, True) - fireplaces = [ + fireplaces: List[Entity] = [ BondFireplace(hub, device) for device in hub.devices if device.type == DeviceTypes.FIREPLACE ] - async_add_entities(fireplaces, True) + + async_add_entities(lights + fireplaces, True) class BondLight(BondEntity, LightEntity): From fa4e9c0485af08d79c3ff2b9d87aa16c8b627dd6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2020 18:47:53 -1000 Subject: [PATCH 017/362] Increase test line coverage of homeassistant/helpers/event.py to 100% (#37927) * Increase test line coverage of homeassistant/helpers/event.py to 100% * fix test --- homeassistant/helpers/event.py | 7 ++-- tests/helpers/test_event.py | 70 +++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index a2dfcff7699..24110e8e63c 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -317,14 +317,13 @@ def async_track_point_in_time( hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" - utc_point_in_time = dt_util.as_utc(point_in_time) @callback def utc_converter(utc_now: datetime) -> None: """Convert passed in UTC now to local now.""" hass.async_run_job(action, dt_util.as_local(utc_now)) - return async_track_point_in_utc_time(hass, utc_converter, utc_point_in_time) + return async_track_point_in_utc_time(hass, utc_converter, point_in_time) track_point_in_time = threaded_listener_factory(async_track_point_in_time) @@ -337,13 +336,13 @@ def async_track_point_in_utc_time( ) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC - point_in_time = dt_util.as_utc(point_in_time) + utc_point_in_time = dt_util.as_utc(point_in_time) cancel_callback = hass.loop.call_at( hass.loop.time() + point_in_time.timestamp() - time.time(), hass.async_run_job, action, - point_in_time, + utc_point_in_time, ) @callback diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index b0034ebaaa6..674dca474cd 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -1,5 +1,6 @@ """Test event helpers.""" # pylint: disable=protected-access +import asyncio from datetime import datetime, timedelta from astral import Astral @@ -22,6 +23,7 @@ from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, async_track_utc_time_change, + track_point_in_utc_time, ) from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component @@ -108,7 +110,9 @@ async def test_track_state_change_from_to_state_match(hass): hass, "light.Bowl", from_and_to_state_callback, "on", "off" ) async_track_state_change(hass, "light.Bowl", only_from_state_callback, "on", None) - async_track_state_change(hass, "light.Bowl", only_to_state_callback, None, "off") + async_track_state_change( + hass, "light.Bowl", only_to_state_callback, None, ["off", "standby"] + ) async_track_state_change( hass, "light.Bowl", match_all_callback, MATCH_ALL, MATCH_ALL ) @@ -1112,3 +1116,67 @@ async def test_track_state_change_event_chain_single_entity(hass): assert len(chained_tracker_called) == 1 assert len(tracker_unsub) == 1 assert len(chained_tracker_unsub) == 2 + + +async def test_track_point_in_utc_time_cancel(hass): + """Test cancel of async track point in time.""" + + times = [] + + @ha.callback + def run_callback(utc_time): + nonlocal times + times.append(utc_time) + + def _setup_listeners(): + """Ensure we test the non-async version.""" + utc_now = dt_util.utcnow() + + with pytest.raises(TypeError): + track_point_in_utc_time("nothass", run_callback, utc_now) + + unsub1 = hass.helpers.event.track_point_in_utc_time( + run_callback, utc_now + timedelta(seconds=0.1) + ) + hass.helpers.event.track_point_in_utc_time( + run_callback, utc_now + timedelta(seconds=0.1) + ) + + unsub1() + + await hass.async_add_executor_job(_setup_listeners) + + await asyncio.sleep(0.2) + + assert len(times) == 1 + assert times[0].tzinfo == dt_util.UTC + + +async def test_async_track_point_in_time_cancel(hass): + """Test cancel of async track point in time.""" + + times = [] + hst_tz = dt_util.get_time_zone("US/Hawaii") + dt_util.set_default_time_zone(hst_tz) + + @ha.callback + def run_callback(local_time): + nonlocal times + times.append(local_time) + + utc_now = dt_util.utcnow() + hst_now = utc_now.astimezone(hst_tz) + + unsub1 = hass.helpers.event.async_track_point_in_time( + run_callback, hst_now + timedelta(seconds=0.1) + ) + hass.helpers.event.async_track_point_in_time( + run_callback, hst_now + timedelta(seconds=0.1) + ) + + unsub1() + + await asyncio.sleep(0.2) + + assert len(times) == 1 + assert times[0].tzinfo.zone == "US/Hawaii" From f5b628c04f60c11d0be748710f6f1cf3847aa60d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 16 Jul 2020 18:48:22 -1000 Subject: [PATCH 018/362] Cleanup logbook tests to prevent failure on race condition (#37928) --- tests/components/logbook/test_init.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 1ee05eb89ab..f264f75e2b0 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -2,7 +2,6 @@ # pylint: disable=protected-access,invalid-name import collections from datetime import datetime, timedelta -from functools import partial import json import logging import unittest @@ -1370,7 +1369,7 @@ async def test_logbook_view_period_entity(hass, hass_client): entity_id_second = "switch.second" hass.states.async_set(entity_id_second, STATE_OFF) hass.states.async_set(entity_id_second, STATE_ON) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1563,7 +1562,7 @@ async def test_logbook_view_end_time_entity(hass, hass_client): entity_id_second = "switch.second" hass.states.async_set(entity_id_second, STATE_OFF) hass.states.async_set(entity_id_second, STATE_ON) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1635,7 +1634,7 @@ async def test_logbook_entity_filter_with_automations(hass, hass_client): ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1701,7 +1700,7 @@ async def test_filter_continuous_sensor_values(hass, hass_client): hass.states.async_set(entity_id_third, STATE_OFF, {"unit_of_measurement": "foo"}) hass.states.async_set(entity_id_third, STATE_ON, {"unit_of_measurement": "foo"}) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1735,7 +1734,7 @@ async def test_exclude_new_entities(hass, hass_client): hass.states.async_set(entity_id2, STATE_OFF) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1776,7 +1775,7 @@ async def test_exclude_removed_entities(hass, hass_client): hass.states.async_remove(entity_id) hass.states.async_remove(entity_id2) - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) @@ -1811,11 +1810,13 @@ async def test_exclude_attribute_changes(hass, hass_client): hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 200}) hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 300}) hass.states.async_set("light.kitchen", STATE_ON, {"brightness": 400}) + hass.states.async_set("light.kitchen", STATE_OFF) await hass.async_block_till_done() - await hass.async_add_job(partial(trigger_db_commit, hass)) + await hass.async_add_job(trigger_db_commit, hass) await hass.async_block_till_done() + await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await hass_client() @@ -1828,10 +1829,12 @@ async def test_exclude_attribute_changes(hass, hass_client): assert response.status == 200 response_json = await response.json() - assert len(response_json) == 2 + assert len(response_json) == 3 assert response_json[0]["domain"] == "homeassistant" assert response_json[1]["message"] == "turned on" assert response_json[1]["entity_id"] == "light.kitchen" + assert response_json[2]["message"] == "turned off" + assert response_json[2]["entity_id"] == "light.kitchen" class MockLazyEventPartialState(ha.Event): From 7c9be024bb874252206ca1ae0f6bb3c2711ad4f7 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 17 Jul 2020 10:27:07 +0200 Subject: [PATCH 019/362] Rfxtrx use previous received event to do complete restore (#37819) * Add event attribute to display last received event * Restore state using event attribute * Allow empty dict for device config * Must also validate normal case * Do early return --- homeassistant/components/rfxtrx/__init__.py | 34 ++++++++++++++++--- .../components/rfxtrx/binary_sensor.py | 21 ++++++++++-- homeassistant/components/rfxtrx/cover.py | 7 ++-- homeassistant/components/rfxtrx/light.py | 17 ++-------- homeassistant/components/rfxtrx/sensor.py | 25 +++++++++----- homeassistant/components/rfxtrx/switch.py | 7 ++-- 6 files changed, 72 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index e4b565cd5d9..54863f86332 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( UV_INDEX, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_EVENT, @@ -92,9 +92,15 @@ def _bytearray_string(data): raise vol.Invalid("Data must be a hex string with multiple of two characters") +def _ensure_device(value): + if value is None: + return DEVICE_DATA_SCHEMA({}) + return DEVICE_DATA_SCHEMA(value) + + SERVICE_SEND_SCHEMA = vol.Schema({ATTR_EVENT: _bytearray_string}) -DEVICE_SCHEMA = vol.Schema( +DEVICE_DATA_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, @@ -110,7 +116,7 @@ BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_DEBUG, default=False): cv.boolean, vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, - vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, + vol.Optional(CONF_DEVICES, default={}): {cv.string: _ensure_device}, } ) @@ -337,7 +343,7 @@ def get_device_id(device, data_bits=None): return (f"{device.packettype:x}", f"{device.subtype:x}", id_string) -class RfxtrxDevice(Entity): +class RfxtrxDevice(RestoreEntity): """Represents a Rfxtrx device. Contains the common logic for Rfxtrx lights and switches. @@ -348,6 +354,7 @@ class RfxtrxDevice(Entity): self.signal_repetitions = signal_repetitions self._name = f"{device.type_string} {device.id_string}" self._device = device + self._event = None self._state = None self._device_id = device_id self._unique_id = "_".join(x for x in self._device_id) @@ -355,6 +362,17 @@ class RfxtrxDevice(Entity): if event: self._apply_event(event) + async def async_added_to_hass(self): + """Restore RFXtrx device state (ON/OFF).""" + if self._event: + return + + old_state = await self.async_get_last_state() + if old_state is not None: + event = old_state.attributes.get(ATTR_EVENT) + if event: + self._apply_event(get_rfx_object(event)) + @property def should_poll(self): """No polling needed for a RFXtrx switch.""" @@ -365,6 +383,13 @@ class RfxtrxDevice(Entity): """Return the name of the device if any.""" return self._name + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if not self._event: + return None + return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} + @property def is_on(self): """Return true if device is on.""" @@ -391,6 +416,7 @@ class RfxtrxDevice(Entity): def _apply_event(self, event): """Apply a received event.""" + self._event = event def _send_command(self, command, brightness=0): rfx_object = self.hass.data[DATA_RFXOBJECT] diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 8ec67d4a902..33ef6893c52 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import event as evt +from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, @@ -25,6 +26,7 @@ from . import ( get_rfx_object, ) from .const import ( + ATTR_EVENT, COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG, @@ -105,7 +107,7 @@ async def async_setup_entry( ) -class RfxtrxBinarySensor(BinarySensorEntity): +class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): """A representation of a RFXtrx binary sensor.""" def __init__( @@ -120,7 +122,7 @@ class RfxtrxBinarySensor(BinarySensorEntity): event=None, ): """Initialize the RFXtrx sensor.""" - self.event = None + self._event = None self._device = device self._name = f"{device.type_string} {device.id_string}" self._device_class = device_class @@ -141,6 +143,13 @@ class RfxtrxBinarySensor(BinarySensorEntity): """Restore RFXtrx switch device state (ON/OFF).""" await super().async_added_to_hass() + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + event = old_state.attributes.get(ATTR_EVENT) + if event: + self._apply_event(get_rfx_object(event)) + self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_EVENT, self._handle_event @@ -152,6 +161,13 @@ class RfxtrxBinarySensor(BinarySensorEntity): """Return the device name.""" return self._name + @property + def device_state_attributes(self): + """Return the device state attributes.""" + if not self._event: + return None + return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} + @property def data_bits(self): """Return the number of data bits.""" @@ -221,6 +237,7 @@ class RfxtrxBinarySensor(BinarySensorEntity): def _apply_event(self, event): """Apply command from rfxtrx.""" + self._event = event if event.device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: self._apply_event_lighting4(event) else: diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 829ff9c8110..a3cefb42cb7 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -2,7 +2,7 @@ import logging from homeassistant.components.cover import CoverEntity -from homeassistant.const import CONF_DEVICES, STATE_OPEN +from homeassistant.const import CONF_DEVICES from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity @@ -86,10 +86,6 @@ class RfxtrxCover(RfxtrxDevice, CoverEntity, RestoreEntity): """Restore RFXtrx cover device state (OPEN/CLOSE).""" await super().async_added_to_hass() - old_state = await self.async_get_last_state() - if old_state is not None: - self._state = old_state.state == STATE_OPEN - self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_EVENT, self._handle_event @@ -120,6 +116,7 @@ class RfxtrxCover(RfxtrxDevice, CoverEntity, RestoreEntity): def _apply_event(self, event): """Apply command from rfxtrx.""" + super()._apply_event(event) if event.values["Command"] in COMMAND_ON_LIST: self._state = True elif event.values["Command"] in COMMAND_OFF_LIST: diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index c248d8b8307..649be7be3fe 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -8,9 +8,8 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, LightEntity, ) -from homeassistant.const import CONF_DEVICES, STATE_ON +from homeassistant.const import CONF_DEVICES from homeassistant.core import callback -from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, @@ -93,7 +92,7 @@ async def async_setup_entry( hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, light_update) -class RfxtrxLight(RfxtrxDevice, LightEntity, RestoreEntity): +class RfxtrxLight(RfxtrxDevice, LightEntity): """Representation of a RFXtrx light.""" _brightness = 0 @@ -102,17 +101,6 @@ class RfxtrxLight(RfxtrxDevice, LightEntity, RestoreEntity): """Restore RFXtrx device state (ON/OFF).""" await super().async_added_to_hass() - old_state = await self.async_get_last_state() - if old_state is not None: - self._state = old_state.state == STATE_ON - - # Restore the brightness of dimmable devices - if ( - old_state is not None - and old_state.attributes.get(ATTR_BRIGHTNESS) is not None - ): - self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS]) - self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_EVENT, self._handle_event @@ -147,6 +135,7 @@ class RfxtrxLight(RfxtrxDevice, LightEntity, RestoreEntity): def _apply_event(self, event): """Apply command from rfxtrx.""" + super()._apply_event(event) if event.values["Command"] in COMMAND_ON_LIST: self._state = True elif event.values["Command"] in COMMAND_OFF_LIST: diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 7c540672c9a..e105c463b3b 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICES from homeassistant.core import callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, @@ -21,7 +21,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import DATA_RFXTRX_CONFIG +from .const import ATTR_EVENT, DATA_RFXTRX_CONFIG _LOGGER = logging.getLogger(__name__) @@ -113,12 +113,12 @@ async def async_setup_entry( hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, sensor_update) -class RfxtrxSensor(Entity): +class RfxtrxSensor(RestoreEntity): """Representation of a RFXtrx sensor.""" def __init__(self, device, device_id, data_type, event=None): """Initialize the sensor.""" - self.event = None + self._event = None self._device = device self._name = f"{device.type_string} {device.id_string} {data_type}" self.data_type = data_type @@ -136,6 +136,13 @@ class RfxtrxSensor(Entity): """Restore RFXtrx switch device state (ON/OFF).""" await super().async_added_to_hass() + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + event = old_state.attributes.get(ATTR_EVENT) + if event: + self._apply_event(get_rfx_object(event)) + self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_EVENT, self._handle_event @@ -149,9 +156,9 @@ class RfxtrxSensor(Entity): @property def state(self): """Return the state of the sensor.""" - if not self.event: + if not self._event: return None - value = self.event.values.get(self.data_type) + value = self._event.values.get(self.data_type) return self._convert_fun(value) @property @@ -162,9 +169,9 @@ class RfxtrxSensor(Entity): @property def device_state_attributes(self): """Return the device state attributes.""" - if not self.event: + if not self._event: return None - return self.event.values + return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} @property def unit_of_measurement(self): @@ -192,7 +199,7 @@ class RfxtrxSensor(Entity): def _apply_event(self, event): """Apply command from rfxtrx.""" - self.event = event + self._event = event @callback def _handle_event(self, event, device_id): diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index c890e162b2c..7b2a23c1624 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -4,7 +4,7 @@ import logging import RFXtrx as rfxtrxmod from homeassistant.components.switch import SwitchEntity -from homeassistant.const import CONF_DEVICES, STATE_ON +from homeassistant.const import CONF_DEVICES from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity @@ -96,10 +96,6 @@ class RfxtrxSwitch(RfxtrxDevice, SwitchEntity, RestoreEntity): """Restore RFXtrx switch device state (ON/OFF).""" await super().async_added_to_hass() - old_state = await self.async_get_last_state() - if old_state is not None: - self._state = old_state.state == STATE_ON - self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_EVENT, self._handle_event @@ -108,6 +104,7 @@ class RfxtrxSwitch(RfxtrxDevice, SwitchEntity, RestoreEntity): def _apply_event(self, event): """Apply command from rfxtrx.""" + super()._apply_event(event) if event.values["Command"] in COMMAND_ON_LIST: self._state = True elif event.values["Command"] in COMMAND_OFF_LIST: From 0297c9e6120c598a8468fff885342dc49b3dae2c Mon Sep 17 00:00:00 2001 From: Tim Messerschmidt Date: Fri, 17 Jul 2020 13:04:12 +0200 Subject: [PATCH 020/362] Fix: Passes secure parameter when setting up Nuki (#36844) (#37932) * Passes secure parameter when setting up Nuki (#36844) * Adds an additional configuration option for soft bridges instead of passing True when setting up the bridge * Revert "Adds an additional configuration option for soft bridges instead of passing True when setting up the bridge" This reverts commit af1d839ab1c130535c5a31ee60e35d3b2c151c5b. --- homeassistant/components/nuki/lock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 13825cede94..f7414d54802 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -51,7 +51,7 @@ LOCK_N_GO_SERVICE_SCHEMA = vol.Schema( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Nuki lock platform.""" bridge = NukiBridge( - config[CONF_HOST], config[CONF_TOKEN], config[CONF_PORT], DEFAULT_TIMEOUT, + config[CONF_HOST], config[CONF_TOKEN], config[CONF_PORT], True, DEFAULT_TIMEOUT, ) devices = [NukiLockEntity(lock) for lock in bridge.locks] From 24ed932b0180a1a4d226fcf49e8b90762f0c3db9 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 17 Jul 2020 14:25:16 +0200 Subject: [PATCH 021/362] Add ozw support for single setpoint thermostat devices (#37713) Co-authored-by: Paulus Schoutsen --- homeassistant/components/ozw/climate.py | 47 +++++++++++++-------- homeassistant/components/ozw/discovery.py | 31 ++++++++++++++ tests/components/ozw/test_climate.py | 45 ++++++++++++++++++++ tests/fixtures/ozw/climate_network_dump.csv | 36 ++++++++++++++++ 4 files changed, 142 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/ozw/climate.py b/homeassistant/components/ozw/climate.py index 8a524805b57..1486d98de2c 100644 --- a/homeassistant/components/ozw/climate.py +++ b/homeassistant/components/ozw/climate.py @@ -174,7 +174,8 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" if not self.values.mode: - return None + # Thermostat(valve) with no support for setting a mode is considered heating-only + return HVAC_MODE_HEAT return ZW_HVAC_MODE_MAPPINGS.get( self.values.mode.value[VALUE_SELECTED_ID], HVAC_MODE_HEAT_COOL ) @@ -197,7 +198,7 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): @property def temperature_unit(self): """Return the unit of measurement.""" - if self.values.temperature and self.values.temperature.units == "F": + if self.values.temperature is not None and self.values.temperature.units == "F": return TEMP_FAHRENHEIT return TEMP_CELSIUS @@ -220,6 +221,8 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): def preset_mode(self): """Return preset operation ie. eco, away.""" # A Zwave mode that can not be translated to a hass mode is considered a preset + if not self.values.mode: + return None if self.values.mode.value[VALUE_SELECTED_ID] not in MODES_LIST: return self.values.mode.value[VALUE_SELECTED_LABEL] return PRESET_NONE @@ -274,8 +277,14 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" + if not self.values.mode: + # Thermostat(valve) with no support for setting a mode + _LOGGER.warning( + "Thermostat %s does not support setting a mode", self.entity_id + ) + return hvac_mode_value = self._hvac_modes.get(hvac_mode) - if not hvac_mode_value: + if hvac_mode_value is None: _LOGGER.warning("Received an invalid hvac mode: %s", hvac_mode) return self.values.mode.send_value(hvac_mode_value) @@ -320,8 +329,11 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): def _get_current_mode_setpoint_values(self) -> Tuple: """Return a tuple of current setpoint Z-Wave value(s).""" - current_mode = self.values.mode.value[VALUE_SELECTED_ID] - setpoint_names = MODE_SETPOINT_MAPPINGS.get(current_mode, ()) + if not self.values.mode: + setpoint_names = ("setpoint_heating",) + else: + current_mode = self.values.mode.value[VALUE_SELECTED_ID] + setpoint_names = MODE_SETPOINT_MAPPINGS.get(current_mode, ()) # we do not want None values in our tuple so check if the value exists return tuple( getattr(self.values, value_name) @@ -331,20 +343,21 @@ class ZWaveClimateEntity(ZWaveDeviceEntity, ClimateEntity): def _set_modes_and_presets(self): """Convert Z-Wave Thermostat modes into Home Assistant modes and presets.""" - if not self.values.mode: - return all_modes = {} all_presets = {PRESET_NONE: None} - # Z-Wave uses one list for both modes and presets. - # Iterate over all Z-Wave ThermostatModes and extract the hvac modes and presets. - for val in self.values.mode.value[VALUE_LIST]: - if val[VALUE_ID] in MODES_LIST: - # treat value as hvac mode - hass_mode = ZW_HVAC_MODE_MAPPINGS.get(val[VALUE_ID]) - all_modes[hass_mode] = val[VALUE_ID] - else: - # treat value as hvac preset - all_presets[val[VALUE_LABEL]] = val[VALUE_ID] + if self.values.mode: + # Z-Wave uses one list for both modes and presets. + # Iterate over all Z-Wave ThermostatModes and extract the hvac modes and presets. + for val in self.values.mode.value[VALUE_LIST]: + if val[VALUE_ID] in MODES_LIST: + # treat value as hvac mode + hass_mode = ZW_HVAC_MODE_MAPPINGS.get(val[VALUE_ID]) + all_modes[hass_mode] = val[VALUE_ID] + else: + # treat value as hvac preset + all_presets[val[VALUE_LABEL]] = val[VALUE_ID] + else: + all_modes[HVAC_MODE_HEAT] = None self._hvac_modes = all_modes self._hvac_presets = all_presets diff --git a/homeassistant/components/ozw/discovery.py b/homeassistant/components/ozw/discovery.py index adcb102b7fe..3eb5d414ac5 100644 --- a/homeassistant/components/ozw/discovery.py +++ b/homeassistant/components/ozw/discovery.py @@ -131,6 +131,37 @@ DISCOVERY_SCHEMAS = ( }, }, }, + { # Z-Wave Thermostat device without mode support + const.DISC_COMPONENT: "climate", + const.DISC_GENERIC_DEVICE_CLASS: (const_ozw.GENERIC_TYPE_THERMOSTAT,), + const.DISC_SPECIFIC_DEVICE_CLASS: ( + const_ozw.SPECIFIC_TYPE_SETPOINT_THERMOSTAT, + ), + const.DISC_VALUES: { + const.DISC_PRIMARY: { + const.DISC_COMMAND_CLASS: (CommandClass.THERMOSTAT_SETPOINT,) + }, + "temperature": { + const.DISC_COMMAND_CLASS: (CommandClass.SENSOR_MULTILEVEL,), + const.DISC_INDEX: (1,), + const.DISC_OPTIONAL: True, + }, + "operating_state": { + const.DISC_COMMAND_CLASS: (CommandClass.THERMOSTAT_OPERATING_STATE,), + const.DISC_OPTIONAL: True, + }, + "valve_position": { + const.DISC_COMMAND_CLASS: (CommandClass.SWITCH_MULTILEVEL,), + const.DISC_INDEX: (0,), + const.DISC_OPTIONAL: True, + }, + "setpoint_heating": { + const.DISC_COMMAND_CLASS: (CommandClass.THERMOSTAT_SETPOINT,), + const.DISC_INDEX: (1,), + const.DISC_OPTIONAL: True, + }, + }, + }, { # Rollershutter const.DISC_COMPONENT: "cover", const.DISC_GENERIC_DEVICE_CLASS: (const_ozw.GENERIC_TYPE_SWITCH_MULTILEVEL,), diff --git a/tests/components/ozw/test_climate.py b/tests/components/ozw/test_climate.py index 13691b49f65..70fba99f7f2 100644 --- a/tests/components/ozw/test_climate.py +++ b/tests/components/ozw/test_climate.py @@ -6,6 +6,7 @@ from homeassistant.components.climate.const import ( ATTR_FAN_MODES, ATTR_HVAC_ACTION, ATTR_HVAC_MODES, + ATTR_PRESET_MODE, ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -218,3 +219,47 @@ async def test_climate(hass, climate_data, sent_messages, climate_msg, caplog): ) assert len(sent_messages) == 8 assert "Received an invalid preset mode: invalid preset mode" in caplog.text + + # test thermostat device without a mode commandclass + state = hass.states.get("climate.danfoss_living_connect_z_v1_06_014g0013_heating_1") + assert state is not None + assert state.state == HVAC_MODE_HEAT + assert state.attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_HEAT, + ] + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) is None + assert round(state.attributes[ATTR_TEMPERATURE], 0) == 21 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) is None + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) is None + assert state.attributes.get(ATTR_PRESET_MODE) is None + assert state.attributes.get(ATTR_PRESET_MODES) is None + + # Test set target temperature + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.danfoss_living_connect_z_v1_06_014g0013_heating_1", + "temperature": 28.0, + }, + blocking=True, + ) + assert len(sent_messages) == 9 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == { + "Value": 28.0, + "ValueIDKey": 281475116220434, + } + + await hass.services.async_call( + "climate", + "set_hvac_mode", + { + "entity_id": "climate.danfoss_living_connect_z_v1_06_014g0013_heating_1", + "hvac_mode": HVAC_MODE_HEAT, + }, + blocking=True, + ) + assert len(sent_messages) == 9 + assert "does not support setting a mode" in caplog.text diff --git a/tests/fixtures/ozw/climate_network_dump.csv b/tests/fixtures/ozw/climate_network_dump.csv index c865e6438de..370edc15be1 100644 --- a/tests/fixtures/ozw/climate_network_dump.csv +++ b/tests/fixtures/ozw/climate_network_dump.csv @@ -72,6 +72,42 @@ OpenZWave/1/node/7/instance/2/commandclass/49/value/72057594168754212/,{ "Lab OpenZWave/1/node/7/instance/2/commandclass/49/value/1407375005990946/,{ "Label": "Instance 2: Humidity", "Value": 56.0, "Units": "%", "Min": 0, "Max": 0, "Type": "Decimal", "Instance": 2, "CommandClass": "COMMAND_CLASS_SENSOR_MULTILEVEL", "Index": 5, "Node": 7, "Genre": "User", "Help": "Humidity Sensor Value", "ValueIDKey": 1407375005990946, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1588264907} OpenZWave/1/node/7/instance/2/commandclass/49/value/73183494075596836/,{ "Label": "Instance 2: Humidity Units", "Value": { "List": [ { "Value": 0, "Label": "Percent" } ], "Selected": "Percent", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 2, "CommandClass": "COMMAND_CLASS_SENSOR_MULTILEVEL", "Index": 260, "Node": 7, "Genre": "System", "Help": "Humidity Sensor Available Units", "ValueIDKey": 73183494075596836, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1588264894} OpenZWave/1/node/7/association/1/,{ "Name": "Reporting", "Help": "", "MaxAssociations": 2, "Members": [ "1.0" ], "TimeStamp": 1588264906} +OpenZWave/1/node/8/,{ "NodeID": 8, "NodeQueryStage": "Complete", "isListening": false, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0002:0003:0005", "ZWAProductURL": "https://products.z-wavealliance.org/products/1507/", "ProductPic": "images/danfoss/z.png", "Description": "Electronic radiator thermostat", "ProductManualURL": "", "ProductPageURL": "http://heating.consumers.danfoss.com/xxTypex/585379.html", "InclusionHelp": "", "ExclusionHelp": "", "ResetHelp": "", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "CEPT (Europe)", "Name": "Danfoss Living Connect Z v1.06 014G0013", "ProductPicBase64": "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" }, "Event": "nodeQueriesComplete", "TimeStamp": 1594159718, "NodeManufacturerName": "Danfoss", "NodeProductName": "Z Thermostat 014G0013", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Thermostat", "NodeGeneric": 8, "NodeSpecificString": "Setpoint Thermostat", "NodeSpecific": 4, "NodeManufacturerID": "0x0002", "NodeProductType": "0x0005", "NodeProductID": "0x0004", "NodeBaudRate": 40000, "NodeVersion": 4, "NodeGroups": 0, "NodeName": "", "NodeLocation": "", "NodeDeviceTypeString": "Unknown Type (0x0000)", "NodeDeviceType": 0, "NodeRole": 0, "NodeRoleString": "Central Controller", "NodePlusType": 0, "NodePlusTypeString": "Z-Wave+ node", "Neighbors": [ 1 ], "Neighbors": [ 1 ]} +OpenZWave/1/node/8/instance/1/commandclass/70/value/2251799953244180/,{ "Label": "Override State", "Value": { "List": [ { "Value": 0, "Label": "None" }, { "Value": 1, "Label": "Temporary" }, { "Value": 2, "Label": "Permanent" } ], "Selected": "None", "Selected_id": 0 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 8, "Node": 8, "Genre": "User", "Help": "Override Schedule", "ValueIDKey": 2251799953244180, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159422} +OpenZWave/1/node/8/instance/1/commandclass/70/value/2533274929954833/,{ "Label": "Override Setback", "Value": 127, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 9, "Node": 8, "Genre": "User", "Help": "Override Setback", "ValueIDKey": 2533274929954833, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159422} +OpenZWave/1/node/8/instance/1/commandclass/70/value/281475116269589/,{ "Label": "Monday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 1, "Node": 8, "Genre": "User", "Help": "Schedule for Monday", "ValueIDKey": 281475116269589, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/value/562950092980245/,{ "Label": "Tuesday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 2, "Node": 8, "Genre": "User", "Help": "Schedule for Tuesday", "ValueIDKey": 562950092980245, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/value/844425069690901/,{ "Label": "Wednesday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 3, "Node": 8, "Genre": "User", "Help": "Schedule for Wednesday", "ValueIDKey": 844425069690901, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/value/1125900046401557/,{ "Label": "Thursday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 4, "Node": 8, "Genre": "User", "Help": "Schedule for Thursday", "ValueIDKey": 1125900046401557, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/value/1407375023112213/,{ "Label": "Friday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 5, "Node": 8, "Genre": "User", "Help": "Schedule for Friday", "ValueIDKey": 1407375023112213, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/value/1688849999822869/,{ "Label": "Saturday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 6, "Node": 8, "Genre": "User", "Help": "Schedule for Saturday", "ValueIDKey": 1688849999822869, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/value/1970324976533525/,{ "Label": "Sunday", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Schedule", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "Index": 7, "Node": 8, "Genre": "User", "Help": "Schedule for Sunday", "ValueIDKey": 1970324976533525, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/70/,{ "Instance": 1, "CommandClassId": 70, "CommandClass": "COMMAND_CLASS_CLIMATE_CONTROL_SCHEDULE", "CommandClassVersion": 1, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/117/value/148717588/,{ "Label": "Protection", "Value": { "List": [ { "Value": 0, "Label": "Unprotected" }, { "Value": 1, "Label": "Protection by Sequence" }, { "Value": 2, "Label": "No Operation Possible" } ], "Selected": "Unprotected", "Selected_id": 0 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_PROTECTION", "Index": 0, "Node": 8, "Genre": "System", "Help": "Protect a device against unintentional control", "ValueIDKey": 148717588, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159718} +OpenZWave/1/node/8/instance/1/commandclass/117/,{ "Instance": 1, "CommandClassId": 117, "CommandClass": "COMMAND_CLASS_PROTECTION", "CommandClassVersion": 1, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/128/value/140509201/,{ "Label": "Battery Level", "Value": 79, "Units": "%", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_BATTERY", "Index": 0, "Node": 8, "Genre": "User", "Help": "Current Battery Level", "ValueIDKey": 140509201, "ReadOnly": true, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159422} +OpenZWave/1/node/8/instance/1/commandclass/128/,{ "Instance": 1, "CommandClassId": 128, "CommandClass": "COMMAND_CLASS_BATTERY", "CommandClassVersion": 1, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/129/value/140525588/,{ "Label": "Day", "Value": { "List": [ { "Value": 1, "Label": "Monday" }, { "Value": 2, "Label": "Tuesday" }, { "Value": 3, "Label": "Wednesday" }, { "Value": 4, "Label": "Thursday" }, { "Value": 5, "Label": "Friday" }, { "Value": 6, "Label": "Saturday" }, { "Value": 7, "Label": "Sunday" } ], "Selected": "Wednesday", "Selected_id": 3 }, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLOCK", "Index": 0, "Node": 8, "Genre": "User", "Help": "Day of Week", "ValueIDKey": 140525588, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159718} +OpenZWave/1/node/8/instance/1/commandclass/129/value/281475117236241/,{ "Label": "Hour", "Value": 13, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLOCK", "Index": 1, "Node": 8, "Genre": "User", "Help": "Hour", "ValueIDKey": 281475117236241, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159718} +OpenZWave/1/node/8/instance/1/commandclass/129/value/562950093946897/,{ "Label": "Minute", "Value": 17, "Units": "", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CLOCK", "Index": 2, "Node": 8, "Genre": "User", "Help": "Minute", "ValueIDKey": 562950093946897, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159718} +OpenZWave/1/node/8/instance/1/commandclass/129/,{ "Instance": 1, "CommandClassId": 129, "CommandClass": "COMMAND_CLASS_CLOCK", "CommandClassVersion": 1, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/67/value/281475116220434/,{ "Label": "Heating 1", "Value": 21.0, "Units": "C", "ValueSet": true, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "Decimal", "Instance": 1, "CommandClass": "COMMAND_CLASS_THERMOSTAT_SETPOINT", "Index": 1, "Node": 8, "Genre": "User", "Help": "Set the Thermostat Setpoint Heating 1", "ValueIDKey": 281475116220434, "ReadOnly": false, "WriteOnly": false, "Event": "valueChanged", "TimeStamp": 1594159422} +OpenZWave/1/node/8/instance/1/commandclass/67/,{ "Instance": 1, "CommandClassId": 67, "CommandClass": "COMMAND_CLASS_THERMOSTAT_SETPOINT", "CommandClassVersion": 2, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "CommandClassVersion": 1, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/114/value/148668435/,{ "Label": "Loaded Config Revision", "Value": 10, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 8, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 148668435, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/114/value/281475125379091/,{ "Label": "Config File Revision", "Value": 10, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 8, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475125379091, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/114/value/562950102089747/,{ "Label": "Latest Available Config File Revision", "Value": 10, "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 8, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950102089747, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/132/,{ "Instance": 1, "CommandClassId": 132, "CommandClass": "COMMAND_CLASS_WAKE_UP", "CommandClassVersion": 2, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/132/value/148963347/,{ "Label": "Wake-up Interval", "Value": 300, "Units": "Seconds", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_WAKE_UP", "Index": 0, "Node": 8, "Genre": "System", "Help": "How often the Device will Wake up to check for pending commands", "ValueIDKey": 148963347, "ReadOnly": false, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/132/value/281475125674003/,{ "Label": "Minimum Wake-up Interval", "Value": 60, "Units": "Seconds", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_WAKE_UP", "Index": 1, "Node": 8, "Genre": "System", "Help": "Minimum Time in seconds the device will wake up", "ValueIDKey": 281475125674003, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/132/value/562950102384659/,{ "Label": "Maximum Wake-up Interval", "Value": 1800, "Units": "Seconds", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_WAKE_UP", "Index": 2, "Node": 8, "Genre": "System", "Help": "Maximum Time in seconds the device will wake up", "ValueIDKey": 562950102384659, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/132/value/844425079095315/,{ "Label": "Default Wake-up Interval", "Value": 300, "Units": "Seconds", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_WAKE_UP", "Index": 3, "Node": 8, "Genre": "System", "Help": "The Default Wake-Up Interval the device will wake up", "ValueIDKey": 844425079095315, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/132/value/1125900055805971/,{ "Label": "Wake-up Interval Step", "Value": 60, "Units": "Seconds", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_WAKE_UP", "Index": 4, "Node": 8, "Genre": "System", "Help": "Step Size on Wake-up interval", "ValueIDKey": 1125900055805971, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "CommandClassVersion": 1, "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/134/value/148996119/,{ "Label": "Library Version", "Value": "6", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 8, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 148996119, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/134/value/281475125706775/,{ "Label": "Protocol Version", "Value": "3.67", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 8, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475125706775, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/commandclass/134/value/562950102417431/,{ "Label": "Application Version", "Value": "1.01", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 8, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950102417431, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1594159418} +OpenZWave/1/node/8/instance/1/,{ "Instance": 1, "TimeStamp": 1594159418} OpenZWave/1/node/16/,{ "NodeID": 16, "NodeQueryStage": "Complete", "isListening": false, "isFlirs": true, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": true, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0148:0001:0003", "ZWAProductURL": "https://products.z-wavealliance.org/products/2543/", "ProductPic": "images/eurotronic/eur_spiritz.png", "Description": "• Easy control for water radiators from any Z-Wave Controller • Fits most European water radiators (wide range of additional adaptors for different manufacturers available) • FLiRS for quick response time • LED Backlit LCD • Metal nut for reliable connection to the radiator • 2 buttons for easy temperature regulation • Battery level indicator • Child Lock • Over the Air update • UK-Mode for upside down installation • Open Window detection • Automatic frost protection", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2650/Spirit_Z-Wave_BAL_web_EN_view_05.pdf", "ProductPageURL": "", "InclusionHelp": "Start Inclusion mode of your primary Z-Wave Controller. Press the Boost-Button.", "ExclusionHelp": "Start Exclusion mode of your primary Z-Wave Controller. Now press and hold the boost button of the Spirit Z-Wave Plus for at least 5 seconds.", "ResetHelp": "Please use this procedure only when the network primary controller is missing or otherwise inoperable. Remove batteries. Press and hold boost button. While still holding boost button insert batteries. The LCD shows RES. Release boost button. To perform the factory reset press boost button.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "UAE", "Name": "KOMFORTHAUS Spirit Z-Wave Plus", "ProductPicBase64": "iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAgAElEQVR4nO29e5Mlx3UfeLKq7vveft1+9zQwGAzxIIknSQMgCVJeSSF6Hd6QvevY2JXlpVa78kdx7LfYcGysvQ5rHRLXBGGBpClCMiXRAiECBvEYDOY909Pd93bfvrfvoypz/8i52afOOZm3unsAQxHOiOmpm3Xy5Hn88pysrKwqpbWGWUUpZYyZSVakiKwC/PEpQna2VmcTowiBjycAkHqxskg5syOKm+5hyXDqDowx1i5nLufn8KmW/yLizez0/ASfhlSBolzEOi2KeZckSLhx6X66v4TM1vjCjOvIkeFjMRgAMgqXBDcsEqUC3HxsRT5i1wGDFJQqYArR+D6jBbwwkwnnkHDb2SJyJ8pwu9hKVyOqSuQ4lTKuU1vD3YyLo+SSOP68YXiYivggBCIrLCqwAcBHEVaZt+JdcAKiiD3mbEmPXAyuHXcKJ044fklnoj6YjJ8VsQh+IDr3E7m5y7nOBD2inoGGXK+ApoTzTK+L/hMpiY7h5pgAj3AiMHc8793HlrDilAFg2HrFJ+8car7KMxfiknN2Icb2h8XqPNweFudARHeV9mBmJhVZiZL4+IRbucpIDAxiyCU6+KIaDz+kCzXNQWQ0iHFF7IIQcN34uPdxILK5sS4KJo51XE8qCRNiEBxRuA0xHx6Vxd6dbX2mUyyT8C58VhUF4NnmpBK3F+dSWAgsPfETHivElD5WhIwUcf4kxvzAnIwjTMw1YW5cU9EUPkqiu5IiDZchIIDPVjMpw72LvguXQI9CKqQUp7lUCUdsTOkk8w2Rgh2JEvp6D2jhGz+8vrhgAbZFuBURsrgAPkfwGOYjCFAK/RJgnVbtgOf4mBPVK+7785eZvZ9Whpn+KCLD56ScUzDi34icLhKW8N9AiiQZkBAQPpB3UoCAy4P5i5Q8KDpKIh6udKw4gc9c+KwoFbeATxdc42MV7lfk7NMF1/uahAnM9PrUnk1wM9+MBIPRl30J1AjIMJmY43FzUSRRycBkRewxQCP+5AeALBPWxYc55Y/QZF7MKzl/IqFvuinO9rjiWC/ehCsFzC+uVWRQUahw3UQDETxxKQMTLE5DdCDjlYwJDvEwgHyuDYsXIHPyEE84AgIILqEPmk738CSPKILtw+HL+XOpXKeEkvgxoJc7dbLyjvvjUQe7E/JjAvLO9tFgKYl1uKrcNIAGB+mO8OFMeBcBkHFnY7FF2HGvzxxOIFlJDD++Vpyta+VrSOzAXQN5iPiyU0Br1yo37EByMOn4VKdmnj0P8eehnE1gHphFVg/XGp+xbb3rWKcSSzwrmu+/ls9P+VSdEsE0ePqyXpEiyifmlOKShYnFOMwHyWnL2VqJYpBJwhn6mqmjSHker51NWlGqCGfcmRxPa3eetmc2ITke8h7CA4D3xSccpy1hIwT+YsGwPQNi4HAuGnmmjtxWhK0of7hSrA/HNj6XBwCltQ774PwBM5BhCzYnxjobK6LIOaWayf/MTT6H04YziJTwxuGgx32MD8jQESdw4fxI+HAawpZc94k0RAbI44kLxtmKZ8lBwCxckplTjoBZsLIFO+JWFeXnHEje8NlHsIy9pUMsWPA6RRz03NCiUYpcIhQhmylkkRJgeNrA9hBlI0bDzQNScXoCnYIiic7iIvlY0eWGsJJFzvr65oKKMCqopwhfQi8SzAR3QMHA8Cie1IoD5VROOYM6wGzuk6c4c0cZWoMurljYguecNJyz+cMqxYfWGXgWx+UZmD9EyuJFuCvEs6lPgsDkA4Lpn5BBgekRlyTAiosxc5Ixs3A5sSRhsX0mCuglahcQ1RcFcVuiNY9VYuoUmfh8epIK7VVhONT7Qi4P4KKPIe9ssbkvQRRMdqJU4jDgpieUxeccYr4InPVJReqLSBXwkc8sPj6Q9yChLJh2+Nmzp8KZUPBJedrAG4BjuNXMPC7ymZmVfBAHz3B6KKxm6jhzPHzahXTk3UHKUwxuXzB9BAqPtMXP+iTk9L4MGO5UJCuesknXkIeaKNhMHcWc6BMpnEAD0VGUE/KGDUviaiIRzq4PEicoKqcEalpcHwoVTm9ZOZk4JT7r/mJVeeERiLfl8hMOAWc7kbit+HEAN9wmuB7/BORREUlOYK4d6YXwJL3g5lxgMb5gVriVq4kg71oOJgIX3LGYTTjqeeEYwtJzo2CUE8SLGvLmkPcKPxb5k45Ef/iMrjxJnxsQH/uGLpGTiydqjWXAfyE/TrD1OLZ86nDVsCKUS8AHPkY+7gH68ISgyByFUIpseXMx4BfRa6b6xe0jxpiApqfqNGzYmYJBMMCfqhSaY7kaLMF/wcJl+zyw/ZSkOmcpLlVByoJkJw+sBuK8LS5nhSMwEYIc88DuaxWQissQZlXkmKc8X334mHAgmau4tJg5YRhmRU758ho/wFNeYJbnUVbU0dWfPKVD5hZEMWJBLrqINp5YxXkb+cltwaXic3/RCr6+xCTC9RK5YQmJamTWgtly9wArvkjgxnNgUuuKOAclfnR8RK25uQiNKAOhKfRcIbFRgKxgJVeDz5yKB3DI+4/4gJwKHweEnClGwY7sX6211jpN0yzL7Bp1HMdxHCdJEkWRj3PxfgP0pIYPfi5tuBfZ7yKwiqDhbJXFeZ6tPERWYW5n6MgYk2WZRZILb1EUWRdkWWYtn0xLAGGfhpo4lp9hhFOe4uRdjNikY7GJmCNEjEM+H3G2POrwUYUpxQwVTj1cTpAsGxaVm4V3aoyZTCZpmnLVXAADAIski7Aoihy8cAojUvnswMUWJecAEvFUxAj0rJsfkDZEGtErAUoRCqK2hFhsHgC62HymbBysYo7w+Y/0G0Cwy3eErW9wZllmkWSjmgtgOHoV9M7Ms0UK8R0EoZLTxcbkQDNxNIMEmiIA4h2Jg4bXC2OiQFwJm0yECDCMBtryse5otNaTyaTgu4Mhj0g763JgskAslUpOsMB4KKKvWBMgEJ2FaTjl7LfN+JTnBOFkXJxATE8zG5JWPsTz4zB8w1qISllIuSjlEhmm4ZGA96i1ttN5F73sBJ/IE7bPTLN/SiXU6zkD6fnj8MPl8ykVbMAsy1ziE+OfGADCzLXWpVIJEEDjOBbB6pp89ubinT58OBe32uehPJRZiHX/NPEZpSDLzHA4StMsiiCKonhacIIrzhwA7Fze5kTIr0udSqPPLFjkHqbAjcloC2TZIn37ck1YBxAvN4pduxUUSWQbbkuGYpZlk8nEVWbZZDgcHh+P0lRHkYqik1mRzWvVajVJTl6ZgVNVYNJjEVYul+M4dpbxYctnH1FTn6NFVtwOPoHp5N1XZvqGd3aGwTFzOlnkVMFeTjXTJ83t3zRN0zR1sEjTdDgcDgbHaZpqnRmjoyjGYcaWKIpKpVK1Wi2Xy4GM5us0juNyuXwyR86jsyCrgtCZySFgQyEVFsxlRTQ5bVosDqzTsjpPIYPbGD2ZpFmWGQNKgTFmMkkHg/5oNJpMJlkGOks73b333nu33mg8++Vn642GUtGJHQCUiiy8avVaqVQiydEXS2zRWkdR5EAJ0hzu4ZazjcBcxDLSFRPuAFBuwhkTQLg8CUR1KoSUzsTJn/Jc4hEZfGKLGsl2kRYRjDGTyUTr1BiwJ8fjSb/fH45GWZoak6Vpur/Xef317//ox2/s7u6Bgd/49W/9k//lu+3ltXJSAQ0QARhQAAoipUAlUbVardfrNsHxTn2Z3RhTqVTsilcuAUleED3lc4QzYMA4RcrJAinpFTxQJclOlGlmk0CCEynF+VZASH7MJRTjv09+e7k3fR2B0dqMRqPBYDAcjrLM3vWb7Ozc/cEPfvDDn/x4NDqu16tJkmSZPup1V5dW/tkf/LPnnnuhXK0pZa/vAAwAgIpAKVUqlRqNRrlcFtwjAQKmV4uVSsVO17gXfE7hBifI8xnNZ3+RIeBU6EtbZ5h/FJmNfR6KTyo8YBykYLoyPhwOB4PBaDSyd/0mk8mNmzdee+37P/uLP890Vpmr1evVSqVULpezTB/3B0d73clg9Jvf+c4/+h/+8eLCShwnAMrdzoHpvL7RaFSrVTwh86Ecw71ardqLTWz5wNgL2yFgkFNNaQBfFfLI/4BCSkxiQpnZVuyFVPIRUzA5+qI3MbovypL476KUq9FaW0iNx5MsS7XW4/H4o48+/Hff///eevuvVazqrVqtUas2Ss1WvV6rRnGcZXrYHx52DnudXnfvYGt987u/93tffubZUqkWRSUrmpMkiqJqtdpoNOyUS5we4GITosWWvUQIqBxIF4HIxP+ShoF5kfBcIe/PpxsUQ5Wve0wmqi2GaJ/+pJWv60BUdoLZRU5jjM1YaZodHw8Hg/54MtFam0yPx6O/+eUvX3v9++/853fictxqNWuNcrlaHo0n24+s1xtJXFJGgzFKT3R/MDzoHPU7R93dzmh0/A/+2//uH/72P2q22nGpAkoDKAADxtgZfaVSaTabZHk9XNI0rVardhGVmzRsAZ/7+HDlyCNOJG56MHkX/TQzs3LH+/I0jyU++IM0zsJhyWdHzop3R6KUWze3lZPJ5Ph4cHw8Go8nADpLs0H/6K//+q++/9prH1+/VmlUanPVer1aq1ZbrVqqzV/+1S/rtfIzz31hbX1BRaCzyOhMaz0epYeHxwfd/uH+YW9398Lqxne/+wdPf+mZcrWqohgAQIECFalIKVUul1utlktwYnIgPyeTSb1etzcTMVZE3UlzHlBEe4ZZceJPceVd7A8YYgJ8oNiMeybPQO+2F6213Sk1pdGTyWQwOB4Ox+kkTXWmdXZ40P2Ln/3HN/796zd2btdb9XqzVqkljWatVa9XayWV6MEo/fl/+mAwHEdR9siFlWeffSKJjTZ2g4POUhj0R4fd/kGnd7B3qAfjv/ebv/Xbv/3fLy4tqaRiVAQKInggW6VSabVa9qKPGJNnFZgOiWazSebyBX30aZTZ9wrJCHCngIUK3lYkDpTw8Ark++IMiVJ4552tH4/H/f7RaGQv90yaZoedvR/+6Ic//slP9nv71VatVi/XGpVGozrXqpcrSSUpxaVEx2YwSq9cubfbOQSTJVH69ZefKZU0qNgYrY3RmckmZnycHhwNu7uH/f2DQae3vrrxu7/zT59/8WulSi2KY5gCSynFcyKP1gZd0duxMTc3Z7GIE9FJevJfuc/0QqCV6JTc5J1kHF/exSWQCgO5GVcW5AwMWz56sSPSI0z3ILgarbVdQRiNRlrrLEvTNL1//96fvPEnP/nTHw/H41qjWWtVa42k2ao1mrVKJS4lcZIkKorGafTRtZsff/LJaKiN0QsL9ccvbW1vrcTKKIiMMnaFQqcmS81kMjk+nhzsHx3sH/X2Dyb90a//2q//T//zP2ktLJbKVSezm29hZHCtsfz2uNVq2Zp79+4ppVZWVvjdyfDUAvIACNg8wEoGFq/hUCCKcRQSQ4iTMMzEN0o4KMOZMSA2TFeALKQsWZZl4/FoMBiMx6nW2mTZeDK5dv36G2+89uc/+zMDptxopJk52O/Esbn8xMVRNp6br29eaJfLpePj9Mq1a1c/uTccQxSZhcXWpYvbq8tz5ZKJYzVNbpkCYwxobbQBnWWTiR4Nda83PNw/7O/1jjq99mL7f/3933/2uRcr9UYUxQDKpkG7fCqGZ5wHYZoltdblcrlWqwFAmqZvvfVWvV6/dOlSrVbLhZPg5CQwewn7JYcHnLYJx5lzoCJkpyUuEqiBhaJwX870bgVBKWUMaJ2NRqPj4+F4PMqyNEshnYyvXPng3732vb9++60oiRuN5nic7d7vDAfDqBSVG8mjj29/fO1mFMHCUm19Y+XD96+PMh1F8cry4qVLW0tLjVipWJlIGaUiUAqMBgBlABQYUJkxoCFLTTbRaZoN+6PD/aNe5+iw2xsdD37t23/3d37nuwuL7aRcBniwLaJWq1UqFaw7MQVWEADwZKvf73/wwQdKqUceeWRpaQnvHOS2cmzDCYcjT3TZSTI+Q/GFxyJnz198wYxTmunlnlM7TdPhcDwcDieTcZZlaZZOJpP3fvk3r//gtXfee1dVVK1ZPx6Yvfvd4+FxOVHVRmXr8oWLly+Uy+rW7XvdznGappVK6fbte+2lpUuPX1harJZiEykDBkApUJGBGJTa3985OhglcalcjtvLCxAnoCdgtMmUzmAyGacj0zsYdPcPD7qHh53e2sLK//77f/DcV18sV2tJUra7ZUjQwgGDYAum42d+ft7S3Lt3b3d3FwA2Njba7TZeJAuYFNcAA3QRvxTdQUp6FZMm5KFdUIHwxK64VKIA7nIvD6nhcDhMU7ukng5Hx2+//fYff++Pr175sFotVxutXu949353PMmSUtRaKD36xIWtxzbLtaScmDKYVKdpGisoa5MZA0mioiiLlI4gBhNrlRoVZTrZ2e18+OGVzu4hmDiJ1daF5S888SgopVRaLccKIqMhyzKdwXikjwfjvb3uoNvv7/aG/dG3fv2/+d1/+nsrK2uVSqVWq9nwE5hg4QNbkiRpNpsWZB999FGn01lcXFxZWWm3287OgQkDd4dvmuSbFOVWcUSI8GM+3xIrCTFRg9uoCIF4IJrbPcXgaLIsG41Gw+HQ7htO00nv6PDnP//LH/7wjWvXrldq5aSRlCvVo+5xd69vQJVr8cUnNje32+WaShKVKAMalIoNZACxAgVKg4kV2EUKpQ0AqLHWN27fv3L15kG3B0aVk9LGxkZ7ceH+7v27d2+tr63u7uy2mrVnnnlyYbGptdE6yyY6m8B4OO52Dnvd415ncNjtXbz4+D//5//H9va2bzJAoEAqJ5PJwsKC3dbc7XY7nc7h4aFS6uLFiwsLC2S+xY3M/eLLmL6GiYhTLi7+6asUp+TiqAKpiASiPHwwYD74cg+PrdFoOBgMs1RPJpPDg/0/ffMnb/zwjd3OXq1aWViaK9fL1YVqrdEYT+4c3e3FpaqBuN4q15pRrEAZAB0bBcYuaBoFShkTAQCYRAEYpUHBJzfvvfvex4PjkTFQq1W3trbm5xt3bt/7xTt/A1kEOq6UG+PJ/n5n9Oaf/eLy5e3Ll7ejODLGRFGkVHkpXqhVm0sL6qWvvvyd3/r7GxuboiOI1uJBHMdHR0c2Ic7NzX388cedTieKomazGUWRXZUIuBhYxBLjpSjGg+YFX7wmYvYMRcziASQFMiM/RTZzQn5UKaVHI727u/ev/59/+eP/8CeDYT+pJNV6tVop1RqVWqtcaZSjJAEd376995/fu5pNJk8/vf3UU4+oSCsVKYiUMgA6MpEyoJUxCkBBZiDSMRiTGfUX/+ndW3d36o369vZ2o1G/cePm7u6+McaYdKW9cPmxRxcXljrdw9t37uo0AzO5sL2+staOIqUyo7K4pOrPPfO1V1/59lxzAQBAAQQHZBhbWZbV6/VqtWqM2dvbu3PnztLSUqlUStN0aWmp0WjMNPhMZ3GPuN5z9wqdZOJUMXD9RVQtft13tsIHWX5by4NipxGYEEBpo3765p/+i//r/9w/uF+uJY1mpdWqVitRVFIqibWK0olO4tLh4fG1T25sX1hZWV2MY1CglNEKAECDSQBAq0yraHCcXbl57dqV21EGrbnaM88+O5wcj8bmwyvX9vf2lYEojldWlx6/dGFhoVZO4tiUJzrTWoM2kVIQxTdv3FqaX1prr3312a+99NVXG/U5oyP14BNHGuvolAI0lwpEdGOM1tomvizL9vf3LY1d8VpfX8c7pIvY/FReEwBIZmTicZGeONiL8HTEBKMcTJbG7Q/mM0oCLKXAGDCgDOhev/fav//jn7/9l3E5jeM0iZXWar971D8exVF04cKqMRmYKIqyB6uVBpQ2oIxWClSkAXr98Xvvf3Lr1u5xlsaQLM03nri8sbnR1gaufHLn2vV7WaZXlxcuPrI6P9csJQkoUEopnWkw2kAEERhldDQ4nPzmr/29r7z48uLcvNLGQAQKALR5sJfQa1XRcfisBVa9XrdbmY+OjobDoTVIr9dbXFxcXl7mWwUx8yJI8sEgIX6aGbemTlLiRRzxLp7oEKOIGPVpQnhaPO3u7na7Xa11kiTlcrnRaDQaDQKmPIhBWXQZmK/P/Y//8Hf+zksvv/bGH93buwF6DCZLkrJS6Z07d+bn6uVSkpTKSRIbo8GAMfZflEK8f9j74INrN2/eTzMTxWpjef6xRx7ZWF2slHRkYAzRZJT1eoM4hka9vLzQSiJlTAYQGQNGawOgIFKmVKu0vvb817/6/NeatXkND+IPGAXGgN3yINmBxyfyE/9VSvX7/VKpZIypVCr9fn8wGHS7XbvNplartVotMbWJ45lfBhIyORXixuDPtQR5PmxBsXwc0MrXNsuy+/fv37592wYq/NaDUqnUbrcXFhbELeEndjFgjDFglIrHevjj//jGz37+p4eHu/vdTr3RGg8ng/6gWks2NlaTREVKZRqU1pHRmVYH/fEP3ngzU7GKko2VhScvbbcXa+VSKQbLGNIsvrd/+Fdvv3s8PE4i81vffnm+XgcwWplMaWOU0uV6ZeGrz730d77ySrVcB5iuJwLYVdSp7qFLP/wTu4BELDtJqNVq5XJZa72zs3N4eNhqtYbDYb/fX15efvTRR+3tSHyhTZwr1gccbX8m3AHumNfwn2KkEXmKJUDAU6SdSNm51OLi4sHBweHhodba3lCzcWtnZ0drvbGx4TS04Ov1etVq1c5kAQCUDRumrEq/8epvXXzk0v/7R/+y0ZjTajI6HrWXWqVSFMUwjSORicBo0AoOj/pJJd5YXr702PbyQrMaQ6RiBQBKa6W0UhqyqJLMLyyqw6RRLcUAqclMpDITmSyZry999YWXv/r8S/VyEwCM0WAz3hQP4EcSCVeYAF+BEYRFUXR8fGzfaTM3N9ftdm/fvm232O/s7MzNzS0vLxObi2MyUM/9qMJBxQl6zrl2mMnMU24i5Ral8Eja39+/cePG0dFRu92u1+ubm5vuBggATCaTbrd7dHRkvdJsNh/clFWRglhBCqCNibVSB/3733vtDz++/p6JRgAGzINkpFRswBjQYEyqo09u7ux3Dp956guV2CRKqyhWOtKQmshkkBwejd778MMbN3eyTCnIVpYa3/jKC0mtpKJSs7z0yovffPHZr9WqdWOmt3o8izuBHAdB5Il/bdCKoijLsmvXru3v79vMWKvVarXaU089FUXRqR6jLVK8D6wSujPAy9eET7CcABzlaZryFQSSl2H6Fg08wRoOh91u9+7du7du3RqNRnEcl0qlra2t+fn51dXVer2hVAyQKjBgSgBRakYaJm/+xY9++rM3MhgZo0EZAGP3vUQRGANaxaORHo30tavXIhVVK+Vmq7m20gIFe93+rz68fuv2fQMZGNNemrt8aXttdakcN1ut+ZdeePnl575ejav6wQzKPLiQMDE2AnhA4zvApsDxDB9Y/9pZfJqm/X7/xo0bpVLJPkamlLp06ZKdxfsyoOgysd7VGMNeY+SbnYnFx9dHif/6CNxPDKlwqiVTjcFgcHBwsLu7++abbw4Gg6effrrdbgPArVu3Pv7448uXL7/wwgutVmtpsR3FkZ3PA4DRBiDSkL7/8bvf+8EfHo32jcqMMgYg0ipSAAq0NmDUJDW/unLznfevpplOIHv2i5f3O72bd/c0mDjSq4vzly9tL7cXy3F1cX7l5Re/8ZVnX6okDa21iTKlDJhIPZhTGQO5yS9W3+VxbCJyTJDkw581Zr1etzvPrl+/Ph6P7cu6kiRpNBrPP/+8jVg+AM30Ly+53Q0PvYiwEGlcsfuihsNhmqY2ApEXKJKZH27e6/W63e5oNAKA119/HQC+853v2LZ2gN6/f/8nP/nJSy+99Oijj8ZRtLK6On0MwUxNAaD0zv69f/v9f3Vn92NtJqBjAwoirUArpYyOtI5Gabrb6f3il+/1Dofbm+t37u6YSC2vzF28uL622C4n5ZWF9Zee/8YLz32tmpRBK1CRMcYorcx02YOpT8DBT3EyHwE3qb12juPYXk3b29KlUimO49Fo9JWvfMXu4jrDdMVXTpbISCTE6YYfixcLXEleL150wHRU2YeJLYENzuPxeDKZAEClUnFvPQApdfZ6vQ8//HB5eTmOYzsb29rasrNUM73nOjc3Nzc39yA7GHPnzp12u91oNACc1QwYtbq4/rv/+H/73uv/5r0P3wIYaQMmi0DFGgBUBEqXSvHq0sKrL3/l9u27lVql2Uray8uL8/OVOFld3Pz6S9985unny1EtghhMatellFtA8INgZu4jB754JtaMRqN6vQ4A9Xq9Vqs5y8RxfOfOHbc9kEwzeBAlzuVoeUBMUiHmAqwQKADDOEcPbuWD7GQyGQ6H4/EYv1PKFjsPSNP06OjIPqJpV/xw6DbTK6O333779u3bTz75ZLlcvn379vvvv7+xsXHhwgW78a3b7b7//vsA8Morr9RqNWPM3t5eHMdPP/00lR8ADGSQ/vjPXv/zv/phmg0AwECsFRiVxVEEJjYmytJURWZilAKITbKytPHNl3/t2adfiFWsjFImAgCjNEiwwD2SSl/oEmNbkVxpuxiNRo1Gw96b/+CDD+yTjDY5lMvlb37zm3jdYWaZmSW9V4W+LGakedLMUGnycyxXbyFlH/7EbPFM3F6zKKVGo1G/39da2+fv8ANPruzu7v70pz/d2tra2trq9XofffTR3bt37RvMSqXSxYsXL1++HEXR7u7ucDjc3t5eX1/nVxIAGowBiLXKfvHOz1//4b8djg80aA1aKQUqAvuUvIl1BgrK66ub33jp1S898UwclcFMF6PAgAEDnHnRiz7fwamyp6Oxl9W1Wi1N006ns7u7a0dylmVxHH/rW9+am5sDKVKcKgmeeLDg67hJ1vPFHjEyicXui7KQwnhyDAHBS6HXSg0Gg8PDQzvg7L16DtY333xzNBo988wztq0NhHYPyf3799M03d7eXllZIW9McKYBpQCUMp+sGxIAACAASURBVEZDBpG5euOjf/NH/3evvwMmNTrWSkGkAQxAdWPt8W+9/OpTj3+xpKrGaFCZnV0YY8A+5WymPEE+wD85GsQpfEFumI8t/X5/bm4uTdNut/urX/3KvrLL0jz//POXLl0i7sYMpeEn3Ghxf3PA4hS4nnQpcodZxWpo71s5nra4JQPHFvIIc+8PtrdUx+NxrVabm5uzC1fOB5bm3Xfffffdd1955ZVKpWKj3b1797TWly5dsheJgYHo3DL9z+zs3flXf/gvdvZvGpVpnUWqemH90re/+RtfePzJ2NiN7WpKfDI3N1MmM5Mdrw8nvoKQcgf2xnO3211eXs6ybDwe/+IXv3DTCQC4ePHiiy++aO0s+pHMalxD78RLVM+XAfkBhhcwtAGLYaPR6PDw0F7x4c0I4tvuHKrMgx1LJ/nRjj87SVpYWLBP4RHJ79y586Mf/ehLX/rSeDw2xnzhC19ot9s+7bioJ2KA0pAdHu394R/966vXr25feOTVb/zdy489pUxJAYChb0QO5yb+kx/zKBWoFPngny52dLvdZrNp3z7yy1/+0o5tY4zWut1uf/vb33YvoSQAIhEH8qgilA+Mhkc5OS1yDJeA2wAgTdP9/f3p0+snwmHouBmVrXFMcOhylZPJ5Pbt23a3ZLvddu/qdD0eHR3t7u4uLCzY3SMYQFYAO5l1LySW1TQKAEDpSTbudLvtdhs0AMTKgDbakReBThhPkIeLWOk7wFoTzs7Fk8nk+Ph4fn7eGPPxxx93u11jTJqm9jmzr3/96/atJJgnNUXhQodXINCJjvH1zcFqjOl0OqPRyI08HkJdsrMvN8eYxlDDiNRaX7lyZTAYLC8vr62t2eFIJCTR0ZYsy/r9vkX5aDRaX18nwpw0MfatVtoAGIjAGAXmwW0+Y+yHrkRU8Uoe0jgxjkw+Pr6zvpDm6geDwdzc3GQy2d/f/+STTxqNxsrKytLS0u7u7pNPPtlqtYo4tEisSbCqIozwT4IVLDFJhUQCALALCi4sE0qYzgOUUnYhyr4tmIhOwpjt9/Lly9evX9/b2wOA5eVlu7hA5CTqjEajnZ2dpaUll3/v3bu3vLxsF2aHw2GpVFpcXJw2yeDB1hkAZcBExihj9QWlZk19wtGI14TBFG4rysADWBRFCwsLTz/9tN1HarfTjEYjvIuGoIJEHFLPjZxbb4T8WAeSNYOXCQFK+/f4+NiiCkcgLJOazr6t5lEUjcdjG7ocWx41bc2jjz7a6XT29vZ2d3cXFxfr9TpZqSfGjeP4xo0b9nFQAKjValrrvb09u0hWLpf39vbs+9DgpNjoBQAaFCgAM11LEF2IS9jxkMdHmNtMSjEouhqbMQDAZr2Dg4ODgwO7BG1v8vgiSIA5sNshQD4rRzwNDGeksUjv8OEobVqx0tt6hzDeO6Bntuw+GRznRBPbrhcXF9fX14fD4f7+fr/fFzOLaxVF0QsvvPDWW29NJhPbvF6vz83N2UVCY8zi4uLdu3fd128wK16IPD4CyEOBEIQpuYPIT1cjOt4Vu3HIGJOm6a1bt3Z2dobDoZ2B4O+yYN+BNAtyBEQG5/0ImIO5MhwBTgexnrOyL0RwTQhqnUz2wF7fOXiRb4eQWTZm0mq1Njc3x+Nxp9Pp9XrudQbEQ7aLarX6pS99qdPpQB7odhjYKUi/34dpjuaY4D9FQ4EfbYEa7gVgYMJ+JWYnxVUmSTKZTNwnx5RS9t1a9qlr4jXcHYkXYX0BIHENfMmSBCd+9REeIra4COwo+Wxa5GaBlSSJTYt2+zaX0AlZq9W2trZu3rzZ6XSMMeTeqpPf7tBaWlpyHMw0QR8dHb3zzjtpmj7xxBPNZtPdEuCyYTtgH/Nx7PNEuEbESvgskYcoDgBRFE0mE7vDttls9vt9O4xNfiZjWOQTuwu4PnIGdaRcEwIyNS1ijaN0P23soR1LO8tEQY0xdheyfeKUDFkisDGmUqlsb2/bLX69Xo8MPjtS7c0y3IWTtlQqJUny6quvbm5uYguQ8UpCBflLhrvvrA9VvG2gUjQjYYv963zh9n3YXTTiQzu4oehrX2UkGk7k6zMo15b8tMAirGDqTh8oDQq/drXJ3u2yadF4hpStLJVKFy5cGI1GdvsouRQ1xjz22GNvv/227TRN06tXr169etV2lCTJ448/bh+WwlYjenFzic4OQI0jTDQgb8h75DYXizWaW9OJosiuVC8sLNTrdfLSNtejcwfuJSy/sXveiawzkx2XPqyPWxEFdjVgAef8Z/K5HD/oYaabIZVSdklT7NRVlsvl7e3t69evdzodpRR+j48xZn5+/uLFi7/61a/m5uY+/PDDvb29paWl1dXVarUKAK1Wa29vbzQa2ZtFHDGiNc52IB4XRO3MGp9frGGTJJmfn69UKvaxMKKsj5uYVXjlg3Ussb1PMpy/MRB9GdfOkzBzjDPMh8Rhoowzt7116r7pYPKXEe64XC5fuHDBzreUUhY0TqTV1dU0Te/cufPUU09tbm5mWXb9+vW1tTU3sb1y5coXv/jFgGoiIMgpHorEyoK4CQxgJU3scEMypO2nMfr9/sHBgd24THaLEP8qNonkAMANEx8jI829AvoE1MavixX7Aj8oCWdjjI1/pVJpMplUKhUjTVGd8NVqdWNj486dO91ud3Fx0b2k36JnfX3dLbhHUbSysmLvpl29evXo6OjJJ5+c6W/i9ZkhDcdjflZk5WMophEyxkT+eDTapRz7AA95DbhBSTDw1/Lk4Ms9sAqeaES4QB4ZuF6Mdu77fTYCu4zmegQ/KAlozDSBAkCpVBqNRu4eDoGp42nvWtj3Ji4tLYmvBHKUx8fH169fn5ub+/KXv+wuL3yyiVHKF7TcMa4nw52c8nXqG+SQ94urIazcBMvOXO1Zu+ODkGH/4r+EAPLesSURG+ADQkCE9mlIVFJKfkkE75FsnhHtaMnshcx4PBaxhQWYn58fDocHBwd2VoFvOBIDLS8vYxOL+CNQmBmlZp4Sz/qKaD38qBXvkXCOoshuU7Y23N3d3drawvcYuH8DYYXLBu6q8FQlDCaQMEqs4EzJWRl0KxBHUM7QGDOZTOwGefz9CFEAAFhdXa1UKgcHB3ZRXvQfqcfzQneKHHBwBChdiAIGL1EA0pYLLHLwkeHY425mDAaDKIouX74s+iKM8jASTg2ssBpigncr4Di68jzos7gYimxxqw/uvSCkdyyhnaG7x3iIkBwEEEQMd7kPUlwkH6qIAcW2cI5i0LTJ1sRxvLm5aW/G8/BTMDWJBF5g+caKGA+4pUSTYWRA/sFwyCOJSjmdV3Ji9wIjO6n3IUMpVSqVNjc37VOHnJgILDLh9ZgeJMzxU+4GUbgQC2BTG4ZaAhdSMKXJ3wpbXl5+/PHH8VDHqvGOAoXY5GS5gRfiyIJzRsM2L5AuHaV7+paw4pWG4RVPjNwTiG7pjwtmfzabzYWFhYODg3K57F7/inshP7nYXKriTYo0V/nrJGxG3IrggJuLnFXossYYY69g4ji2m7G4xcjsiuOSF2JM6gnSwakKkcl3gOdJYmp3B9wT4sTLGOMeOPENd+cJ+1jOwcHBcDh0OZpHi5nHpFIUvmBznwGJ9Xz8OQ2GkRiK7MwhiqL19XU8Grk7fD4lhcAd7L1CLjcwv4rjg7sQm49375YbyAjgYvGfZjqvF4cUANgHftxGF+5CW5RSm5ub/X6/2+26BTZjjF3O4fL7AEFQRaAj2pC3mtlQPMsL8ZTxXEq7A7cDIBw+OCJJj04qElmNMTRi+aQX2/M5Vlgrnih9KOGFPCthWPQyxtiLRLKn3tG7ylar1Wq1Dg8PB4OBb8YDCAeYhltWrBH5hI0c8LFBVzzhmBFghVFiny7hNuTcfB5xDcV0pJSKiECB+MHb+0zA6THyCD3WASSjEye5XpxpXI+TyQS/jY0YAoNyY2NjPB4fHByQpQpgOPDJw+k5LkFCHmGFmYhpgRtNJBMNy3u3ZG7lb6abXH04tvFCl6ExIycKIcDdY5qAcCp/GchpCE+iiZKmtI4Stx2Px/wSkmgEAOVyeXV1dWdnp16vz8/Pc5Uh73XMR7SjSCMiSTRggLlIzFGLbYJrRBl8N9cBYcjHnHTEMWB/njysR8IV7hUHIZOPnyIQuUXIAf4SHw8YkIcF7w5XAsOQm8gTniQ8rK2tAcDBwYHNC5jMzbdw+OGhiIco0oUogOhFXjgmuEf4WZOfLXAafGODH0B+YBPzEubY9QTQQNaxCPS4to4F+YsJyAGgPX1q+kgMH3m+SAB5QBOvkFZOPLdlHjzAtcQbGxu9Xs9uYnZFxA3HEKEhczUiIZFclIfodaocxPHhI8NhnnwgmMd4yBufo0d0vT2mj1KR8CDK6gOyGE4AmRWbzGcRDlbSCntCxDEAuOV48SkMx7PdbidJcnh4aGf9BO6YmByLI4GjCteLYpCGTlPFsh4uxMGij3HB9ZiSf8GVuAP3gnGGBVMs+T5g7pMePC4kBEQyPKZP+kArJUQ+0T2BYWfyOZdv83IMLaocYkSvK6VWV1ftU3WOA0xThi/8kBqROafxaSTqyJGN+czEtCits9twOLQ1eHc4F554QcQfpiStIhEKPtsRZchfrABPhY4DeXhGTBM+T4ihEdhFH0xfY4eDFlbK2cLuZeh2u+7y0MrpUgYwnIkFm0uUPGDbU+nuoydhhhMbY8bj8Xg8rlQq9u3cbgeRYZlXBACXmYcr9zMB5loRsD5VObEINR8N78XXO67n/YqssiyLomg4HNqHM7nJACCKouXl5fv37w8GA/dWOyjgfvAgwAcLwhM7kviGpyHiM9Es5Cz5aecG9uEcd8q9aY1zJvGJezMgxgPD+niRNhiw2DTOCsTTOISQkOva8teKkmmBr54PA/B41MYt+6ysb2K+srKSpil+DlEcnUSFAIACheMb2PAgQyisY8DNttg3BsRxjN9Sblu5yTsJV0qapCs0UQaPx/HfBJOSxlhQN5gwtgL6QH7MAQI1t6arwQ7DP51ievoSaawbFwMPfbsQb19iyyU3xthH6Y+OjkajkX1QWIwQYSTNxBkJToDMzvn4kCQGEuwa3JG9wZUkiftsgtNLoVvRXCTso4J6cT7CckO4fZFZDin2rUu+wUdCFGdia/CD1JCPfCSekYsdpZQdtbx3y1Nrvba2dnx8bJ9iBQmyuCGJZ76xK2oBHiMTxPCMIbb19WgfFQSAarXq3tmHJVfTd1GLnfqMAPkYIUruWJ34gDgp4G8+VrhdcN/2DR/E+gFjYRTivhxnzEqxuA1oGDgCu5cB0NP0ePvh/Px8HMe9Xs8tq2LQiPcKeRHHLj5LQOkzAjkrKkgkBIQGN50izwkSR7tn6iHvUOICXMNZkRrsr4hLRoyo8ukPnxKbcKtF00JkJWblbwoBhC2CJ9ElgLDOu8DYctzc8cLCwmAwcC+5I86DfPEhDFuSUPqCUKC43sUQSMgAwL663L693fVCYgRMb+fjBzN9DiWiKmk6hI8xgXBbLaw/F7RI8aVz8hNDx+SjDu804HLy1+bi4+NjjFFSlpaWxuOxm8LjAsiCvk5xPRkJvBUh4JbBrJQ0MyNN7DpCkiTk2UACDowA/JQl+DEgSstbGTZBp8+riM18HuX+9h3j1/OZfPZ0rPBzbeAPbASCOPtgzvjtNLa4mYcLjbaVvfljX7pydHRkF7REO2K9MG6wHXzGwWdJk4DlIT/AuFMmkwl5ixjhI6IKANyX94gjsNFEeUDyskKTIltzcq2E4xj3FuHC1cZNFLuacBdlOJBi5Uk9l4R36nO8qLwt9gNrBu2rwTo2Go1erzcej907XsVCBsZMwbBqIlsysEXj8+Z27de+UptIEsCEmcZvF9uMZ+ZAEAPIrYEY4SgjyMOTw5ZLxrsnTYiSSilyAcKNi0Unkcl4ZmDYhUR+7iErwHA4tM/7GzQld7ef7TvQ7e4//m4tYgQfrEkrMlpw4XzIWVyJI6J9n7a1qg+pvC+FAnm5XOZwBAZxfMzBxyGFz57cK+TnsINJN2Lh1nfHcRy79zBxMvGpCkAjAzw3tgD5xpnPnXWPcTpsJUlip/CkWAL7alf3dhosqspPIEQTBdQXgWhQ7giMYVtvplnbXvSJz3PjJk5skFxmX5DJe+RKcV1EhrzmZO2bSGM8C1ciO5GG8OSfanGW5Zo4Mvf8ND9Lhhdm9UC3KBeP7V87zcKByh3bz6LYqIaFJAKTUyKqCpaA4pjGFrtvzL7UikcBEqTJAZGfzNydGMTjxL9FdHFlxusJzlMIT/cKZK4zGWeQBw3Bny1uvdRxw+DDoc5V2iUPe4fH7WB2odqisNlsjsdj/Ho3kk3EiOuLWGKEBgZWXENA5iBlcY+t4WjEwMORZ4wZjUZ2Id430+eVxQtpnlu3FLXyjUge6okdSU/k7f6YP0YYBopC83qdf7UkJnM1AdOY6XJzqVQ6Pj42aIEUi91oNNI0PT4+5s1FiGBb+XrnBgyEdlLsbRn3tTMCo4C+vlNRFDUaDR7ysZA+3ItexmS4JNxb5GfAmpjpTLzbiCW+0oiDW6GLDo48Mhw1ej8bVxXzx8FSo3eDOz72Red2/o5vDRHtRDQHxp7PhuJZM51O2bmUyl9/OacWTE+OLMsy+y1MmwfFFEmOCVlg/PDms9/dIFqWEBRR0l4Yix9KBb9XsG98sVBsSH7aJlYA+9ltgmCLM7sLwK3RQ36Yin2FIxmwEUh0IaxgGqWiKMI3zh2qipiajFVb7GsQG40G2ZGMyXyuLBIjCUFk8qPKqUcMSsJjwNCc1YOeosjeRhDDAA453II+z/FQR1g5SVwSsffI8KOqKj9zqtVqdtWR8wEGDtEUAdP5dLGVdgnX3cXDsdlZLIxO8IxYd4FsP0p42sLV9HnEldynaUQpRRCQFEAs6Btb+IYDFtQxFP3ELUW6szV4zYKD0s2oMLAwgWtiX8Ft5++KzdZ5DZbB52yiDk7KML1EtbuoyRN/JNo5qxIXcC2IfeyOIPtdY7Eh7gJbmOguHnNW4B7/ImqDPyr6LCV2TDjgHYzcuGb6ATCfgcRi0ORDsZmWKBvkn6smTeznT+wEn6hTBDe8hH1gpg9wu5vHAflVfpbt64VgRWtdKpXm5+ft8hVviEFJAqQIhiIyJCTM+Hp1x9gZYhNff0op92JxhSakuIlmXxXATOyEGj94w4n56/CJPDhcafQKAzWdzttvPPFtDoFOse64X3fMgy5MIaWmd1eITTATkZUoEjapO5hMJqurqwsLC/jll4QtCfYcFZietCKjWpHdDSafzklLUWiiGFZeBHgURS5ocdST3sWDAPIARRQyHsgAMNO86cTGYLUrPfjL52IvvBAyX9C1x/Z1hPZjBbg+rB35y0UCtgN7NBqtrKwsLi7yLzYQf4mQ5UAXpeUHkUIzRF+XuBL7w6A0BHn8khHgCt55jXmSgrdMcZV80YiMNtGIMJ1m4S2pCr3GU2ttv8iNJ/icoVgC4dapZj87hbe4+CCIawKo4sTuOI7j7e3tdrvNA4HIioABu974g7Hoa+ErFxwuYiABCafWdr5wbYyxa332ZUMm/8Q3llLUnDiAZD2TX1AFT7HmNtOJPCdQSpVKJTt/d8MgHE7wWBJjuZmunI3HYxylzlYMyxhYSJgap91u25fLBd6Gz/kQn/p+8lOEifyIfVjowFlSyaWxa3QnARM9csh7J3GUzK7wT1+AJAQYdnaLGG7lTjUaDa21u7ET0BEXIq1rZW9E2u+fVatVNx6Kc+YdkS5Mfv90q9V69NFHbfrDY9XH0CeMkxMfiKw4POQvrPrimzsVIBC7wcfVatVeB/EkK8ZYnODEfkkvYWyBZ6g5bKnp91HsUhZRnFjAl3xxxLXhuVKpWIRxebgwoti+gn1fq9XsN2bxkOaYEOM6187XkSgw8cLJe95xA186A+Q5kq04CFwhUlar1aOjI5W/qYKx6GqI51ylu7QMyAMSFh29DRtZlvGHNo0x9grD3vrlHiJWJgkaU9rnGe1cCgtM7Cw6GFuPt8K2sqVUKrXbbfeBCXGABfIaiS8iPSbzhRtXIyw3+AALEoaIcAT1oh1LpZLdncKVtAsKPpsaacnKFzOAjU6iFAmN+K99NEp8xTdW3zdkzfQrnvib1jw6kkrChHdnphd9+JT92W63FxcXfbspiadEI3ABxMDsQyTkna7sA6u8P9I3iQS+StIZiRxY4kqlQmYwtuDNgOKYEMVQ00KMQhCJPYQfRyPS2n4rlYqNWDgAcKAD8429eWwXVkSz8MqAdiD5xZnI7npdXV0lz1CQHnkIJCpwLSDvNWJJrjsPuidXhURD0oHYNylioMI83UGtVuv1emIvxPqEocMc5unc7yh9AYyAiUQp15ddcbA7/vhX/BxqMQc1/QqLjXairQh68EDnvifHWH0ASNO0Vqutra3V6/WwX0hcx51ywU5VePQC5Ef6MAVXDJA1MUID+ojBAPPHmwhwMND511k5hzkdNHt1DEy/Moeh6dY/yXDHmHD8zXQtwIUxY4z9RrA41zb5jGBbjUYju9mLW89nMV848dnTSm6/8LixsYFX0vmQxkhyzbEvCNSAgY8LyVmRJrg+wfYif0UriJDnVuCsMH0URfV6fTAYEKOLsZMPL25xXMl30JNQ4Qjw+juOeW6vKfmKPS9KKTsV44nPZxkyPgOUWGwLKa318vLyysoKWYEjgZAMM04AeTQQYTAZrxfdyn8mzkA+JUXIn79UKhX7zUFg9sXIcPGMYALyyMPRkdiFjwQOO8zKVtoMiO/q8PRkPU2ilDgmSXfheA/I97bYdYpms7m5uSl+bh1zJoOQE/tGHRc7ICSO/WIgyK28i30QRxbBVhGaOI4bjUa32/WNDCIAATdednf0Gn25TrS+QVkSj1cMSnvKfmKT3y50KdtOp+x1n2sIEo4L5juuu50nZFlWqVQ2NjbsdIoMOSI85+aTpEj+IRgVO+XE9iD3IUyTz3ckchJ9xG54wuYGdZX1ev3w8JDsueO94LbAQMAV45Uk4HF7YUpLbFMbB5aZTqdc7iOhjgjDjcCLOJDcHlcyneKmIJb3+Z77l8vPAUCGnyiw2CT3IUz8lxiLnOIKcOG4CQjbOI7r9bp7eRCeO/vMjV+67AMQERXfjsRn7QZl0VswffWSzj+nPxqNlFK1Wg2jk4wWEeuiWQLD3c7t2u326uqq6A5XE/YCoEEFkhP5T4wtH2XgwJWid0MDIUSs5wlCNEGz2ez1emZaAv2SIGqmL2HDxHr6rR4cmchc3qHWTC8GiVR22m45u7P2HpR77z63xsxCzEJ0dGztyur8/Pza2poLimJf2PdivORjDPJjUoQU7pEEQneqiPpFgXVaO0J+rIAHiOVyuV6v9/t9kYOzHXE/XnQIh1UlzR5cpUMeX+aA6Vef7EJupVKxO224CpAHfcDo/BQOVPbdJPV6fX19vVar8U/kcS1mngqEgyIMw7EjzERY/fP9LFJIkgqMCVfm5+ctsAgKeboBhAkLOD70Mf58Y8sXSt1P+9yVfaDF7Rgm4OPFlwRJ1+TYRakkSTY3N+fm5orYXMRoEWvPZHgGp4utEm7WMN+ZEpDhQnI2nx7ZoHV0dER6IayweDh3AAoYLjW4XXuAfEm4cd9jyKrpcjwJVDOjkQ9SvCP3dD8ArK6uLi8vi/vDxC64C3wRgRvcJw8w3Kv8BCbAh8dI+eZDoPiiEa8UE7xic0ljzOLi4mAwIJdgriHZjixOUNwxDwmExoGDgBXyAHU5twiqRMG41lgeF6gWFhbW1tbsXaCCuS/sAqJOEf/yhMCbF8SJnAqLtz9b8TFPkmR+fn5/f58EOXtWvDNN5pJ4I03YTGTk2XciAPMHf5hspteJ2JBHNo6ydhmsXq9vbm6KT86cp5zTgw8LAIUm7zMnpAGXq/xMmYxLVzk3N9fr9ew7esibcF1zcT7k0gqBjk9acuDbveR7aqPIFIRbA6PKbqfZ3Ny0+4bFiE66EGvcsWjPgGy4lagO8SA+FiczopyJyQcJX5rwiSiiisRPX/rHJY7jxcXFe/fuGfb+Dw4yYADyzW9mggxnKOtmDD6enoitiAzYJq4LnX+j7srKip1O8czrwxN3xGlBT1iJp8Tu+FRBBCI+ZX8KC6TA4MzRI/qAxCdRSTII8M9ms9nv9+2yFqckJhDtOxOOnNigtS4SC12WJJx9OmIwwRSyLkoZYxYXF+3eKdKRyD+gmq/4XE6ihg+g4eMAakVRE5MHLHGeD92YgNOT0cwhwuOiPWi324PBwH0vCRuFwJFbkJjAHYvGxeq4rTtujd5FF7dJiw9r3js5dhd9WZY1Go2trS27vsoHtziQyKAlxzxac3tCfgyIVuI8fSF5ppzESicPyWD5CJ5IJCPjjNNjcXns5fZypVQqra6uEjFcX7w70jV/5RXXi8iDCfDVPv5QCiYG5FQfwsx0P4K9Uf3II49cunSJvDyIaOeK6GNReD7CA0bm4xn3yAcGiRp8TBJQcl8nxFsEqoEILEKbIz2gD1beNW82m/Pz83bXA5eEWxyYp01+5zHuFD/gz02Gedo3StpHxPita9wvMaA9sB8eW19fX1paIu/Z4tLykAwISdjaXF/Rqtwyoqm5KwOUxJLc6YAGv7Ib/XAwELvhheOURDVSOAHuF/K4WVlZOT4+tq/Otqd0/kvG3L64Bsd/bg5y7KtxrxPyRVzSxBUbq5aWllZXV+2mLm4ZPvT5UJkJGm5M3pZ3xDmIfES7cRfwA8dBeKMfjkOiKXk9iUlcW/yTQJkLEEXR1tbWtWvX7JN95KnLQCjlXcyMu9hwTgZjzGQysU/BB+wAeUgZY9I0tdvx7BcSxb58NvHRBNQkMvPRHuhR/OmDqXgqLB59YBVLKQZ/PjLEcO1o+E+RP6FMkmRjY+PGjRsG7WIgz+Nzq/mchE2MZea3/9y9oPF4bN8GQJoTmV2UyrKsXC5ftgwsxQAAC69JREFUvHix2WyGNSVycs/hjriReUIkdiBNsGd9wUwckASg3Fk+gNomCfnti0Y+mnArUROONhzAXHN7n//mzZtuH8vMIBrIfaR3EqL4qX6/T74caTHkKF2x0/yNjY12u8234/HIISLGHfNRijslkZ4bFnMgGIUg0Hnv5C8xqeh33JGw8s6FJvGMmMkHcB5diNG50KRmfn5+PB7fu3fPocoGrcBTgfaAoxDLGba1bTUcDhcXF4EBEe/YsRtdFhYWNjc33c0+HiFEx/tcAmx4OEyTSmI3Hsk4RrnZi4xDn4+4PLgm95QOhwhXmIdlYnqCMGduHj/DqLdleXk5TdP9/X3MnI9FwpAIiV3O4yUwv7o9WD5iu5TQaDQ2Nzfti9p8qOJKkfgqkokDgHPgP4llSIwIVELeg0RgbmSREosh3ysUBw3k3SACGWOR24WIiPviARKm3lpbW9NadzodkmjEmCyqygePSOwS7tHRUalUIu+Igymk7M2+Rx55ZGFhgTiYWM8nCTGgzyz4gCQNDgvOXywE09gO3PiYkg9pbmpcmbse5mPIN2gK1oR/krGLD7AaURRtbGxorQ8ODgB9r9X3dR3SBeQHBnEnfogeppOno6Ojer1OnkJzqNrY2FhaWuLfiSDWE+NxwAIQdAQf6uRYlATTEIByOX3xggtAjEkQYlnl3lMIDPK4Sy4uKeGzREoiLgn+xChxHF+4cMEYc3BwYKZ3qfk+GV+osLBw/EmWIc9AZ1k2GAzW1tYALeXbRwjtdAq/KEGMxMRoon1EAn7sC64+Y5JKX1DEwOUe98kwUyT8M3cTmo8wx0I8DlP6SngcixxskwsXLkRR1Ol0VH5xC7flw06ssQUYMrTWw+FQTR/FsafcdMqtTvEQRWKtT5GApjyWhI1GKnnUJx356n09BrQQzxI75J4rJBRcLFF6PpKIuYnJeJQuwhAA4jje3NxUSu3t7dl634PRLkRBftT6UIvHYqfTqdfrdkeynU5dvHix1WqRjMmzwMyIDh4villGlJPbRFQf0xORuJC+QUjk5KNIHFeu0A+dgzSqfNDhknECoh6PH6JWPp5xHG9tbcVxvLOzQ2Qmutka/DlgMfiTjuyHMNfX1+0+6fX19Xa7TTbP+OIBsYYYTvBPMciFrcoNgjnzLiBvf94jb8i5cYBiJr44ffK24LA5uGl4Kz58CT2WL2w432hwmNjd3b1z5w5MtyS4G71OTxwgidVMfi+h46m13tnZsXf6lpaW1tfX3XccxLAt6gUeB4MUD0T3iwFAZCjWi4HK1xchIDJgaUXxQn35QndAtyKUxZsX4Q/SZKjX6127dg2mqIL8ly9djYhj8n4iWwaDQafTeeyxx7a2tvCnIgM+Dpw9c3mIpvv0OM9klXuKHCORIBckLItZ33UsdCbBHwsamKCQs/Z4OBx+8skn9vsOajqjx3ELS4Uv/TBA7U/7/dLLly/bz/mRvfCBaOSTmYzvIqqFoz7RHTcHKXiIDgqHW5/MPmxwO5wkioDCYglD9TxnTyWDO8iy7ObNmwcHBxYNODNaGhy0FHqjrhXG3pZJkmR9fX1+ft731bvPRqPzsPr04tzZOhJeXgCeMVGEexj4IGEfMxRnJL5pCpZzf3//1q1bIKVFogUOWvatySsrK3Y7Hld2pgxiSCASEmW51iDFGyKMrxUPbIFK8BSfI0RWIkNOf4olzc/heMKhazwe3759+/Dw0G37JDkRS2K3JKysrLgtCZzyzCIVYVKEjNOc34wiQM/GOSweXVUnYYYPMrEDYC4JjJiCEauIYpAf5caYo6Ojmzdv2o/VAItbrsnS0hJeRwAWZQsKcAY3B2Yn5+zroYze8ytliyJ7wHkDCF4WBfAkQvNUGD2tkm7+dHR01Ol07JNkLhQppSqVyvz8vPsWyAMTzFq0LCgtbyjmQbFHX048LdrI3MM38eAqiKwCAsy0WC5iiY3D4zhgoJkHM89yMWb2js9mWTYej+3biCqVSrlcFl9Be4bMVUSYQGXBMrPT8OwqMLADUXOmQwsKEAJWgIV46qFPpD6lmdlD6egzk+1UZaabfH/F5gUDuchBeGCVNCPgE2M1T2GuLf5JeBYJdSIrHu1571zUIqV4W3IqrKlPZrFhuFMsp8jNF4E4kkQ8hUMaoX8QnMQAKTb4r+WhlHPa81TNC0aXh9tvgPLkSegiXAixqxHJZvLhlQH64qdmCsDbEo2KFG4Hfsznf8X5n7YEUMW79rmSsJrZUAyuD07p/NclAzE5nCh9BD7iM1TOLGI+OjOrcAo7J6szF5LsivMX869vSlPQj4HpGt3dcM5CpjsziR9ivzOvLc7PihCEO7UHIsEZ0lZBLc5sf1EkyEOwoBiWJlH5Uc4HhKvnHfjMN3Nchi8IYFbACAwpMpJEeXyBDVCoxxy4AAE7+IZyWCrfTNlnB5/8WEJRDHHSzVN2wEfgxxxM1wuNm7yL4D1DJZeS6yOi3sdKdKEYe2ey4haZyYqYmyNDZOXjcFqpgI3YgmY/QxGHwXk4zH7QxZ1yBIGfJJbQvMuuWjElMPQ4Mo7+sMCYJ7DhjscWNoqoOBHGNsciceGJZXzCiMSYbKaaoq3EA94RriEqE/Wxvtw42DXYfSdvmxGVdBb32UWUCXvRZxouilgf4IAbcmRwFUhfGF5Wdz4qeC9i15ge2FgnwnMQcG5c5iIYcrqIXWNLitDnFiDd8VFE7JyzqghGn1bA8ERAIJ7irIh1xF5IKywe74XbjhOQaMeDnxgkeNecj8jNZwdgviGsuKiQL4SMuJYYgftOdJlvWHJWPjBQ7/tuQgd6JSYW0cD58LzDOyUEgWNfTbjfE7UlyWfyJJx5Q5BsxfWdKZVINtNWvibiqJ4Z4wOtxChAOs2tY0Ee+HysBOrDp4pQimHvbPxn9ivGfF8gdDXgiQThjoCh52xazJS5oBZhI5+Wlbf5OV0FpxlYgeEVbv5ZlocoQ3FWgczwt65YrXPPF7hjUkOakQMODswK0+Phy/siHMiBjz9vLsrpE8mnpk9ZkbMoOWfFuRFin/2LsA24L9BdcQLuNd+BKriO5drwGQAgVBkpGhmW0UkrQs/5YLmLsCKUhIAzn9lvWOywhD7+oq3OwEqUGZjjRNOF58dOhjOzigi1Y0QAx6FGpp+uXjzrmAe8wqUkTQhn7gxSlFJhIbFg5BTvC7MKUJJeRHNxW3HPiaxIJbEhdq2ZriJhyX0Dg9fjVu4nqSRSEVYnSzgi3dnKw+LzqZa/FUL+7So59Iu7G8QYSMjIZJwMPhIeCX+Rkicd3jsXLJwycBMsp6hyQUU487B2oqiEoS+WE/kDbX0WE4UJ2yrMjdgBk+HmIZUeYiT77IPiQ+kozIQnwYdSzqnaQ4zE52EVORa+v7zytPR8qsEnT76pjzsgx24iSPoF5G8fB8yfjKuZlfwsEZtLHubP7YZHO+8LpHAiih2WkPwVC+loJhJwzUNYxypSTIG1rs++GJbQHwrD89N8fspppXX0uYjFidxBYAyJo4EUHD/ClD5x+RAJcxOHIxnZZ0OVjzN41j5msgJPMDgVK96c8ORd+HxaRAyROaBplvwODGcm3ECkBBYw8c9wiMaswoZzF7o4XwS4BfiANHWdWUiqAqYmSN4SmYvzWl5Jfp4Bsny9APJjSfQpOetdTWDMiX1yn033Kczn/+ERhkHmeiXS8+DB2XL+jiHk8cF7D1wGisecmPcrNiEE4YJlC/geGNTCOOOV5CoSWxKDADxO9BGIvrbFedngOVZg7J4hWYSZnCpOnFMqTmbOPa9ybYk1zzYXeehSnYEgBwj/VpdT9XLyZQreAeFoaXDHZATweM7jkBh1RYlJdCQ8CwospmluCNGmHECiKXlC5ww5f8WmGZxSJAvYioMVC8mF4bYNd8Sh4jOOMf5XRZ6tPJTw9qky/NyWz7mmp42p9NuhOB+LOT581nccmC5wBYgyAVZhPjMpi/P8DFidQVORAOeHIjQFCXxTTB+r/x/orEKbtlVUngAAAABJRU5ErkJggg==" }, "Event": "nodeQueriesComplete", "TimeStamp": 1588422766, "NodeManufacturerName": "EUROtronic", "NodeProductName": "EUR_SPIRITZ Wall Radiator Thermostat", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Thermostat", "NodeGeneric": 8, "NodeSpecificString": "General Thermostat V2", "NodeSpecific": 6, "NodeManufacturerID": "0x0148", "NodeProductType": "0x0003", "NodeProductID": "0x0001", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1, "NodeName": "", "NodeLocation": "", "NodeDeviceTypeString": "Thermostat HVAC", "NodeDeviceType": 4608, "NodeRole": 7, "NodeRoleString": "Listening Sleeping Slave", "NodePlusType": 0, "NodePlusTypeString": "Z-Wave+ node", "Neighbors": [ 1, 3, 7, 8, 9, 10, 12, 13, 14 ]} OpenZWave/1/node/16/instance/1/,{ "Instance": 1, "TimeStamp": 1588422682} OpenZWave/1/node/16/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1588422682} From 8beaccf2ddb5a6e67619738996b86420b7ba831e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 17 Jul 2020 15:04:04 +0100 Subject: [PATCH 022/362] Change ZHA power unit from kW to W (#37896) * Change ZHA power unit from kW to W * Use POWER_WATT * Move kW to W conversion; ignore unit for power --- .../components/zha/core/channels/smartenergy.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 7b12411b84f..9138ea09782 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -3,7 +3,7 @@ import logging import zigpy.zcl.clusters.smartenergy as smartenergy -from homeassistant.const import LENGTH_FEET, TIME_HOURS, TIME_SECONDS +from homeassistant.const import LENGTH_FEET, POWER_WATT, TIME_HOURS, TIME_SECONDS from homeassistant.core import callback from .. import registries, typing as zha_typing @@ -60,7 +60,7 @@ class Metering(ZigbeeChannel): REPORT_CONFIG = [{"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT}] unit_of_measure_map = { - 0x00: "kW", + 0x00: POWER_WATT, 0x01: f"m³/{TIME_HOURS}", 0x02: f"{LENGTH_FEET}³/{TIME_HOURS}", 0x03: f"ccf/{TIME_HOURS}", @@ -135,6 +135,12 @@ class Metering(ZigbeeChannel): def formatter_function(self, value): """Return formatted value for display.""" + if self.unit_of_measurement == POWER_WATT: + # Zigbee spec power unit is kW, but we show the value in W + value_watt = value * 1000 + if value_watt < 100: + return round(value_watt, 1) + return round(value_watt) return self._format_spec.format(value).lstrip() From 1e8676bf2c19b7ab7703de110539676a2170a11b Mon Sep 17 00:00:00 2001 From: rajlaud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 17 Jul 2020 11:21:42 -0500 Subject: [PATCH 023/362] Fix bugs updating state of `hdmi_cec` switch (#37786) --- homeassistant/components/hdmi_cec/__init__.py | 11 ++++++++++- homeassistant/components/hdmi_cec/media_player.py | 8 ++++---- homeassistant/components/hdmi_cec/switch.py | 13 ++++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 471a2dd0f46..c9a5d27a3be 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -353,7 +353,7 @@ def setup(hass: HomeAssistant, base_config): return True -class CecDevice(Entity): +class CecEntity(Entity): """Representation of a HDMI CEC device entity.""" def __init__(self, device, logical) -> None: @@ -388,6 +388,15 @@ class CecDevice(Entity): """Device status changed, schedule an update.""" self.schedule_update_ha_state(True) + @property + def should_poll(self): + """ + Return false. + + CecEntity.update() is called by the HDMI network when there is new data. + """ + return False + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index 180580ef371..c3cab6a8f98 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -43,7 +43,7 @@ from homeassistant.const import ( STATE_PLAYING, ) -from . import ATTR_NEW, CecDevice +from . import ATTR_NEW, CecEntity _LOGGER = logging.getLogger(__name__) @@ -57,16 +57,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data.get(device) - entities.append(CecPlayerDevice(hdmi_device, hdmi_device.logical_address)) + entities.append(CecPlayerEntity(hdmi_device, hdmi_device.logical_address)) add_entities(entities, True) -class CecPlayerDevice(CecDevice, MediaPlayerEntity): +class CecPlayerEntity(CecEntity, MediaPlayerEntity): """Representation of a HDMI device as a Media player.""" def __init__(self, device, logical) -> None: """Initialize the HDMI device.""" - CecDevice.__init__(self, device, logical) + CecEntity.__init__(self, device, logical) self.entity_id = f"{DOMAIN}.hdmi_{hex(self._logical_address)[2:]}" def send_keypress(self, key): diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index aaaa2b83054..ea0cac76a99 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY -from . import ATTR_NEW, CecDevice +from . import ATTR_NEW, CecEntity _LOGGER = logging.getLogger(__name__) @@ -18,27 +18,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data.get(device) - entities.append(CecSwitchDevice(hdmi_device, hdmi_device.logical_address)) + entities.append(CecSwitchEntity(hdmi_device, hdmi_device.logical_address)) add_entities(entities, True) -class CecSwitchDevice(CecDevice, SwitchEntity): +class CecSwitchEntity(CecEntity, SwitchEntity): """Representation of a HDMI device as a Switch.""" def __init__(self, device, logical) -> None: """Initialize the HDMI device.""" - CecDevice.__init__(self, device, logical) + CecEntity.__init__(self, device, logical) self.entity_id = f"{DOMAIN}.hdmi_{hex(self._logical_address)[2:]}" def turn_on(self, **kwargs) -> None: """Turn device on.""" self._device.turn_on() self._state = STATE_ON + self.schedule_update_ha_state(force_refresh=False) def turn_off(self, **kwargs) -> None: """Turn device off.""" self._device.turn_off() - self._state = STATE_ON + self._state = STATE_OFF + self.schedule_update_ha_state(force_refresh=False) def toggle(self, **kwargs): """Toggle the entity.""" @@ -47,6 +49,7 @@ class CecSwitchDevice(CecDevice, SwitchEntity): self._state = STATE_OFF else: self._state = STATE_ON + self.schedule_update_ha_state(force_refresh=False) @property def is_on(self) -> bool: From 1dd5a36f5cf2d167c969af3cf889373d57906684 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 17 Jul 2020 18:27:46 +0200 Subject: [PATCH 024/362] Improve setup script portability (#37935) --- script/setup | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/setup b/script/setup index eb7bda18d44..83c2d24f038 100755 --- a/script/setup +++ b/script/setup @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Setups the repository. # Stop on errors @@ -14,7 +14,7 @@ source venv/bin/activate script/bootstrap pre-commit install -pip install -e . --constraint homeassistant/package_constraints.txt +python3 -m pip install -e . --constraint homeassistant/package_constraints.txt hass --script ensure_config -c config From 6ad794e1f8ab530c0adb2b1a31059aa5e262c6a0 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Fri, 17 Jul 2020 09:29:20 -0700 Subject: [PATCH 025/362] Switch back to create task for Neato (#37913) --- homeassistant/components/neato/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index a80d555708c..311f5ff5f42 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -108,7 +108,7 @@ async def async_setup_entry(hass, entry): hass.data[NEATO_LOGIN] = hub for component in ("camera", "vacuum", "switch", "sensor"): - hass.async_add_job( + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) From 2e4b4dc1883c67e8c93d2b8a55cfc723c2952f6d Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 17 Jul 2020 20:18:35 +0200 Subject: [PATCH 026/362] prometheus: Reduce loglevel of failed float conversion to debug (#37936) It creates alot of useless noise currently. Fixes #30186 --- homeassistant/components/prometheus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 654e6245a57..1a70e4cf78e 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -225,7 +225,7 @@ class PrometheusMetrics: try: value = state_helper.state_as_number(state) except ValueError: - _LOGGER.warning("Could not convert %s to float", state) + _LOGGER.debug("Could not convert %s to float", state) value = 0 return value From 7c9ef39ef6bdf1c6e14776fcf4c8d16ae4a8fc86 Mon Sep 17 00:00:00 2001 From: Shulyaka Date: Fri, 17 Jul 2020 21:20:34 +0300 Subject: [PATCH 027/362] Add humidifier intents (#37335) Co-authored-by: Paulus Schoutsen --- homeassistant/components/humidifier/intent.py | 127 +++++++++++ tests/components/humidifier/test_intent.py | 208 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 homeassistant/components/humidifier/intent.py create mode 100644 tests/components/humidifier/test_intent.py diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py new file mode 100644 index 00000000000..ee257cc7123 --- /dev/null +++ b/homeassistant/components/humidifier/intent.py @@ -0,0 +1,127 @@ +"""Intents for the humidifier integration.""" +import voluptuous as vol + +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_AVAILABLE_MODES, + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + SERVICE_TURN_ON, + SUPPORT_MODES, +) + +INTENT_HUMIDITY = "HassHumidifierSetpoint" +INTENT_MODE = "HassHumidifierMode" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the humidifier intents.""" + hass.helpers.intent.async_register(HumidityHandler()) + hass.helpers.intent.async_register(SetModeHandler()) + + +class HumidityHandler(intent.IntentHandler): + """Handle set humidity intents.""" + + intent_type = INTENT_HUMIDITY + slot_schema = { + vol.Required("name"): cv.string, + vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)), + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots["name"]["value"], + [state for state in hass.states.async_all() if state.domain == DOMAIN], + ) + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + humidity = slots["humidity"]["value"] + + if state.state == STATE_OFF: + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, service_data, context=intent_obj.context + ) + speech = f"Turned {state.name} on and set humidity to {humidity}%" + else: + speech = f"The {state.name} is set to {humidity}%" + + service_data[ATTR_HUMIDITY] = humidity + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + service_data, + context=intent_obj.context, + blocking=True, + ) + + response = intent_obj.create_response() + + response.async_set_speech(speech) + return response + + +class SetModeHandler(intent.IntentHandler): + """Handle set humidity intents.""" + + intent_type = INTENT_MODE + slot_schema = { + vol.Required("name"): cv.string, + vol.Required("mode"): cv.string, + } + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots["name"]["value"], + [state for state in hass.states.async_all() if state.domain == DOMAIN], + ) + + service_data = {ATTR_ENTITY_ID: state.entity_id} + + intent.async_test_feature(state, SUPPORT_MODES, "modes") + mode = slots["mode"]["value"] + + if mode not in state.attributes.get(ATTR_AVAILABLE_MODES, []): + raise intent.IntentHandleError( + f"Entity {state.name} does not support {mode} mode" + ) + + if state.state == STATE_OFF: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + service_data, + context=intent_obj.context, + blocking=True, + ) + speech = f"Turned {state.name} on and set {mode} mode" + else: + speech = f"The mode for {state.name} is set to {mode}" + + service_data[ATTR_MODE] = mode + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + service_data, + context=intent_obj.context, + blocking=True, + ) + + response = intent_obj.create_response() + + response.async_set_speech(speech) + return response diff --git a/tests/components/humidifier/test_intent.py b/tests/components/humidifier/test_intent.py new file mode 100644 index 00000000000..18c5b632aa6 --- /dev/null +++ b/tests/components/humidifier/test_intent.py @@ -0,0 +1,208 @@ +"""Tests for the humidifier intents.""" +from homeassistant.components.humidifier import ( + ATTR_AVAILABLE_MODES, + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, + intent, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.helpers.intent import IntentHandleError + +from tests.common import async_mock_service + + +async def test_intent_set_humidity(hass): + """Test the set humidity intent.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} + ) + humidity_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + ) + await hass.async_block_till_done() + + assert result.speech["plain"]["speech"] == "The bedroom humidifier is set to 50%" + + assert len(turn_on_calls) == 0 + assert len(humidity_calls) == 1 + call = humidity_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_HUMIDITY + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_HUMIDITY) == 50 + + +async def test_intent_set_humidity_and_turn_on(hass): + """Test the set humidity intent for turned off humidifier.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40} + ) + humidity_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + ) + await hass.async_block_till_done() + + assert ( + result.speech["plain"]["speech"] + == "Turned bedroom humidifier on and set humidity to 50%" + ) + + assert len(turn_on_calls) == 1 + call = turn_on_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert len(humidity_calls) == 1 + call = humidity_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_HUMIDITY + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_HUMIDITY) == 50 + + +async def test_intent_set_mode(hass): + """Test the set mode intent.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: "home", + }, + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + ) + await hass.async_block_till_done() + + assert ( + result.speech["plain"]["speech"] + == "The mode for bedroom humidifier is set to away" + ) + + assert len(turn_on_calls) == 0 + assert len(mode_calls) == 1 + call = mode_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_MODE + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_MODE) == "away" + + +async def test_intent_set_mode_and_turn_on(hass): + """Test the set mode intent.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", + STATE_OFF, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: "home", + }, + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + turn_on_calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + await intent.async_setup_intents(hass) + + result = await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + ) + await hass.async_block_till_done() + + assert ( + result.speech["plain"]["speech"] + == "Turned bedroom humidifier on and set away mode" + ) + + assert len(turn_on_calls) == 1 + call = turn_on_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert len(mode_calls) == 1 + call = mode_calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_SET_MODE + assert call.data.get(ATTR_ENTITY_ID) == "humidifier.bedroom_humidifier" + assert call.data.get(ATTR_MODE) == "away" + + +async def test_intent_set_mode_tests_feature(hass): + """Test the set mode intent where modes are not supported.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + try: + await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + ) + assert False, "handling intent should have raised" + except IntentHandleError as err: + assert str(err) == "Entity bedroom humidifier does not support modes" + + assert len(mode_calls) == 0 + + +async def test_intent_set_unknown_mode(hass): + """Test the set mode intent for unsupported mode.""" + hass.states.async_set( + "humidifier.bedroom_humidifier", + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: "home", + }, + ) + mode_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + try: + await hass.helpers.intent.async_handle( + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}}, + ) + assert False, "handling intent should have raised" + except IntentHandleError as err: + assert str(err) == "Entity bedroom humidifier does not support eco mode" + + assert len(mode_calls) == 0 From cee136ec55e8479e0ef9e519113b054895515092 Mon Sep 17 00:00:00 2001 From: Shulyaka Date: Fri, 17 Jul 2020 21:33:52 +0300 Subject: [PATCH 028/362] Add humidifier device conditions (#36962) --- .../components/humidifier/device_condition.py | 103 ++++++ .../components/humidifier/strings.json | 5 + .../humidifier/test_device_condition.py | 314 ++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 homeassistant/components/humidifier/device_condition.py create mode 100644 tests/components/humidifier/test_device_condition.py diff --git a/homeassistant/components/humidifier/device_condition.py b/homeassistant/components/humidifier/device_condition.py new file mode 100644 index 00000000000..7f37fc3b1fa --- /dev/null +++ b/homeassistant/components/humidifier/device_condition.py @@ -0,0 +1,103 @@ +"""Provide the device automations for Humidifier.""" +from typing import Dict, List + +import voluptuous as vol + +from homeassistant.components.device_automation import toggle_entity +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv, entity_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN, const + +TOGGLE_CONDITION = toggle_entity.CONDITION_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) + +MODE_CONDITION = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "is_mode", + vol.Required(const.ATTR_MODE): str, + } +) + +CONDITION_SCHEMA = vol.Any(TOGGLE_CONDITION, MODE_CONDITION) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> List[Dict[str, str]]: + """List device conditions for Humidifier devices.""" + registry = await entity_registry.async_get_registry(hass) + conditions = await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + + if state and state.attributes["supported_features"] & const.SUPPORT_MODES: + conditions.append( + { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "is_mode", + } + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + if config[CONF_TYPE] == "is_mode": + attribute = const.ATTR_MODE + else: + return toggle_entity.async_condition_from_config(config) + + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + state = hass.states.get(config[ATTR_ENTITY_ID]) + return state and state.attributes.get(attribute) == config[attribute] + + return test_is_state + + +async def async_get_condition_capabilities(hass, config): + """List condition capabilities.""" + state = hass.states.get(config[CONF_ENTITY_ID]) + condition_type = config[CONF_TYPE] + + fields = {} + + if condition_type == "is_mode": + if state: + modes = state.attributes.get(const.ATTR_AVAILABLE_MODES, []) + else: + modes = [] + + fields[vol.Required(const.ATTR_AVAILABLE_MODES)] = vol.In(modes) + + return {"extra_fields": vol.Schema(fields)} + + return await toggle_entity.async_get_condition_capabilities(hass, config) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index de7086cd053..df0551245d7 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,6 +1,11 @@ { "title": "Humidifier", "device_automation": { + "condition_type": { + "is_mode": "{entity_name} is set to a specific mode", + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, "action_type": { "set_humidity": "Set humidity for {entity_name}", "set_mode": "Change mode on {entity_name}", diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py new file mode 100644 index 00000000000..76a850887ca --- /dev/null +++ b/tests/components/humidifier/test_device_condition.py @@ -0,0 +1,314 @@ +"""The tests for Humidifier device conditions.""" +import pytest +import voluptuous_serialize + +import homeassistant.components.automation as automation +from homeassistant.components.humidifier import DOMAIN, const, device_condition +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automation_capabilities, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_conditions(hass, device_reg, entity_reg): + """Test we get the expected conditions from a humidifier.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + hass.states.async_set( + f"{DOMAIN}.test_5678", + STATE_ON, + { + const.ATTR_MODE: const.MODE_AWAY, + const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY], + }, + ) + hass.states.async_set( + "humidifier.test_5678", "attributes", {"supported_features": 1} + ) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "is_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_mode", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + conditions = await async_get_device_automations(hass, "condition", device_entry.id) + assert_lists_same(conditions, expected_conditions) + + +async def test_get_conditions_toggle_only(hass, device_reg, entity_reg): + """Test we get the expected conditions from a humidifier.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + hass.states.async_set( + f"{DOMAIN}.test_5678", + STATE_ON, + { + const.ATTR_MODE: const.MODE_AWAY, + const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY], + }, + ) + hass.states.async_set( + "humidifier.test_5678", "attributes", {"supported_features": 0} + ) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "is_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + conditions = await async_get_device_automations(hass, "condition", device_entry.id) + assert_lists_same(conditions, expected_conditions) + + +async def test_if_state(hass, calls): + """Test for turn_on and turn_off conditions.""" + hass.states.async_set( + "humidifier.entity", STATE_ON, {const.ATTR_MODE: const.MODE_AWAY} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_off", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_off {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_mode", + "mode": "away", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_mode - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get("humidifier.entity").state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + hass.states.async_set("humidifier.entity", STATE_OFF) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "is_off event - test_event2" + + hass.states.async_set( + "humidifier.entity", STATE_ON, {const.ATTR_MODE: const.MODE_AWAY} + ) + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + + assert len(calls) == 3 + assert calls[2].data["some"] == "is_mode - event - test_event3" + + hass.states.async_set( + "humidifier.entity", STATE_ON, {const.ATTR_MODE: const.MODE_HOME} + ) + + # Should not fire + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert len(calls) == 3 + + +async def test_capabilities(hass): + """Test capabilities.""" + hass.states.async_set( + "humidifier.entity", + STATE_ON, + { + const.ATTR_MODE: const.MODE_AWAY, + const.ATTR_AVAILABLE_MODES: [const.MODE_HOME, const.MODE_AWAY], + }, + ) + + # Test mode + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_mode", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "available_modes", + "options": [("home", "home"), ("away", "away")], + "required": True, + "type": "select", + } + ] + + +async def test_capabilities_no_state(hass): + """Test capabilities while state not available.""" + # Test mode + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "is_mode", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + {"name": "available_modes", "options": [], "required": True, "type": "select"} + ] + + +async def test_get_condition_capabilities(hass, device_reg, entity_reg): + """Test we get the expected toggle capabilities.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + conditions = await async_get_device_automations(hass, "condition", device_entry.id) + for condition in conditions: + capabilities = await async_get_device_automation_capabilities( + hass, "condition", condition + ) + assert capabilities == expected_capabilities From cecdce07cc8b24ab353e74bec6bb78ef9c0fdf7f Mon Sep 17 00:00:00 2001 From: Ivan Belokobylskiy Date: Fri, 17 Jul 2020 22:55:30 +0300 Subject: [PATCH 029/362] Fix Yandex transport Integration, add signature to requests (#37365) --- CODEOWNERS | 2 +- .../components/yandex_transport/manifest.json | 4 ++-- .../components/yandex_transport/sensor.py | 22 ++++++++++--------- requirements_all.txt | 6 ++--- requirements_test_all.txt | 6 ++--- .../test_yandex_transport_sensor.py | 8 +++---- 6 files changed, 25 insertions(+), 23 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 44345be6b37..c224a61c068 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -467,7 +467,7 @@ homeassistant/components/xiaomi_miio/* @rytilahti @syssi homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth -homeassistant/components/yandex_transport/* @rishatik92 +homeassistant/components/yandex_transport/* @rishatik92 @devbis homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yessssms/* @flowolf diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json index da9d920a26c..bb53be8f170 100644 --- a/homeassistant/components/yandex_transport/manifest.json +++ b/homeassistant/components/yandex_transport/manifest.json @@ -2,6 +2,6 @@ "domain": "yandex_transport", "name": "Yandex Transport", "documentation": "https://www.home-assistant.io/integrations/yandex_transport", - "requirements": ["ya_ma==0.3.8"], - "codeowners": ["@rishatik92"] + "requirements": ["aioymaps==1.0.0"], + "codeowners": ["@rishatik92", "@devbis"] } diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index be029715cce..cde115cb12f 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -3,11 +3,12 @@ from datetime import timedelta import logging +from aioymaps import YandexMapsRequester import voluptuous as vol -from ya_ma import YandexMapsRequester from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.aiohttp_client import async_create_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -35,20 +36,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Yandex transport sensor.""" stop_id = config[CONF_STOP_ID] name = config[CONF_NAME] routes = config[CONF_ROUTE] - data = YandexMapsRequester(user_agent=USER_AGENT) - add_entities([DiscoverMoscowYandexTransport(data, stop_id, routes, name)], True) + client_session = async_create_clientsession(hass, requote_redirect_url=False) + data = YandexMapsRequester(user_agent=USER_AGENT, client_session=client_session) + async_add_entities([DiscoverYandexTransport(data, stop_id, routes, name)], True) -class DiscoverMoscowYandexTransport(Entity): +class DiscoverYandexTransport(Entity): """Implementation of yandex_transport sensor.""" - def __init__(self, requester, stop_id, routes, name): + def __init__(self, requester: YandexMapsRequester, stop_id, routes, name): """Initialize sensor.""" self.requester = requester self._stop_id = stop_id @@ -58,12 +60,12 @@ class DiscoverMoscowYandexTransport(Entity): self._name = name self._attrs = None - def update(self): + async def async_update(self): """Get the latest data from maps.yandex.ru and update the states.""" attrs = {} closer_time = None + yandex_reply = await self.requester.get_stop_info(self._stop_id) try: - yandex_reply = self.requester.get_stop_info(self._stop_id) data = yandex_reply["data"] except KeyError as key_error: _LOGGER.warning( @@ -71,8 +73,8 @@ class DiscoverMoscowYandexTransport(Entity): key_error, yandex_reply, ) - self.requester.set_new_session() - data = self.requester.get_stop_info(self._stop_id)["data"] + await self.requester.set_new_session() + data = (await self.requester.get_stop_info(self._stop_id))["data"] stop_name = data["name"] transport_list = data["transports"] for transport in transport_list: diff --git a/requirements_all.txt b/requirements_all.txt index 3f572bb8f8a..355b2b211a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -215,6 +215,9 @@ aioswitcher==1.2.0 # homeassistant.components.unifi aiounifi==23 +# homeassistant.components.yandex_transport +aioymaps==1.0.0 + # homeassistant.components.airly airly==0.0.2 @@ -2229,9 +2232,6 @@ xmltodict==0.12.0 # homeassistant.components.xs1 xs1-api-client==3.0.0 -# homeassistant.components.yandex_transport -ya_ma==0.3.8 - # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3aae1e3b80..8d3b154c6e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -125,6 +125,9 @@ aioswitcher==1.2.0 # homeassistant.components.unifi aiounifi==23 +# homeassistant.components.yandex_transport +aioymaps==1.0.0 + # homeassistant.components.airly airly==0.0.2 @@ -980,9 +983,6 @@ wled==0.4.3 # homeassistant.components.zestimate xmltodict==0.12.0 -# homeassistant.components.yandex_transport -ya_ma==0.3.8 - # homeassistant.components.zeroconf zeroconf==0.27.1 diff --git a/tests/components/yandex_transport/test_yandex_transport_sensor.py b/tests/components/yandex_transport/test_yandex_transport_sensor.py index e5b6f31990b..069d171a371 100644 --- a/tests/components/yandex_transport/test_yandex_transport_sensor.py +++ b/tests/components/yandex_transport/test_yandex_transport_sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_NAME from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.async_mock import patch +from tests.async_mock import AsyncMock, patch from tests.common import assert_setup_component, load_fixture REPLY = json.loads(load_fixture("yandex_transport_reply.json")) @@ -17,10 +17,10 @@ REPLY = json.loads(load_fixture("yandex_transport_reply.json")) @pytest.fixture def mock_requester(): - """Create a mock ya_ma module and YandexMapsRequester.""" - with patch("ya_ma.YandexMapsRequester") as requester: + """Create a mock for YandexMapsRequester.""" + with patch("aioymaps.YandexMapsRequester") as requester: instance = requester.return_value - instance.get_stop_info.return_value = REPLY + instance.get_stop_info = AsyncMock(return_value=REPLY) yield instance From d0040e60cc80fedcf9b7d7f97d034881935bf420 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 18 Jul 2020 01:23:49 +0200 Subject: [PATCH 030/362] Rftrx force update (#37944) * Make sure sensor and binary_sensor force update This make sure it's possible to react to events on all updates. * Correct addition of binary sensors --- homeassistant/components/rfxtrx/binary_sensor.py | 7 ++++++- homeassistant/components/rfxtrx/sensor.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 33ef6893c52..5f6e32437c4 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -56,7 +56,7 @@ async def async_setup_entry( _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): - return + continue device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) if device_id in device_ids: @@ -188,6 +188,11 @@ class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): """No polling needed.""" return False + @property + def force_update(self) -> bool: + """We should force updates. Repeated states have meaning.""" + return True + @property def device_class(self): """Return the sensor class.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index e105c463b3b..de341307551 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -178,6 +178,16 @@ class RfxtrxSensor(RestoreEntity): """Return the unit this state is expressed in.""" return self._unit_of_measurement + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def force_update(self) -> bool: + """We should force updates. Repeated states have meaning.""" + return True + @property def device_class(self): """Return a device class for sensor.""" From 72309a0dfcc55e83fb471a3d0a956a0323cbf717 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sat, 18 Jul 2020 00:02:49 +0000 Subject: [PATCH 031/362] [ci skip] Translation update --- .../components/enocean/translations/pt.json | 6 ++++++ .../components/firmata/translations/ca.json | 7 +++++++ .../components/firmata/translations/en.json | 5 ++--- .../components/firmata/translations/pt.json | 11 ++++++++++ .../components/firmata/translations/ru.json | 7 +++++++ .../firmata/translations/zh-Hant.json | 7 +++++++ .../humidifier/translations/ca.json | 5 +++++ .../humidifier/translations/en.json | 5 +++++ .../components/netatmo/translations/pt.json | 20 +++++++++++++++++-- .../components/rfxtrx/translations/pt.json | 4 ++++ .../components/syncthru/translations/pt.json | 19 ++++++++++++++++++ 11 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/enocean/translations/pt.json create mode 100644 homeassistant/components/firmata/translations/ca.json create mode 100644 homeassistant/components/firmata/translations/pt.json create mode 100644 homeassistant/components/firmata/translations/ru.json create mode 100644 homeassistant/components/firmata/translations/zh-Hant.json create mode 100644 homeassistant/components/rfxtrx/translations/pt.json create mode 100644 homeassistant/components/syncthru/translations/pt.json diff --git a/homeassistant/components/enocean/translations/pt.json b/homeassistant/components/enocean/translations/pt.json new file mode 100644 index 00000000000..a004e5cae8b --- /dev/null +++ b/homeassistant/components/enocean/translations/pt.json @@ -0,0 +1,6 @@ +{ + "config": { + "flow_title": "Configura\u00e7\u00e3o ENOcean" + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/ca.json b/homeassistant/components/firmata/translations/ca.json new file mode 100644 index 00000000000..29a04d50b3e --- /dev/null +++ b/homeassistant/components/firmata/translations/ca.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "No s'ha pogut connectar a la placa Frimata durant la configuraci\u00f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/en.json b/homeassistant/components/firmata/translations/en.json index 68d7ae8c041..39ea716d975 100644 --- a/homeassistant/components/firmata/translations/en.json +++ b/homeassistant/components/firmata/translations/en.json @@ -2,7 +2,6 @@ "config": { "abort": { "cannot_connect": "Cannot connect to Firmata board during setup" - }, - "step": {} + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/pt.json b/homeassistant/components/firmata/translations/pt.json new file mode 100644 index 00000000000..785f8887678 --- /dev/null +++ b/homeassistant/components/firmata/translations/pt.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "cannot_connect": "N\u0101o foi poss\u00edvel conectar ao board do Firmata durante a configura\u00e7\u00e3o" + }, + "step": { + "one": "Um", + "other": "Outro" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/ru.json b/homeassistant/components/firmata/translations/ru.json new file mode 100644 index 00000000000..64737774a2d --- /dev/null +++ b/homeassistant/components/firmata/translations/ru.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u043b\u0430\u0442\u0435 Firmata \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0438." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/zh-Hant.json b/homeassistant/components/firmata/translations/zh-Hant.json new file mode 100644 index 00000000000..d86ad56653c --- /dev/null +++ b/homeassistant/components/firmata/translations/zh-Hant.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u65bc\u8a2d\u5b9a\u671f\u9593\uff0c\u7121\u6cd5\u9023\u7dda\u81f3 Firmata \u677f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/ca.json b/homeassistant/components/humidifier/translations/ca.json index 353e590d59b..08224cf387f 100644 --- a/homeassistant/components/humidifier/translations/ca.json +++ b/homeassistant/components/humidifier/translations/ca.json @@ -6,6 +6,11 @@ "toggle": "Commuta {entity_name}", "turn_off": "Apaga {entity_name}", "turn_on": "Enc\u00e9n {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} est\u00e0 configurat/ada en un mode espec\u00edfic", + "is_off": "{entity_name} est\u00e0 apagat/ada", + "is_on": "{entity_name} est\u00e0 enc\u00e8s/a" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/en.json b/homeassistant/components/humidifier/translations/en.json index 5a5f803b2a3..8cf8f57c3e9 100644 --- a/homeassistant/components/humidifier/translations/en.json +++ b/homeassistant/components/humidifier/translations/en.json @@ -6,6 +6,11 @@ "toggle": "Toggle {entity_name}", "turn_off": "Turn off {entity_name}", "turn_on": "Turn on {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} is set to a specific mode", + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" } }, "state": { diff --git a/homeassistant/components/netatmo/translations/pt.json b/homeassistant/components/netatmo/translations/pt.json index 8c857ebebbe..634d4810ca0 100644 --- a/homeassistant/components/netatmo/translations/pt.json +++ b/homeassistant/components/netatmo/translations/pt.json @@ -6,10 +6,26 @@ }, "options": { "step": { + "public_weather": { + "data": { + "area_name": "Nome da \u00e1rea", + "lat_ne": "Latitude nordeste", + "lat_sw": "Latitude sudoeste", + "lon_ne": "Longitude nordeste", + "lon_sw": "Longitude sudoeste", + "mode": "C\u00e1lculo", + "show_on_map": "Mostrar no mapa" + }, + "description": "Configurar um sensor de clima profundo para uma \u00e1rea.", + "title": "Sensor de clima Netatmo p\u00fablico" + }, "public_weather_areas": { "data": { - "new_area": "Nome da \u00e1rea" - } + "new_area": "Nome da \u00e1rea", + "weather_areas": "\u00c1reas de clima" + }, + "description": "Configurar sensores de clima p\u00fablicos.", + "title": "Sensor de clima Netatmo p\u00fablico" } } } diff --git a/homeassistant/components/rfxtrx/translations/pt.json b/homeassistant/components/rfxtrx/translations/pt.json new file mode 100644 index 00000000000..350ef57c286 --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/pt.json @@ -0,0 +1,4 @@ +{ + "one": "Um", + "other": "Outro" +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/pt.json b/homeassistant/components/syncthru/translations/pt.json new file mode 100644 index 00000000000..03ddb9523ec --- /dev/null +++ b/homeassistant/components/syncthru/translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "unknown_state": "Estado da impressora desconhecido, verifique a conectividade de URL e de rede" + }, + "step": { + "confirm": { + "data": { + "name": "Nome" + } + }, + "user": { + "data": { + "name": "Nome" + } + } + } + } +} \ No newline at end of file From 1582e4347dea625a9b80fbf2de919a13cf20bc2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2020 15:17:40 -1000 Subject: [PATCH 032/362] Mock out I/O in the default_config test (#37897) This test never passed locally because of the I/O. --- tests/components/default_config/test_init.py | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 00fb1c1047b..7edf1dc7d60 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -6,6 +6,27 @@ from homeassistant.setup import async_setup_component from tests.async_mock import patch +@pytest.fixture(autouse=True) +def mock_zeroconf(): + """Mock zeroconf.""" + with patch("homeassistant.components.zeroconf.HaZeroconf"): + yield + + +@pytest.fixture(autouse=True) +def mock_ssdp(): + """Mock ssdp.""" + with patch("homeassistant.components.ssdp.Scanner.async_scan"): + yield + + +@pytest.fixture(autouse=True) +def mock_updater(): + """Mock updater.""" + with patch("homeassistant.components.updater.get_newest_version"): + yield + + @pytest.fixture(autouse=True) def recorder_url_mock(): """Mock recorder url.""" From a7dfa602080811176a43c279bd666c0dd81a86d0 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 17 Jul 2020 20:18:53 -0500 Subject: [PATCH 033/362] Fix Sonos speaker lookup for Plex (#37942) --- homeassistant/components/plex/__init__.py | 6 +++--- homeassistant/components/sonos/__init__.py | 10 ++++++---- tests/components/plex/mock_classes.py | 8 ++++---- tests/components/plex/test_playback.py | 12 ++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 01f80ed0d2b..4556422dd00 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -215,7 +215,7 @@ def play_on_sonos(hass, service_call): sonos = hass.components.sonos try: - sonos_id = sonos.get_coordinator_id(entity_id) + sonos_name = sonos.get_coordinator_name(entity_id) except HomeAssistantError as err: _LOGGER.error("Cannot get Sonos device: %s", err) return @@ -239,10 +239,10 @@ def play_on_sonos(hass, service_call): else: plex_server = next(iter(plex_servers)) - sonos_speaker = plex_server.account.sonos_speaker_by_id(sonos_id) + sonos_speaker = plex_server.account.sonos_speaker(sonos_name) if sonos_speaker is None: _LOGGER.error( - "Sonos speaker '%s' could not be found on this Plex account", sonos_id + "Sonos speaker '%s' could not be found on this Plex account", sonos_name ) return diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index f19816e865d..cc33134c810 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -58,8 +58,10 @@ async def async_setup_entry(hass, entry): @bind_hass -def get_coordinator_id(hass, entity_id): - """Obtain the unique_id of a device's coordinator. +def get_coordinator_name(hass, entity_id): + """Obtain the room/name of a device's coordinator. + + Used by the Plex integration. This function is safe to run inside the event loop. """ @@ -71,5 +73,5 @@ def get_coordinator_id(hass, entity_id): ) if device.is_coordinator: - return device.unique_id - return device.coordinator.unique_id + return device.name + return device.coordinator.name diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index 3812e9c87b9..eacee6d9f98 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -78,9 +78,9 @@ class MockPlexAccount: """Mock the PlexAccount resources listing method.""" return self._resources - def sonos_speaker_by_id(self, machine_identifier): + def sonos_speaker(self, speaker_name): """Mock the PlexAccount Sonos lookup method.""" - return MockPlexSonosClient(machine_identifier) + return MockPlexSonosClient(speaker_name) class MockPlexSystemAccount: @@ -378,9 +378,9 @@ class MockPlexMediaTrack(MockPlexMediaItem): class MockPlexSonosClient: """Mock a PlexSonosClient instance.""" - def __init__(self, machine_identifier): + def __init__(self, name): """Initialize the object.""" - self.machineIdentifier = machine_identifier + self.name = name def playMedia(self, item): """Mock the playMedia method.""" diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index dafc8720ab1..82682ea0ac2 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -39,7 +39,7 @@ async def test_sonos_playback(hass): # Test Sonos integration lookup failure with patch.object( - hass.components.sonos, "get_coordinator_id", side_effect=HomeAssistantError + hass.components.sonos, "get_coordinator_name", side_effect=HomeAssistantError ): assert await hass.services.async_call( DOMAIN, @@ -55,7 +55,7 @@ async def test_sonos_playback(hass): # Test success with dict with patch.object( hass.components.sonos, - "get_coordinator_id", + "get_coordinator_name", return_value="media_player.sonos_kitchen", ), patch("plexapi.playqueue.PlayQueue.create"): assert await hass.services.async_call( @@ -72,7 +72,7 @@ async def test_sonos_playback(hass): # Test success with plex_key with patch.object( hass.components.sonos, - "get_coordinator_id", + "get_coordinator_name", return_value="media_player.sonos_kitchen", ), patch("plexapi.playqueue.PlayQueue.create"): assert await hass.services.async_call( @@ -89,7 +89,7 @@ async def test_sonos_playback(hass): # Test invalid Plex server requested with patch.object( hass.components.sonos, - "get_coordinator_id", + "get_coordinator_name", return_value="media_player.sonos_kitchen", ): assert await hass.services.async_call( @@ -105,10 +105,10 @@ async def test_sonos_playback(hass): # Test no speakers available with patch.object( - loaded_server.account, "sonos_speaker_by_id", return_value=None + loaded_server.account, "sonos_speaker", return_value=None ), patch.object( hass.components.sonos, - "get_coordinator_id", + "get_coordinator_name", return_value="media_player.sonos_kitchen", ): assert await hass.services.async_call( From 9ae08585dcdbd2ec7cf3a34df4c584f69a30dc21 Mon Sep 17 00:00:00 2001 From: Shulyaka Date: Sat, 18 Jul 2020 07:57:52 +0300 Subject: [PATCH 034/362] Add humidifier device triggers (#36887) Co-authored-by: Paulus Schoutsen --- .../components/humidifier/device_trigger.py | 126 +++++++ .../components/humidifier/strings.json | 5 + .../humidifier/test_device_trigger.py | 353 ++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 homeassistant/components/humidifier/device_trigger.py create mode 100644 tests/components/humidifier/test_device_trigger.py diff --git a/homeassistant/components/humidifier/device_trigger.py b/homeassistant/components/humidifier/device_trigger.py new file mode 100644 index 00000000000..906fb96bede --- /dev/null +++ b/homeassistant/components/humidifier/device_trigger.py @@ -0,0 +1,126 @@ +"""Provides device automations for Climate.""" +from typing import List + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + numeric_state as numeric_state_automation, +) +from homeassistant.components.device_automation import ( + TRIGGER_BASE_SCHEMA, + toggle_entity, +) +from homeassistant.const import ( + CONF_ABOVE, + CONF_BELOW, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_FOR, + CONF_PLATFORM, + CONF_TYPE, + UNIT_PERCENTAGE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, entity_registry +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TARGET_TRIGGER_SCHEMA = vol.All( + TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): "target_humidity_changed", + vol.Optional(CONF_BELOW): vol.Any(vol.Coerce(int)), + vol.Optional(CONF_ABOVE): vol.Any(vol.Coerce(int)), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ), + cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), +) + +TOGGLE_TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) + +TRIGGER_SCHEMA = vol.Any(TARGET_TRIGGER_SCHEMA, TOGGLE_TRIGGER_SCHEMA) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for Humidifier devices.""" + registry = await entity_registry.async_get_registry(hass) + triggers = await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "target_humidity_changed", + } + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == "target_humidity_changed": + numeric_state_config = { + numeric_state_automation.CONF_PLATFORM: "numeric_state", + numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + numeric_state_automation.CONF_VALUE_TEMPLATE: "{{ state.attributes.humidity }}", + } + + if CONF_ABOVE in config: + numeric_state_config[CONF_ABOVE] = config[CONF_ABOVE] + if CONF_BELOW in config: + numeric_state_config[CONF_BELOW] = config[CONF_BELOW] + if CONF_FOR in config: + numeric_state_config[CONF_FOR] = config[CONF_FOR] + + numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA( + numeric_state_config + ) + return await numeric_state_automation.async_attach_trigger( + hass, numeric_state_config, action, automation_info, platform_type="device" + ) + + return await toggle_entity.async_attach_trigger( + hass, config, action, automation_info + ) + + +async def async_get_trigger_capabilities(hass: HomeAssistant, config): + """List trigger capabilities.""" + trigger_type = config[CONF_TYPE] + + if trigger_type == "target_humidity_changed": + return { + "extra_fields": vol.Schema( + { + vol.Optional( + CONF_ABOVE, description={"suffix": UNIT_PERCENTAGE} + ): vol.Coerce(int), + vol.Optional( + CONF_BELOW, description={"suffix": UNIT_PERCENTAGE} + ): vol.Coerce(int), + vol.Optional(CONF_FOR): cv.positive_time_period_dict, + } + ) + } + return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index df0551245d7..5a8864b496b 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,6 +1,11 @@ { "title": "Humidifier", "device_automation": { + "trigger_type": { + "target_humidity_changed": "{entity_name} target humidity changed", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + }, "condition_type": { "is_mode": "{entity_name} is set to a specific mode", "is_on": "{entity_name} is on", diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py new file mode 100644 index 00000000000..4f93b4be4de --- /dev/null +++ b/tests/components/humidifier/test_device_trigger.py @@ -0,0 +1,353 @@ +"""The tests for Humidifier device triggers.""" +import datetime + +import pytest +import voluptuous_serialize + +import homeassistant.components.automation as automation +from homeassistant.components.humidifier import DOMAIN, const, device_trigger +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_fire_time_changed, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers(hass, device_reg, entity_reg): + """Test we get the expected triggers from a humidifier device.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + entity_id = f"{DOMAIN}.test_5678" + hass.states.async_set( + entity_id, + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + const.ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "target_humidity_changed", + "device_id": device_entry.id, + "entity_id": entity_id, + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + hass.states.async_set( + "humidifier.entity", + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + const.ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "below": 20, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_below"}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "above": 30, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_above"}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "above": 30, + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed_above_for"}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + + # Fake that the humidity is changing + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 7}) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "target_humidity_changed_below" + + # Fake that the humidity is changing + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "target_humidity_changed_above" + + # Wait 6 minutes + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=6)) + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "target_humidity_changed_above_for" + + # Fake turn off + hass.states.async_set("humidifier.entity", STATE_OFF, {const.ATTR_HUMIDITY: 37}) + await hass.async_block_till_done() + assert len(calls) == 4 + assert ( + calls[3].data["some"] == "turn_off device - humidifier.entity - on - off - None" + ) + + # Fake turn on + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 37}) + await hass.async_block_till_done() + assert len(calls) == 5 + assert ( + calls[4].data["some"] == "turn_on device - humidifier.entity - off - on - None" + ) + + +async def test_invalid_config(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + hass.states.async_set( + "humidifier.entity", + STATE_ON, + { + const.ATTR_HUMIDITY: 23, + const.ATTR_MODE: "home", + const.ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_SUPPORTED_FEATURES: 1, + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "humidifier.entity", + "type": "target_humidity_changed", + "below": 20, + "invalid": "invalid", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "target_humidity_changed"}, + }, + }, + ] + }, + ) + + # Fake that the humidity is changing + hass.states.async_set("humidifier.entity", STATE_ON, {const.ATTR_HUMIDITY: 7}) + await hass.async_block_till_done() + # Should not trigger for invalid config + assert len(calls) == 0 + + +async def test_get_trigger_capabilities_on(hass): + """Test we get the expected capabilities from a humidifier trigger.""" + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": "humidifier", + "type": "turned_on", + "entity_id": "humidifier.upstairs", + "above": "23", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "for", "optional": True, "type": "positive_time_period_dict"}] + + +async def test_get_trigger_capabilities_off(hass): + """Test we get the expected capabilities from a humidifier trigger.""" + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": "humidifier", + "type": "turned_off", + "entity_id": "humidifier.upstairs", + "above": "23", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [{"name": "for", "optional": True, "type": "positive_time_period_dict"}] + + +async def test_get_trigger_capabilities_humidity(hass): + """Test we get the expected capabilities from a humidifier trigger.""" + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": "humidifier", + "type": "target_humidity_changed", + "entity_id": "humidifier.upstairs", + "above": "23", + }, + ) + + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "description": {"suffix": "%"}, + "name": "above", + "optional": True, + "type": "integer", + }, + { + "description": {"suffix": "%"}, + "name": "below", + "optional": True, + "type": "integer", + }, + {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + ] From 910b6c9c2c985c8371f6fcd4764e1c1baf0aeff5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2020 18:59:18 -1000 Subject: [PATCH 035/362] Index entity_registry_updated listeners (#37940) --- homeassistant/helpers/entity.py | 18 ++--- homeassistant/helpers/event.py | 87 ++++++++++++++++++-- tests/components/mqtt/test_discovery.py | 1 + tests/helpers/test_event.py | 102 ++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 19 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 8414bb912c2..7c19d540704 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -27,11 +27,8 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import NoEntitySpecifiedError from homeassistant.helpers.entity_platform import EntityPlatform -from homeassistant.helpers.entity_registry import ( - EVENT_ENTITY_REGISTRY_UPDATED, - RegistryEntry, -) -from homeassistant.helpers.event import Event +from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.helpers.event import Event, async_track_entity_registry_updated_event from homeassistant.util import dt as dt_util, ensure_unique_string, slugify from homeassistant.util.async_ import run_callback_threadsafe @@ -518,8 +515,8 @@ class Entity(ABC): if self.registry_entry is not None: assert self.hass is not None self.async_on_remove( - self.hass.bus.async_listen( - EVENT_ENTITY_REGISTRY_UPDATED, self._async_registry_updated + async_track_entity_registry_updated_event( + self.hass, self.entity_id, self._async_registry_updated ) ) @@ -532,14 +529,11 @@ class Entity(ABC): async def _async_registry_updated(self, event: Event) -> None: """Handle entity registry update.""" data = event.data - if data["action"] == "remove" and data["entity_id"] == self.entity_id: + if data["action"] == "remove": await self.async_removed_from_registry() await self.async_remove() - if ( - data["action"] != "update" - or data.get("old_entity_id", data["entity_id"]) != self.entity_id - ): + if data["action"] != "update": return assert self.hass is not None diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 24110e8e63c..ecbf88d67a9 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -17,6 +17,7 @@ from homeassistant.const import ( SUN_EVENT_SUNSET, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.sun import get_astral_event_next from homeassistant.helpers.template import Template from homeassistant.loader import bind_hass @@ -26,6 +27,9 @@ from homeassistant.util.async_ import run_callback_threadsafe TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks" TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener" +TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks" +TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener" + _LOGGER = logging.getLogger(__name__) # PyLint does not like the use of threaded_listener_factory @@ -137,7 +141,7 @@ track_state_change = threaded_listener_factory(async_track_state_change) def async_track_state_change_event( hass: HomeAssistant, entity_ids: Union[str, Iterable[str]], - action: Callable[[Event], None], + action: Callable[[Event], Any], ) -> Callable[[], None]: """Track specific state change events indexed by entity_id. @@ -186,17 +190,28 @@ def async_track_state_change_event( @callback def remove_listener() -> None: """Remove state change listener.""" - _async_remove_state_change_listeners(hass, entity_ids, action) + _async_remove_entity_listeners( + hass, + TRACK_STATE_CHANGE_CALLBACKS, + TRACK_STATE_CHANGE_LISTENER, + entity_ids, + action, + ) return remove_listener @callback -def _async_remove_state_change_listeners( - hass: HomeAssistant, entity_ids: Iterable[str], action: Callable[[Event], None] +def _async_remove_entity_listeners( + hass: HomeAssistant, + storage_key: str, + listener_key: str, + entity_ids: Iterable[str], + action: Callable[[Event], Any], ) -> None: """Remove a listener.""" - entity_callbacks = hass.data[TRACK_STATE_CHANGE_CALLBACKS] + + entity_callbacks = hass.data[storage_key] for entity_id in entity_ids: entity_callbacks[entity_id].remove(action) @@ -204,8 +219,66 @@ def _async_remove_state_change_listeners( del entity_callbacks[entity_id] if not entity_callbacks: - hass.data[TRACK_STATE_CHANGE_LISTENER]() - del hass.data[TRACK_STATE_CHANGE_LISTENER] + hass.data[listener_key]() + del hass.data[listener_key] + + +@bind_hass +def async_track_entity_registry_updated_event( + hass: HomeAssistant, + entity_ids: Union[str, Iterable[str]], + action: Callable[[Event], Any], +) -> Callable[[], None]: + """Track specific entity registry updated events indexed by entity_id. + + Similar to async_track_state_change_event. + """ + + entity_callbacks = hass.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) + + if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: + + @callback + def _async_entity_registry_updated_dispatcher(event: Event) -> None: + """Dispatch entity registry updates by entity_id.""" + entity_id = event.data.get("old_entity_id", event.data["entity_id"]) + + if entity_id not in entity_callbacks: + return + + for action in entity_callbacks[entity_id][:]: + try: + hass.async_run_job(action, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while processing entity registry update for %s", + entity_id, + ) + + hass.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen( + EVENT_ENTITY_REGISTRY_UPDATED, _async_entity_registry_updated_dispatcher + ) + + if isinstance(entity_ids, str): + entity_ids = [entity_ids] + + entity_ids = [entity_id.lower() for entity_id in entity_ids] + + for entity_id in entity_ids: + entity_callbacks.setdefault(entity_id, []).append(action) + + @callback + def remove_listener() -> None: + """Remove state change listener.""" + _async_remove_entity_listeners( + hass, + TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, + TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, + entity_ids, + action, + ) + + return remove_listener @callback diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 6c317e17989..c1388aeb1c1 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -338,6 +338,7 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): # Verify state is removed state = hass.states.get("sensor.mqtt_sensor") assert state is None + await hass.async_block_till_done() # Verify retained discovery topic has been cleared mqtt_mock.async_publish.assert_called_once_with( diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 674dca474cd..99b4cad6eca 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -10,6 +10,7 @@ from homeassistant.components import sun from homeassistant.const import MATCH_ALL import homeassistant.core as ha from homeassistant.core import callback +from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( async_call_later, async_track_point_in_time, @@ -1180,3 +1181,104 @@ async def test_async_track_point_in_time_cancel(hass): assert len(times) == 1 assert times[0].tzinfo.zone == "US/Hawaii" + + +async def test_async_track_entity_registry_updated_event(hass): + """Test tracking entity registry updates for an entity_id.""" + + entity_id = "switch.puppy_feeder" + new_entity_id = "switch.dog_feeder" + untracked_entity_id = "switch.kitty_feeder" + + hass.states.async_set(entity_id, "on") + await hass.async_block_till_done() + event_data = [] + + @ha.callback + def run_callback(event): + event_data.append(event.data) + + unsub1 = hass.helpers.event.async_track_entity_registry_updated_event( + entity_id, run_callback + ) + unsub2 = hass.helpers.event.async_track_entity_registry_updated_event( + new_entity_id, run_callback + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + {"action": "create", "entity_id": untracked_entity_id}, + ) + await hass.async_block_till_done() + + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + { + "action": "update", + "entity_id": new_entity_id, + "old_entity_id": entity_id, + "changes": {}, + }, + ) + await hass.async_block_till_done() + + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "remove", "entity_id": new_entity_id} + ) + await hass.async_block_till_done() + + unsub1() + unsub2() + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": new_entity_id} + ) + await hass.async_block_till_done() + + assert event_data[0] == {"action": "create", "entity_id": "switch.puppy_feeder"} + assert event_data[1] == { + "action": "update", + "changes": {}, + "entity_id": "switch.dog_feeder", + "old_entity_id": "switch.puppy_feeder", + } + assert event_data[2] == {"action": "remove", "entity_id": "switch.dog_feeder"} + + +async def test_async_track_entity_registry_updated_event_with_a_callback_that_throws( + hass, +): + """Test tracking entity registry updates for an entity_id when one callback throws.""" + + entity_id = "switch.puppy_feeder" + + hass.states.async_set(entity_id, "on") + await hass.async_block_till_done() + event_data = [] + + @ha.callback + def run_callback(event): + event_data.append(event.data) + + @ha.callback + def failing_callback(event): + raise ValueError + + unsub1 = hass.helpers.event.async_track_entity_registry_updated_event( + entity_id, failing_callback + ) + unsub2 = hass.helpers.event.async_track_entity_registry_updated_event( + entity_id, run_callback + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "entity_id": entity_id} + ) + await hass.async_block_till_done() + unsub1() + unsub2() + + assert event_data[0] == {"action": "create", "entity_id": "switch.puppy_feeder"} From 1acdb28cdd6cc0badd50997698ac6d4fa63879f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2020 19:07:37 -1000 Subject: [PATCH 036/362] Automatically recover when the sqlite3 database is malformed or corrupted (#37949) * Validate sqlite database on startup and move away if corruption is detected. * do not switch context in test -- its all sync --- homeassistant/components/recorder/__init__.py | 13 +++-- homeassistant/components/recorder/const.py | 1 + homeassistant/components/recorder/util.py | 56 ++++++++++++++++++- tests/components/recorder/test_util.py | 32 ++++++++++- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c64b9429cf0..5ac4d226082 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,9 +35,9 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import migration, purge -from .const import DATA_INSTANCE +from .const import DATA_INSTANCE, SQLITE_URL_PREFIX from .models import Base, Events, RecorderRuns, States -from .util import session_scope +from .util import session_scope, validate_or_move_away_sqlite_database _LOGGER = logging.getLogger(__name__) @@ -510,7 +510,7 @@ class Recorder(threading.Thread): # We do not import sqlite3 here so mysql/other # users do not have to pay for it to be loaded in # memory - if self.db_url.startswith("sqlite://"): + if self.db_url.startswith(SQLITE_URL_PREFIX): old_isolation = dbapi_connection.isolation_level dbapi_connection.isolation_level = None cursor = dbapi_connection.cursor() @@ -526,13 +526,18 @@ class Recorder(threading.Thread): cursor.execute("SET session wait_timeout=28800") cursor.close() - if self.db_url == "sqlite://" or ":memory:" in self.db_url: + if self.db_url == SQLITE_URL_PREFIX or ":memory:" in self.db_url: kwargs["connect_args"] = {"check_same_thread": False} kwargs["poolclass"] = StaticPool kwargs["pool_reset_on_return"] = None else: kwargs["echo"] = False + if self.db_url != SQLITE_URL_PREFIX and self.db_url.startswith( + SQLITE_URL_PREFIX + ): + validate_or_move_away_sqlite_database(self.db_url) + if self.engine is not None: self.engine.dispose() diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index ed0950b6c6f..fb699d13fb3 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,3 +1,4 @@ """Recorder constants.""" DATA_INSTANCE = "recorder_instance" +SQLITE_URL_PREFIX = "sqlite://" diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 883bc41e71b..8a59cc42a33 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -1,16 +1,20 @@ """SQLAlchemy util functions.""" from contextlib import contextmanager import logging +import os import time from sqlalchemy.exc import OperationalError, SQLAlchemyError -from .const import DATA_INSTANCE +import homeassistant.util.dt as dt_util + +from .const import DATA_INSTANCE, SQLITE_URL_PREFIX _LOGGER = logging.getLogger(__name__) RETRIES = 3 QUERY_RETRY_WAIT = 0.1 +SQLITE3_POSTFIXES = ["", "-wal", "-shm"] @contextmanager @@ -59,6 +63,7 @@ def execute(qry, to_native=False, validate_entity_ids=True): This method also retries a few times in the case of stale connections. """ + for tryno in range(0, RETRIES): try: timer_start = time.perf_counter() @@ -94,3 +99,52 @@ def execute(qry, to_native=False, validate_entity_ids=True): if tryno == RETRIES - 1: raise time.sleep(QUERY_RETRY_WAIT) + + +def validate_or_move_away_sqlite_database(dburl: str) -> bool: + """Ensure that the database is valid or move it away.""" + dbpath = dburl[len(SQLITE_URL_PREFIX) :] + + if not os.path.exists(dbpath): + # Database does not exist yet, this is OK + return True + + if not validate_sqlite_database(dbpath): + _move_away_broken_database(dbpath) + return False + + return True + + +def validate_sqlite_database(dbpath: str) -> bool: + """Run a quick check on an sqlite database to see if it is corrupt.""" + import sqlite3 # pylint: disable=import-outside-toplevel + + try: + conn = sqlite3.connect(dbpath) + conn.cursor().execute("PRAGMA QUICK_CHECK") + conn.close() + except sqlite3.DatabaseError: + _LOGGER.exception("The database at %s is corrupt or malformed.", dbpath) + return False + + return True + + +def _move_away_broken_database(dbfile: str) -> None: + """Move away a broken sqlite3 database.""" + + isotime = dt_util.utcnow().isoformat() + corrupt_postfix = f".corrupt.{isotime}" + + _LOGGER.error( + "The system will rename the corrupt database file %s to %s in order to allow startup to proceed", + dbfile, + f"{dbfile}{corrupt_postfix}", + ) + + for postfix in SQLITE3_POSTFIXES: + path = f"{dbfile}{postfix}" + if not os.path.exists(path): + continue + os.rename(path, f"{path}{corrupt_postfix}") diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 6a4126e76fd..56f1e069a61 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -1,8 +1,10 @@ """Test util methods.""" +import os + import pytest from homeassistant.components.recorder import util -from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX from tests.async_mock import MagicMock, patch from tests.common import get_test_home_assistant, init_recorder_component @@ -60,3 +62,31 @@ def test_recorder_bad_execute(hass_recorder): util.execute((mck1,), to_native=True) assert e_mock.call_count == 2 + + +def test_validate_or_move_away_sqlite_database(hass, tmpdir, caplog): + """Ensure a malformed sqlite database is moved away.""" + + test_dir = tmpdir.mkdir("test_validate_or_move_away_sqlite_database") + test_db_file = f"{test_dir}/broken.db" + dburl = f"{SQLITE_URL_PREFIX}{test_db_file}" + + util.validate_sqlite_database(test_db_file) is True + + assert os.path.exists(test_db_file) is True + assert util.validate_or_move_away_sqlite_database(dburl) is True + + _corrupt_db_file(test_db_file) + + assert util.validate_or_move_away_sqlite_database(dburl) is False + + assert "corrupt or malformed" in caplog.text + + assert util.validate_or_move_away_sqlite_database(dburl) is True + + +def _corrupt_db_file(test_db_file): + """Corrupt an sqlite3 database file.""" + f = open(test_db_file, "a") + f.write("I am a corrupt db") + f.close() From 394194d1e6564b9ae213ed59e234cd95522b17ae Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Sat, 18 Jul 2020 14:19:01 +0800 Subject: [PATCH 037/362] Add switch to pi_hole integration (#35605) Co-authored-by: Ian --- homeassistant/components/pi_hole/__init__.py | 169 ++++++++---------- .../components/pi_hole/binary_sensor.py | 44 +++++ .../components/pi_hole/config_flow.py | 10 -- homeassistant/components/pi_hole/const.py | 3 - homeassistant/components/pi_hole/sensor.py | 41 +---- .../components/pi_hole/services.yaml | 12 +- homeassistant/components/pi_hole/strings.json | 6 +- homeassistant/components/pi_hole/switch.py | 100 +++++++++++ tests/components/pi_hole/__init__.py | 10 +- tests/components/pi_hole/test_config_flow.py | 21 --- tests/components/pi_hole/test_init.py | 155 ++++++++++------ 11 files changed, 341 insertions(+), 230 deletions(-) create mode 100644 homeassistant/components/pi_hole/binary_sensor.py create mode 100644 homeassistant/components/pi_hole/switch.py diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index eba9053183b..9b51cc09b35 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -1,11 +1,11 @@ """The pi_hole component.""" +import asyncio import logging from hole import Hole from hole.exceptions import HoleError import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_API_KEY, @@ -14,9 +14,11 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -29,11 +31,6 @@ from .const import ( DEFAULT_VERIFY_SSL, DOMAIN, MIN_TIME_BETWEEN_UPDATES, - SERVICE_DISABLE, - SERVICE_DISABLE_ATTR_DURATION, - SERVICE_DISABLE_ATTR_NAME, - SERVICE_ENABLE, - SERVICE_ENABLE_ATTR_NAME, ) _LOGGER = logging.getLogger(__name__) @@ -58,20 +55,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): - """Set up the Pi_hole integration.""" - - service_disable_schema = vol.Schema( - vol.All( - { - vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All( - cv.time_period_str, cv.positive_timedelta - ), - vol.Optional(SERVICE_DISABLE_ATTR_NAME): str, - }, - ) - ) - - service_enable_schema = vol.Schema({vol.Optional(SERVICE_ENABLE_ATTR_NAME): str}) + """Set up the Pi-hole integration.""" hass.data[DOMAIN] = {} @@ -84,71 +68,6 @@ async def async_setup(hass, config): ) ) - def get_api_from_name(name): - """Get Pi-hole API object from user configured name.""" - hole_data = hass.data[DOMAIN].get(name) - if hole_data is None: - _LOGGER.error("Unknown Pi-hole name %s", name) - return None - api = hole_data[DATA_KEY_API] - if not api.api_token: - _LOGGER.error( - "Pi-hole %s must have an api_key provided in configuration to be enabled", - name, - ) - return None - return api - - async def disable_service_handler(call): - """Handle the service call to disable a single Pi-hole or all configured Pi-holes.""" - duration = call.data[SERVICE_DISABLE_ATTR_DURATION].total_seconds() - name = call.data.get(SERVICE_DISABLE_ATTR_NAME) - - async def do_disable(name): - """Disable the named Pi-hole.""" - api = get_api_from_name(name) - if api is None: - return - - _LOGGER.debug( - "Disabling Pi-hole '%s' (%s) for %d seconds", name, api.host, duration, - ) - await api.disable(duration) - - if name is not None: - await do_disable(name) - else: - for name in hass.data[DOMAIN]: - await do_disable(name) - - async def enable_service_handler(call): - """Handle the service call to enable a single Pi-hole or all configured Pi-holes.""" - - name = call.data.get(SERVICE_ENABLE_ATTR_NAME) - - async def do_enable(name): - """Enable the named Pi-hole.""" - api = get_api_from_name(name) - if api is None: - return - - _LOGGER.debug("Enabling Pi-hole '%s' (%s)", name, api.host) - await api.enable() - - if name is not None: - await do_enable(name) - else: - for name in hass.data[DOMAIN]: - await do_enable(name) - - hass.services.async_register( - DOMAIN, SERVICE_DISABLE, disable_service_handler, schema=service_disable_schema - ) - - hass.services.async_register( - DOMAIN, SERVICE_ENABLE, enable_service_handler, schema=service_enable_schema - ) - return True @@ -187,19 +106,85 @@ async def async_setup_entry(hass, entry): update_method=async_update_data, update_interval=MIN_TIME_BETWEEN_UPDATES, ) - hass.data[DOMAIN][name] = { + hass.data[DOMAIN][entry.entry_id] = { DATA_KEY_API: api, DATA_KEY_COORDINATOR: coordinator, } - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN) - ) + for platform in _async_platforms(entry): + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) return True async def async_unload_entry(hass, entry): - """Unload pi-hole entry.""" - hass.data[DOMAIN].pop(entry.data[CONF_NAME]) - return await hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN) + """Unload Pi-hole entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in _async_platforms(entry) + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +@callback +def _async_platforms(entry): + """Return platforms to be loaded / unloaded.""" + platforms = ["sensor"] + if entry.data.get(CONF_API_KEY): + platforms.append("switch") + else: + platforms.append("binary_sensor") + return platforms + + +class PiHoleEntity(Entity): + """Representation of a Pi-hole entity.""" + + def __init__(self, api, coordinator, name, server_unique_id): + """Initialize a Pi-hole entity.""" + self.api = api + self.coordinator = coordinator + self._name = name + self._server_unique_id = server_unique_id + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:pi-hole" + + @property + def device_info(self): + """Return the device information of the entity.""" + return { + "identifiers": {(DOMAIN, self._server_unique_id)}, + "name": self._name, + "manufacturer": "Pi-hole", + } + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self.coordinator.last_update_success + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_update(self): + """Get the latest data from the Pi-hole API.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py new file mode 100644 index 00000000000..d572bb390e5 --- /dev/null +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -0,0 +1,44 @@ +"""Support for getting status from a Pi-hole system.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_NAME + +from . import PiHoleEntity +from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Pi-hole binary sensor.""" + name = entry.data[CONF_NAME] + hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] + binary_sensors = [ + PiHoleBinarySensor( + hole_data[DATA_KEY_API], + hole_data[DATA_KEY_COORDINATOR], + name, + entry.entry_id, + ) + ] + async_add_entities(binary_sensors, True) + + +class PiHoleBinarySensor(PiHoleEntity, BinarySensorEntity): + """Representation of a Pi-hole binary sensor.""" + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return f"{self._server_unique_id}/Status" + + @property + def is_on(self): + """Return if the service is on.""" + return self.api.data.get("status") == "enabled" diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index 2b0ebfb7c16..c7061b05caa 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -60,10 +60,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if await self._async_endpoint_existed(endpoint): return self.async_abort(reason="already_configured") - if await self._async_name_existed(name): - if is_import: - _LOGGER.error("Failed to import: name %s already existed", name) - return self.async_abort(reason="duplicated_name") try: await self._async_try_connect( @@ -127,12 +123,6 @@ class PiHoleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ] return endpoint in existing_endpoints - async def _async_name_existed(self, name): - existing_names = [ - entry.data.get(CONF_NAME) for entry in self._async_current_entries() - ] - return name in existing_names - async def _async_try_connect(self, host, location, tls, verify_tls, api_token): session = async_get_clientsession(self.hass, verify_tls) pi_hole = Hole( diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index a5807de5575..cb8087fdbf0 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -15,9 +15,6 @@ DEFAULT_VERIFY_SSL = True SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" -SERVICE_DISABLE_ATTR_NAME = "name" -SERVICE_ENABLE = "enable" -SERVICE_ENABLE_ATTR_NAME = SERVICE_DISABLE_ATTR_NAME ATTR_BLOCKED_DOMAINS = "domains_blocked" diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index d0009f1ebba..179e61a21cc 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -2,8 +2,8 @@ import logging from homeassistant.const import CONF_NAME -from homeassistant.helpers.entity import Entity +from . import PiHoleEntity from .const import ( ATTR_BLOCKED_DOMAINS, DATA_KEY_API, @@ -19,7 +19,7 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Pi-hole sensor.""" name = entry.data[CONF_NAME] - hole_data = hass.data[PIHOLE_DOMAIN][name] + hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] sensors = [ PiHoleSensor( hole_data[DATA_KEY_API], @@ -33,28 +33,20 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(sensors, True) -class PiHoleSensor(Entity): +class PiHoleSensor(PiHoleEntity): """Representation of a Pi-hole sensor.""" def __init__(self, api, coordinator, name, sensor_name, server_unique_id): """Initialize a Pi-hole sensor.""" - self.api = api - self.coordinator = coordinator - self._name = name + super().__init__(api, coordinator, name, server_unique_id) + self._condition = sensor_name - self._server_unique_id = server_unique_id variable_info = SENSOR_DICT[sensor_name] self._condition_name = variable_info[0] self._unit_of_measurement = variable_info[1] self._icon = variable_info[2] - async def async_added_to_hass(self): - """When entity is added to hass.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - @property def name(self): """Return the name of the sensor.""" @@ -65,15 +57,6 @@ class PiHoleSensor(Entity): """Return the unique id of the sensor.""" return f"{self._server_unique_id}/{self._condition_name}" - @property - def device_info(self): - """Return the device information of the sensor.""" - return { - "identifiers": {(PIHOLE_DOMAIN, self._server_unique_id)}, - "name": self._name, - "manufacturer": "Pi-hole", - } - @property def icon(self): """Icon to use in the frontend, if any.""" @@ -96,17 +79,3 @@ class PiHoleSensor(Entity): def device_state_attributes(self): """Return the state attributes of the Pi-hole.""" return {ATTR_BLOCKED_DOMAINS: self.api.data["domains_being_blocked"]} - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self.coordinator.last_update_success - - @property - def should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" - return False - - async def async_update(self): - """Get the latest data from the Pi-hole API.""" - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/pi_hole/services.yaml b/homeassistant/components/pi_hole/services.yaml index 9bb31b1723f..fb9a5c17a13 100644 --- a/homeassistant/components/pi_hole/services.yaml +++ b/homeassistant/components/pi_hole/services.yaml @@ -1,15 +1,9 @@ disable: description: Disable configured Pi-hole(s) for an amount of time fields: + entity_id: + description: Target switch entity + example: switch.pi_hole duration: description: Time that the Pi-hole should be disabled for example: "00:00:15" - name: - description: "[Optional] When multiple Pi-holes are configured, the name of the one to disable. If omitted, all configured Pi-holes will be disabled." - example: "Pi-Hole" -enable: - description: Enable configured Pi-hole(s) - fields: - name: - description: "[Optional] When multiple Pi-holes are configured, the name of the one to enable. If omitted, all configured Pi-holes will be enabled." - example: "Pi-Hole" diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index b155550844a..42faf5d5a46 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -6,7 +6,8 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "name": "Name", - "api_key": "API Key (Optional)", + "location": "Location", + "api_key": "[%key:common::config_flow::data::api_key%]", "ssl": "Use SSL", "verify_ssl": "Verify SSL certificate" } @@ -16,8 +17,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", - "duplicated_name": "Name already existed" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py new file mode 100644 index 00000000000..015bab8fe60 --- /dev/null +++ b/homeassistant/components/pi_hole/switch.py @@ -0,0 +1,100 @@ +"""Support for turning on and off Pi-hole system.""" +import logging + +from hole.exceptions import HoleError +import voluptuous as vol + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, entity_platform + +from . import PiHoleEntity +from .const import ( + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN as PIHOLE_DOMAIN, + SERVICE_DISABLE, + SERVICE_DISABLE_ATTR_DURATION, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Pi-hole switch.""" + name = entry.data[CONF_NAME] + hole_data = hass.data[PIHOLE_DOMAIN][entry.entry_id] + switches = [ + PiHoleSwitch( + hole_data[DATA_KEY_API], + hole_data[DATA_KEY_COORDINATOR], + name, + entry.entry_id, + ) + ] + async_add_entities(switches, True) + + # register service + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_DISABLE, + { + vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All( + cv.time_period_str, cv.positive_timedelta + ), + }, + "async_disable", + ) + + +class PiHoleSwitch(PiHoleEntity, SwitchEntity): + """Representation of a Pi-hole switch.""" + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the switch.""" + return f"{self._server_unique_id}/Switch" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return "mdi:pi-hole" + + @property + def is_on(self): + """Return if the service is on.""" + return self.api.data.get("status") == "enabled" + + async def async_turn_on(self, **kwargs): + """Turn on the service.""" + try: + await self.api.enable() + await self.async_update() + except HoleError as err: + _LOGGER.error("Unable to enable Pi-hole: %s", err) + + async def async_turn_off(self, **kwargs): + """Turn off the service.""" + await self.async_disable() + + async def async_disable(self, duration=None): + """Disable the service for a given duration.""" + duration_seconds = True # Disable infinitely by default + if duration is not None: + duration_seconds = duration.total_seconds() + _LOGGER.debug( + "Disabling Pi-hole '%s' (%s) for %d seconds", + self.name, + self.api.host, + duration_seconds, + ) + try: + await self.api.disable(duration_seconds) + await self.async_update() + except HoleError as err: + _LOGGER.error("Unable to disable Pi-hole: %s", err) diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index b39bfdced2a..f487f413363 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -21,7 +21,7 @@ ZERO_DATA = { "domains_being_blocked": 0, "queries_cached": 0, "queries_forwarded": 0, - "status": 0, + "status": "disabled", "unique_clients": 0, "unique_domains": 0, } @@ -29,7 +29,7 @@ ZERO_DATA = { HOST = "1.2.3.4" PORT = 80 LOCATION = "location" -NAME = "name" +NAME = "Pi hole" API_KEY = "apikey" SSL = False VERIFY_SSL = True @@ -53,6 +53,8 @@ CONF_CONFIG_FLOW = { CONF_VERIFY_SSL: VERIFY_SSL, } +SWITCH_ENTITY_ID = "switch.pi_hole" + def _create_mocked_hole(raise_exception=False): mocked_hole = MagicMock() @@ -65,6 +67,10 @@ def _create_mocked_hole(raise_exception=False): return mocked_hole +def _patch_init_hole(mocked_hole): + return patch("homeassistant.components.pi_hole.Hole", return_value=mocked_hole) + + def _patch_config_flow_hole(mocked_hole): return patch( "homeassistant.components.pi_hole.config_flow.Hole", return_value=mocked_hole diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index 32b5b1ca146..07a9e08313a 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -1,5 +1,4 @@ """Test pi_hole config flow.""" -import copy import logging from homeassistant.components.pi_hole.const import DOMAIN @@ -13,7 +12,6 @@ from homeassistant.data_entry_flow import ( from . import ( CONF_CONFIG_FLOW, CONF_DATA, - CONF_HOST, NAME, _create_mocked_hole, _patch_config_flow_hole, @@ -54,16 +52,6 @@ async def test_flow_import(hass, caplog): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - # duplicated name - conf_data = copy.deepcopy(CONF_DATA) - conf_data[CONF_HOST] = "4.3.2.1" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf_data - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "duplicated_name" - assert len([x for x in caplog.records if x.levelno == logging.ERROR]) == 1 - async def test_flow_import_invalid(hass, caplog): """Test import flow with invalid server.""" @@ -103,15 +91,6 @@ async def test_flow_user(hass): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - # duplicated name - conf_data = copy.deepcopy(CONF_CONFIG_FLOW) - conf_data[CONF_HOST] = "4.3.2.1" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=conf_data - ) - assert result["type"] == RESULT_TYPE_ABORT - assert result["reason"] == "duplicated_name" - async def test_flow_user_invalid(hass): """Test user initialized flow with invalid server.""" diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 088b56d75b9..1f3e2451895 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -1,18 +1,36 @@ """Test pi_hole component.""" +import logging -from homeassistant.components import pi_hole -from homeassistant.components.pi_hole.const import MIN_TIME_BETWEEN_UPDATES +from hole.exceptions import HoleError + +from homeassistant.components import pi_hole, switch +from homeassistant.components.pi_hole.const import ( + CONF_LOCATION, + DEFAULT_LOCATION, + DEFAULT_NAME, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + SERVICE_DISABLE, + SERVICE_DISABLE_ATTR_DURATION, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_HOST, + CONF_NAME, + CONF_SSL, + CONF_VERIFY_SSL, +) from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util -from . import _create_mocked_hole, _patch_config_flow_hole +from . import ( + SWITCH_ENTITY_ID, + _create_mocked_hole, + _patch_config_flow_hole, + _patch_init_hole, +) -from tests.async_mock import patch -from tests.common import async_fire_time_changed - - -def _patch_init_hole(mocked_hole): - return patch("homeassistant.components.pi_hole.Hole", return_value=mocked_hole) +from tests.async_mock import AsyncMock +from tests.common import MockConfigEntry async def test_setup_minimal_config(hass): @@ -69,6 +87,9 @@ async def test_setup_minimal_config(hass): assert hass.states.get("sensor.pi_hole_domains_blocked").state == "0" assert hass.states.get("sensor.pi_hole_seen_clients").state == "0" + assert hass.states.get("binary_sensor.pi_hole").name == "Pi-Hole" + assert hass.states.get("binary_sensor.pi_hole").state == "off" + async def test_setup_name_config(hass): """Tests component setup with a custom name.""" @@ -88,6 +109,54 @@ async def test_setup_name_config(hass): ) +async def test_switch(hass, caplog): + """Test Pi-hole switch.""" + mocked_hole = _create_mocked_hole() + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + assert await async_setup_component( + hass, + pi_hole.DOMAIN, + {pi_hole.DOMAIN: [{"host": "pi.hole1", "api_key": "1"}]}, + ) + + await hass.async_block_till_done() + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + mocked_hole.enable.assert_called_once() + + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + mocked_hole.disable.assert_called_once_with(True) + + # Failed calls + type(mocked_hole).enable = AsyncMock(side_effect=HoleError("Error1")) + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + type(mocked_hole).disable = AsyncMock(side_effect=HoleError("Error2")) + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {"entity_id": SWITCH_ENTITY_ID}, + blocking=True, + ) + errors = [x for x in caplog.records if x.levelno == logging.ERROR] + assert errors[-2].message == "Unable to enable Pi-hole: Error1" + assert errors[-1].message == "Unable to disable Pi-hole: Error2" + + async def test_disable_service_call(hass): """Test disable service call with no Pi-hole named.""" mocked_hole = _create_mocked_hole() @@ -98,7 +167,7 @@ async def test_disable_service_call(hass): { pi_hole.DOMAIN: [ {"host": "pi.hole1", "api_key": "1"}, - {"host": "pi.hole2", "name": "Custom", "api_key": "2"}, + {"host": "pi.hole2", "name": "Custom"}, ] }, ) @@ -107,57 +176,35 @@ async def test_disable_service_call(hass): await hass.services.async_call( pi_hole.DOMAIN, - pi_hole.SERVICE_DISABLE, - {pi_hole.SERVICE_DISABLE_ATTR_DURATION: "00:00:01"}, + SERVICE_DISABLE, + {ATTR_ENTITY_ID: "all", SERVICE_DISABLE_ATTR_DURATION: "00:00:01"}, blocking=True, ) await hass.async_block_till_done() - assert mocked_hole.disable.call_count == 2 + mocked_hole.disable.assert_called_once_with(1) -async def test_enable_service_call(hass): - """Test enable service call with no Pi-hole named.""" +async def test_unload(hass): + """Test unload entities.""" + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "pi.hole", + CONF_LOCATION: DEFAULT_LOCATION, + CONF_SSL: DEFAULT_SSL, + CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, + }, + ) + entry.add_to_hass(hass) mocked_hole = _create_mocked_hole() with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): - assert await async_setup_component( - hass, - pi_hole.DOMAIN, - { - pi_hole.DOMAIN: [ - {"host": "pi.hole1", "api_key": "1"}, - {"host": "pi.hole2", "name": "Custom", "api_key": "2"}, - ] - }, - ) - + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.entry_id in hass.data[pi_hole.DOMAIN] - await hass.services.async_call( - pi_hole.DOMAIN, pi_hole.SERVICE_ENABLE, {}, blocking=True - ) - - await hass.async_block_till_done() - - assert mocked_hole.enable.call_count == 2 - - -async def test_update_coordinator(hass): - """Test update coordinator.""" - mocked_hole = _create_mocked_hole() - sensor_entity_id = "sensor.pi_hole_ads_blocked_today" - with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): - assert await async_setup_component( - hass, pi_hole.DOMAIN, {pi_hole.DOMAIN: [{"host": "pi.hole"}]} - ) - await hass.async_block_till_done() - assert mocked_hole.get_data.call_count == 3 - assert hass.states.get(sensor_entity_id).state == "0" - - mocked_hole.data["ads_blocked_today"] = 1 - utcnow = dt_util.utcnow() - async_fire_time_changed(hass, utcnow + MIN_TIME_BETWEEN_UPDATES) - await hass.async_block_till_done() - assert mocked_hole.get_data.call_count == 4 - assert hass.states.get(sensor_entity_id).state == "1" + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.entry_id not in hass.data[pi_hole.DOMAIN] From ad22619efb5de2409216ec4aff5e9f8801f5b3cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jul 2020 21:47:43 -1000 Subject: [PATCH 038/362] Bump zeroconf to 0.28.0 (#37951) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index fcc99088524..b93e47f8a65 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.27.1"], + "requirements": ["zeroconf==0.28.0"], "dependencies": ["api"], "codeowners": ["@Kane610"], "quality_scale": "internal" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 972c80ea6bc..28783be60b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ruamel.yaml==0.15.100 sqlalchemy==1.3.18 voluptuous-serialize==2.4.0 voluptuous==0.11.7 -zeroconf==0.27.1 +zeroconf==0.28.0 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 355b2b211a2..874bfa6c29a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2248,7 +2248,7 @@ youtube_dl==2020.06.16.1 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.27.1 +zeroconf==0.28.0 # homeassistant.components.zha zha-quirks==0.0.42 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d3b154c6e9..453feb4fb32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,7 +984,7 @@ wled==0.4.3 xmltodict==0.12.0 # homeassistant.components.zeroconf -zeroconf==0.27.1 +zeroconf==0.28.0 # homeassistant.components.zha zha-quirks==0.0.42 From b030ed1adf45ca734620e7691c5f23124f8abcb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Jul 2020 01:25:07 -1000 Subject: [PATCH 039/362] fix (#37889) --- .../components/homekit/type_thermostats.py | 81 +++++++++---------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 10e4e956328..1d0d760b963 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -74,6 +74,13 @@ from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) +DEFAULT_HVAC_MODES = [ + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, +] + HC_HOMEKIT_VALID_MODES_WATER_HEATER = {"Heat": 1} UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} @@ -117,7 +124,6 @@ class Thermostat(HomeAccessory): """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit - self._state_updates = 0 self.hc_homekit_to_hass = None self.hc_hass_to_homekit = None hc_min_temp, hc_max_temp = self.get_temperature_range() @@ -237,14 +243,20 @@ class Thermostat(HomeAccessory): # Homekit will reset the mode when VIEWING the temp # Ignore it if its the same mode if char_values[CHAR_TARGET_HEATING_COOLING] != homekit_hvac_mode: - service = SERVICE_SET_HVAC_MODE_THERMOSTAT - hass_value = self.hc_homekit_to_hass[ - char_values[CHAR_TARGET_HEATING_COOLING] - ] - params = {ATTR_HVAC_MODE: hass_value} - events.append( - f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" - ) + target_hc = char_values[CHAR_TARGET_HEATING_COOLING] + if target_hc in self.hc_homekit_to_hass: + service = SERVICE_SET_HVAC_MODE_THERMOSTAT + hass_value = self.hc_homekit_to_hass[target_hc] + params = {ATTR_HVAC_MODE: hass_value} + events.append( + f"{CHAR_TARGET_HEATING_COOLING} to {char_values[CHAR_TARGET_HEATING_COOLING]}" + ) + else: + _LOGGER.warning( + "The entity: %s does not have a %s mode", + self.entity_id, + target_hc, + ) if CHAR_TARGET_TEMPERATURE in char_values: hc_target_temp = char_values[CHAR_TARGET_TEMPERATURE] @@ -321,20 +333,8 @@ class Thermostat(HomeAccessory): def _configure_hvac_modes(self, state): """Configure target mode characteristics.""" - hc_modes = state.attributes.get(ATTR_HVAC_MODES) - if not hc_modes: - # This cannot be none OR an empty list - _LOGGER.error( - "%s: HVAC modes not yet available. Please disable auto start for homekit", - self.entity_id, - ) - hc_modes = ( - HVAC_MODE_HEAT, - HVAC_MODE_COOL, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - ) - + # This cannot be none OR an empty list + hc_modes = state.attributes.get(ATTR_HVAC_MODES) or DEFAULT_HVAC_MODES # Determine available modes for this entity, # Prefer HEAT_COOL over AUTO and COOL over FAN_ONLY, DRY # @@ -379,26 +379,23 @@ class Thermostat(HomeAccessory): @callback def async_update_state(self, new_state): """Update thermostat state after state changed.""" - if self._state_updates < 3: - # When we get the first state updates - # we recheck valid hvac modes as the entity - # may not have been fully setup when we saw it the - # first time - original_hc_hass_to_homekit = self.hc_hass_to_homekit - self._configure_hvac_modes(new_state) - if self.hc_hass_to_homekit != original_hc_hass_to_homekit: - if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: - # We must make sure the char value is - # in the new valid values before - # setting the new valid values or - # changing them with throw - self.char_target_heat_cool.set_value( - list(self.hc_homekit_to_hass)[0], should_notify=False - ) - self.char_target_heat_cool.override_properties( - valid_values=self.hc_hass_to_homekit + # We always recheck valid hvac modes as the entity + # may not have been fully setup when we saw it last + original_hc_hass_to_homekit = self.hc_hass_to_homekit + self._configure_hvac_modes(new_state) + + if self.hc_hass_to_homekit != original_hc_hass_to_homekit: + if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: + # We must make sure the char value is + # in the new valid values before + # setting the new valid values or + # changing them with throw + self.char_target_heat_cool.set_value( + list(self.hc_homekit_to_hass)[0], should_notify=False ) - self._state_updates += 1 + self.char_target_heat_cool.override_properties( + valid_values=self.hc_hass_to_homekit + ) self._async_update_state(new_state) From f173805c2fe455714622a1e23f3d6143377c4b15 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 18 Jul 2020 13:43:38 +0200 Subject: [PATCH 040/362] Make sensor and binary_sensor inherit from base class (#37946) * Make sensor and binary_sensor inherit from base class * Drop several pointless public properties * Make sure base function has same parameters * Drop pass * Missed one * Adjust inherit order --- homeassistant/components/rfxtrx/__init__.py | 52 +++++---- .../components/rfxtrx/binary_sensor.py | 106 +++--------------- homeassistant/components/rfxtrx/cover.py | 20 +--- homeassistant/components/rfxtrx/light.py | 19 ++-- homeassistant/components/rfxtrx/sensor.py | 67 +---------- homeassistant/components/rfxtrx/switch.py | 20 ++-- 6 files changed, 68 insertions(+), 216 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 54863f86332..10b036e9eb9 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UNIT_PERCENTAGE, UV_INDEX, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity @@ -343,35 +344,36 @@ def get_device_id(device, data_bits=None): return (f"{device.packettype:x}", f"{device.subtype:x}", id_string) -class RfxtrxDevice(RestoreEntity): +class RfxtrxEntity(RestoreEntity): """Represents a Rfxtrx device. Contains the common logic for Rfxtrx lights and switches. """ - def __init__(self, device, device_id, signal_repetitions, event=None): + def __init__(self, device, device_id, event=None): """Initialize the device.""" - self.signal_repetitions = signal_repetitions self._name = f"{device.type_string} {device.id_string}" self._device = device - self._event = None - self._state = None + self._event = event self._device_id = device_id self._unique_id = "_".join(x for x in self._device_id) - if event: - self._apply_event(event) - async def async_added_to_hass(self): """Restore RFXtrx device state (ON/OFF).""" if self._event: - return + self._apply_event(self._event) + else: + old_state = await self.async_get_last_state() + if old_state is not None: + event = old_state.attributes.get(ATTR_EVENT) + if event: + self._apply_event(get_rfx_object(event)) - old_state = await self.async_get_last_state() - if old_state is not None: - event = old_state.attributes.get(ATTR_EVENT) - if event: - self._apply_event(get_rfx_object(event)) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_EVENT, self._handle_event + ) + ) @property def should_poll(self): @@ -390,11 +392,6 @@ class RfxtrxDevice(RestoreEntity): return None return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} - @property - def is_on(self): - """Return true if device is on.""" - return self._state - @property def assumed_state(self): """Return true if unable to access real state of entity.""" @@ -418,6 +415,23 @@ class RfxtrxDevice(RestoreEntity): """Apply a received event.""" self._event = event + @callback + def _handle_event(self, event, device_id): + """Handle a reception of data, overridden by other classes.""" + + +class RfxtrxCommandEntity(RfxtrxEntity): + """Represents a Rfxtrx device. + + Contains the common logic for Rfxtrx lights and switches. + """ + + def __init__(self, device, device_id, signal_repetitions=1, event=None): + """Initialzie a switch or light device.""" + super().__init__(device, device_id, event=event) + self.signal_repetitions = signal_repetitions + self._state = None + def _send_command(self, command, brightness=0): rfx_object = self.hass.data[DATA_RFXOBJECT] diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 5f6e32437c4..20782766e29 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -12,21 +12,19 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import event as evt -from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_OFF_DELAY, - DOMAIN, SIGNAL_EVENT, + RfxtrxEntity, find_possible_pt2262_device, get_device_id, get_pt2262_cmd, get_rfx_object, ) from .const import ( - ATTR_EVENT, COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG, @@ -107,7 +105,7 @@ async def async_setup_entry( ) -class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): +class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): """A representation of a RFXtrx binary sensor.""" def __init__( @@ -122,72 +120,15 @@ class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): event=None, ): """Initialize the RFXtrx sensor.""" - self._event = None - self._device = device - self._name = f"{device.type_string} {device.id_string}" + super().__init__(device, device_id, event=event) self._device_class = device_class self._data_bits = data_bits self._off_delay = off_delay - self._state = False - self.delay_listener = None + self._state = None + self._delay_listener = None self._cmd_on = cmd_on self._cmd_off = cmd_off - self._device_id = device_id - self._unique_id = "_".join(x for x in self._device_id) - - if event: - self._apply_event(event) - - async def async_added_to_hass(self): - """Restore RFXtrx switch device state (ON/OFF).""" - await super().async_added_to_hass() - - if self._event is None: - old_state = await self.async_get_last_state() - if old_state is not None: - event = old_state.attributes.get(ATTR_EVENT) - if event: - self._apply_event(get_rfx_object(event)) - - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_EVENT, self._handle_event - ) - ) - - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if not self._event: - return None - return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} - - @property - def data_bits(self): - """Return the number of data bits.""" - return self._data_bits - - @property - def cmd_on(self): - """Return the value of the 'On' command.""" - return self._cmd_on - - @property - def cmd_off(self): - """Return the value of the 'Off' command.""" - return self._cmd_off - - @property - def should_poll(self): - """No polling needed.""" - return False - @property def force_update(self) -> bool: """We should force updates. Repeated states have meaning.""" @@ -198,38 +139,19 @@ class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): """Return the sensor class.""" return self._device_class - @property - def off_delay(self): - """Return the off_delay attribute value.""" - return self._off_delay - @property def is_on(self): """Return true if the sensor state is True.""" return self._state - @property - def unique_id(self): - """Return unique identifier of remote device.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, *self._device_id)}, - "name": f"{self._device.type_string} {self._device.id_string}", - "model": self._device.type_string, - } - def _apply_event_lighting4(self, event): """Apply event for a lighting 4 device.""" - if self.data_bits is not None: - cmd = get_pt2262_cmd(event.device.id_string, self.data_bits) + if self._data_bits is not None: + cmd = get_pt2262_cmd(event.device.id_string, self._data_bits) cmd = int(cmd, 16) - if cmd == self.cmd_on: + if cmd == self._cmd_on: self._state = True - elif cmd == self.cmd_off: + elif cmd == self._cmd_off: self._state = False else: self._state = True @@ -242,7 +164,7 @@ class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): def _apply_event(self, event): """Apply command from rfxtrx.""" - self._event = event + super()._apply_event(event) if event.device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: self._apply_event_lighting4(event) else: @@ -265,15 +187,15 @@ class RfxtrxBinarySensor(BinarySensorEntity, RestoreEntity): self.async_write_ha_state() - if self.is_on and self.off_delay is not None and self.delay_listener is None: + if self.is_on and self._off_delay is not None and self._delay_listener is None: @callback def off_delay_listener(now): """Switch device off after a delay.""" - self.delay_listener = None + self._delay_listener = None self._state = False self.async_write_ha_state() - self.delay_listener = evt.async_call_later( - self.hass, self.off_delay.total_seconds(), off_delay_listener + self._delay_listener = evt.async_call_later( + self.hass, self._off_delay.total_seconds(), off_delay_listener ) diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index a3cefb42cb7..e8ed8498580 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -4,14 +4,13 @@ import logging from homeassistant.components.cover import CoverEntity from homeassistant.const import CONF_DEVICES from homeassistant.core import callback -from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, SIGNAL_EVENT, - RfxtrxDevice, + RfxtrxCommandEntity, get_device_id, get_rfx_object, ) @@ -79,24 +78,9 @@ async def async_setup_entry( hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, cover_update) -class RfxtrxCover(RfxtrxDevice, CoverEntity, RestoreEntity): +class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): """Representation of a RFXtrx cover.""" - async def async_added_to_hass(self): - """Restore RFXtrx cover device state (OPEN/CLOSE).""" - await super().async_added_to_hass() - - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_EVENT, self._handle_event - ) - ) - - @property - def should_poll(self): - """Return the polling state. No polling available in RFXtrx cover.""" - return False - @property def is_closed(self): """Return if the cover is closed.""" diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 649be7be3fe..9a986b96bb9 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -16,7 +16,7 @@ from . import ( CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, SIGNAL_EVENT, - RfxtrxDevice, + RfxtrxCommandEntity, get_device_id, get_rfx_object, ) @@ -92,21 +92,11 @@ async def async_setup_entry( hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, light_update) -class RfxtrxLight(RfxtrxDevice, LightEntity): +class RfxtrxLight(RfxtrxCommandEntity, LightEntity): """Representation of a RFXtrx light.""" _brightness = 0 - async def async_added_to_hass(self): - """Restore RFXtrx device state (ON/OFF).""" - await super().async_added_to_hass() - - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_EVENT, self._handle_event - ) - ) - @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -117,6 +107,11 @@ class RfxtrxLight(RfxtrxDevice, LightEntity): """Flag supported features.""" return SUPPORT_RFXTRX + @property + def is_on(self): + """Return true if device is on.""" + return self._state + def turn_on(self, **kwargs): """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index de341307551..129e4f6f9d5 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -11,17 +11,16 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import CONF_DEVICES from homeassistant.core import callback -from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, DATA_TYPES, - DOMAIN, SIGNAL_EVENT, + RfxtrxEntity, get_device_id, get_rfx_object, ) -from .const import ATTR_EVENT, DATA_RFXTRX_CONFIG +from .const import DATA_RFXTRX_CONFIG _LOGGER = logging.getLogger(__name__) @@ -113,46 +112,20 @@ async def async_setup_entry( hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, sensor_update) -class RfxtrxSensor(RestoreEntity): +class RfxtrxSensor(RfxtrxEntity): """Representation of a RFXtrx sensor.""" def __init__(self, device, device_id, data_type, event=None): """Initialize the sensor.""" - self._event = None - self._device = device - self._name = f"{device.type_string} {device.id_string} {data_type}" + super().__init__(device, device_id, event=event) self.data_type = data_type self._unit_of_measurement = DATA_TYPES.get(data_type, "") - self._device_id = device_id + self._name = f"{device.type_string} {device.id_string} {data_type}" self._unique_id = "_".join(x for x in (*self._device_id, data_type)) self._device_class = DEVICE_CLASSES.get(data_type) self._convert_fun = CONVERT_FUNCTIONS.get(data_type, lambda x: x) - if event: - self._apply_event(event) - - async def async_added_to_hass(self): - """Restore RFXtrx switch device state (ON/OFF).""" - await super().async_added_to_hass() - - if self._event is None: - old_state = await self.async_get_last_state() - if old_state is not None: - event = old_state.attributes.get(ATTR_EVENT) - if event: - self._apply_event(get_rfx_object(event)) - - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_EVENT, self._handle_event - ) - ) - - def __str__(self): - """Return the name of the sensor.""" - return self._name - @property def state(self): """Return the state of the sensor.""" @@ -161,18 +134,6 @@ class RfxtrxSensor(RestoreEntity): value = self._event.values.get(self.data_type) return self._convert_fun(value) - @property - def name(self): - """Get the name of the sensor.""" - return self._name - - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if not self._event: - return None - return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} - @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" @@ -193,24 +154,6 @@ class RfxtrxSensor(RestoreEntity): """Return a device class for sensor.""" return self._device_class - @property - def unique_id(self): - """Return unique identifier of remote device.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, *self._device_id)}, - "name": f"{self._device.type_string} {self._device.id_string}", - "model": self._device.type_string, - } - - def _apply_event(self, event): - """Apply command from rfxtrx.""" - self._event = event - @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 7b2a23c1624..3b0290f5546 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -6,7 +6,6 @@ import RFXtrx as rfxtrxmod from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_DEVICES from homeassistant.core import callback -from homeassistant.helpers.restore_state import RestoreEntity from . import ( CONF_AUTOMATIC_ADD, @@ -14,7 +13,7 @@ from . import ( DEFAULT_SIGNAL_REPETITIONS, DOMAIN, SIGNAL_EVENT, - RfxtrxDevice, + RfxtrxCommandEntity, get_device_id, get_rfx_object, ) @@ -89,19 +88,9 @@ async def async_setup_entry( hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, switch_update) -class RfxtrxSwitch(RfxtrxDevice, SwitchEntity, RestoreEntity): +class RfxtrxSwitch(RfxtrxCommandEntity, SwitchEntity): """Representation of a RFXtrx switch.""" - async def async_added_to_hass(self): - """Restore RFXtrx switch device state (ON/OFF).""" - await super().async_added_to_hass() - - self.async_on_remove( - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_EVENT, self._handle_event - ) - ) - def _apply_event(self, event): """Apply command from rfxtrx.""" super()._apply_event(event) @@ -120,6 +109,11 @@ class RfxtrxSwitch(RfxtrxDevice, SwitchEntity, RestoreEntity): self.async_write_ha_state() + @property + def is_on(self): + """Return true if device is on.""" + return self._state + def turn_on(self, **kwargs): """Turn the device on.""" self._send_command("turn_on") From 2354d0117be6027d196838090e9c7c5c6960fae3 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 18 Jul 2020 14:47:32 -0400 Subject: [PATCH 041/362] Force updates for ZHA light group entity members (#37961) * Force updates for ZHA light group entity members * add a 3 second debouncer to the forced refresh * lint --- homeassistant/components/zha/light.py | 36 ++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index efe95ae6604..f1bae3dd4c2 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1,4 +1,5 @@ """Lights on Zigbee Home Automation networks.""" +import asyncio from collections import Counter from datetime import timedelta import functools @@ -31,6 +32,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import State, callback +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util @@ -406,7 +408,7 @@ class Light(BaseLight, ZhaEntity): async def async_get_state(self, from_cache=True): """Attempt to retrieve on off state from the light.""" - self.debug("polling current state") + self.debug("polling current state - from cache: %s", from_cache) if self._on_off_channel: state = await self._on_off_channel.get_attribute_value( "on_off", from_cache=from_cache @@ -494,6 +496,30 @@ class LightGroup(BaseLight, ZhaGroupEntity): self._level_channel = group.endpoint[LevelControl.cluster_id] self._color_channel = group.endpoint[Color.cluster_id] self._identify_channel = group.endpoint[Identify.cluster_id] + self._debounced_member_refresh = None + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._debounced_member_refresh is None: + force_refresh_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=3, + immediate=True, + function=self._force_member_updates, + ) + self._debounced_member_refresh = force_refresh_debouncer + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + await super().async_turn_on(**kwargs) + await self._debounced_member_refresh.async_call() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + await super().async_turn_off(**kwargs) + await self._debounced_member_refresh.async_call() async def async_update(self) -> None: """Query all members and determine the light group state.""" @@ -541,3 +567,11 @@ class LightGroup(BaseLight, ZhaGroupEntity): # Bitwise-and the supported features with the GroupedLight's features # so that we don't break in the future when a new feature is added. self._supported_features &= SUPPORT_GROUP_LIGHT + + async def _force_member_updates(self): + """Force the update of member entities to ensure the states are correct for bulbs that don't report their state.""" + component = self.hass.data[light.DOMAIN] + entities = [component.get_entity(entity_id) for entity_id in self._entity_ids] + tasks = [entity.async_get_state(from_cache=False) for entity in entities] + if tasks: + await asyncio.gather(*tasks) From 6fa04aa3e3ef299287e97d43107ca50b9f75928a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 19 Jul 2020 01:07:32 +0200 Subject: [PATCH 042/362] Add support for InputSelector trait (#35753) --- homeassistant/components/demo/media_player.py | 7 +- .../components/google_assistant/trait.py | 88 ++++++++++++------- tests/components/google_assistant/__init__.py | 1 + .../components/google_assistant/test_trait.py | 59 ++++--------- 4 files changed, 81 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 9cfb5582acc..f00f1fb781e 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -61,7 +61,6 @@ YOUTUBE_PLAYER_SUPPORT = ( | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE - | SUPPORT_SELECT_SOURCE | SUPPORT_SEEK ) @@ -397,6 +396,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer): self._cur_episode = 1 self._episode_count = 13 self._source = "dvd" + self._source_list = ["dvd", "youtube"] @property def media_content_id(self): @@ -448,6 +448,11 @@ class DemoTVShowPlayer(AbstractDemoPlayer): """Return the current input source.""" return self._source + @property + def source_list(self): + """List of available sources.""" + return self._source_list + @property def supported_features(self): """Flag media player features that are supported.""" diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 70cc9bd9f52..da3363fb4d9 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -86,6 +86,7 @@ TRAIT_TEMPERATURE_SETTING = f"{PREFIX_TRAITS}TemperatureSetting" TRAIT_LOCKUNLOCK = f"{PREFIX_TRAITS}LockUnlock" TRAIT_FANSPEED = f"{PREFIX_TRAITS}FanSpeed" TRAIT_MODES = f"{PREFIX_TRAITS}Modes" +TRAIT_INPUTSELECTOR = f"{PREFIX_TRAITS}InputSelector" TRAIT_OPENCLOSE = f"{PREFIX_TRAITS}OpenClose" TRAIT_VOLUME = f"{PREFIX_TRAITS}Volume" TRAIT_ARMDISARM = f"{PREFIX_TRAITS}ArmDisarm" @@ -112,6 +113,7 @@ COMMAND_THERMOSTAT_SET_MODE = f"{PREFIX_COMMANDS}ThermostatSetMode" COMMAND_LOCKUNLOCK = f"{PREFIX_COMMANDS}LockUnlock" COMMAND_FANSPEED = f"{PREFIX_COMMANDS}SetFanSpeed" COMMAND_MODES = f"{PREFIX_COMMANDS}SetModes" +COMMAND_INPUT = f"{PREFIX_COMMANDS}SetInput" COMMAND_OPENCLOSE = f"{PREFIX_COMMANDS}OpenClose" COMMAND_SET_VOLUME = f"{PREFIX_COMMANDS}setVolume" COMMAND_VOLUME_RELATIVE = f"{PREFIX_COMMANDS}volumeRelative" @@ -1213,7 +1215,6 @@ class ModesTrait(_Trait): commands = [COMMAND_MODES] SYNONYMS = { - "input source": ["input source", "input", "source"], "sound mode": ["sound mode", "effects"], "option": ["option", "setting", "mode", "value"], } @@ -1230,10 +1231,7 @@ class ModesTrait(_Trait): if domain != media_player.DOMAIN: return False - return ( - features & media_player.SUPPORT_SELECT_SOURCE - or features & media_player.SUPPORT_SELECT_SOUND_MODE - ) + return features & media_player.SUPPORT_SELECT_SOUND_MODE def sync_attributes(self): """Return mode attributes for a sync request.""" @@ -1266,13 +1264,6 @@ class ModesTrait(_Trait): attrs = self.state.attributes modes = [] if self.state.domain == media_player.DOMAIN: - if media_player.ATTR_INPUT_SOURCE_LIST in attrs: - modes.append( - _generate( - "input source", attrs[media_player.ATTR_INPUT_SOURCE_LIST] - ) - ) - if media_player.ATTR_SOUND_MODE_LIST in attrs: modes.append( _generate("sound mode", attrs[media_player.ATTR_SOUND_MODE_LIST]) @@ -1294,11 +1285,6 @@ class ModesTrait(_Trait): mode_settings = {} if self.state.domain == media_player.DOMAIN: - if media_player.ATTR_INPUT_SOURCE_LIST in attrs: - mode_settings["input source"] = attrs.get( - media_player.ATTR_INPUT_SOURCE - ) - if media_player.ATTR_SOUND_MODE_LIST in attrs: mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) elif self.state.domain == input_select.DOMAIN: @@ -1352,21 +1338,8 @@ class ModesTrait(_Trait): ) return - requested_source = settings.get("input source") sound_mode = settings.get("sound mode") - if requested_source: - await self.hass.services.async_call( - media_player.DOMAIN, - media_player.SERVICE_SELECT_SOURCE, - { - ATTR_ENTITY_ID: self.state.entity_id, - media_player.ATTR_INPUT_SOURCE: requested_source, - }, - blocking=True, - context=data.context, - ) - if sound_mode: await self.hass.services.async_call( media_player.DOMAIN, @@ -1380,6 +1353,61 @@ class ModesTrait(_Trait): ) +@register_trait +class InputSelectorTrait(_Trait): + """Trait to set modes. + + https://developers.google.com/assistant/smarthome/traits/inputselector + """ + + name = TRAIT_INPUTSELECTOR + commands = [COMMAND_INPUT] + + SYNONYMS = {} + + @staticmethod + def supported(domain, features, device_class): + """Test if state is supported.""" + if domain == media_player.DOMAIN and ( + features & media_player.SUPPORT_SELECT_SOURCE + ): + return True + + return False + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + attrs = self.state.attributes + inputs = [ + {"key": source, "names": [{"name_synonym": [source], "lang": "en"}]} + for source in attrs.get(media_player.ATTR_INPUT_SOURCE_LIST, []) + ] + + payload = {"availableInputs": inputs, "orderedInputs": True} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + return {"currentInput": attrs.get(media_player.ATTR_INPUT_SOURCE, "")} + + async def execute(self, command, data, params, challenge): + """Execute an SetInputSource command.""" + requested_source = params.get("newInput") + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOURCE, + { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_INPUT_SOURCE: requested_source, + }, + blocking=True, + context=data.context, + ) + + @register_trait class OpenCloseTrait(_Trait): """Trait to open and close a cover. diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 45adc281524..a801a6c960f 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -190,6 +190,7 @@ DEMO_DEVICES = [ "id": "media_player.lounge_room", "name": {"name": "Lounge room"}, "traits": [ + "action.devices.traits.InputSelector", "action.devices.traits.OnOff", "action.devices.traits.Modes", "action.devices.traits.TransportControl", diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index adcdbd8291d..9d99736c87f 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1313,14 +1313,14 @@ async def test_fan_speed(hass): assert calls[0].data == {"entity_id": "fan.living_room_fan", "speed": "medium"} -async def test_modes_media_player(hass): - """Test Media Player Mode trait.""" +async def test_inputselector(hass): + """Test input selector trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None - assert trait.ModesTrait.supported( + assert trait.InputSelectorTrait.supported( media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE, None ) - trt = trait.ModesTrait( + trt = trait.InputSelectorTrait( hass, State( "media_player.living_room", @@ -1340,56 +1340,29 @@ async def test_modes_media_player(hass): attribs = trt.sync_attributes() assert attribs == { - "availableModes": [ + "availableInputs": [ + {"key": "media", "names": [{"name_synonym": ["media"], "lang": "en"}]}, + {"key": "game", "names": [{"name_synonym": ["game"], "lang": "en"}]}, { - "name": "input source", - "name_values": [ - {"name_synonym": ["input source", "input", "source"], "lang": "en"} - ], - "settings": [ - { - "setting_name": "media", - "setting_values": [ - {"setting_synonym": ["media"], "lang": "en"} - ], - }, - { - "setting_name": "game", - "setting_values": [{"setting_synonym": ["game"], "lang": "en"}], - }, - { - "setting_name": "chromecast", - "setting_values": [ - {"setting_synonym": ["chromecast"], "lang": "en"} - ], - }, - { - "setting_name": "plex", - "setting_values": [{"setting_synonym": ["plex"], "lang": "en"}], - }, - ], - "ordered": False, - } - ] + "key": "chromecast", + "names": [{"name_synonym": ["chromecast"], "lang": "en"}], + }, + {"key": "plex", "names": [{"name_synonym": ["plex"], "lang": "en"}]}, + ], + "orderedInputs": True, } assert trt.query_attributes() == { - "currentModeSettings": {"input source": "game"}, - "on": True, + "currentInput": "game", } - assert trt.can_execute( - trait.COMMAND_MODES, params={"updateModeSettings": {"input source": "media"}}, - ) + assert trt.can_execute(trait.COMMAND_INPUT, params={"newInput": "media"},) calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE ) await trt.execute( - trait.COMMAND_MODES, - BASIC_DATA, - {"updateModeSettings": {"input source": "media"}}, - {}, + trait.COMMAND_INPUT, BASIC_DATA, {"newInput": "media"}, {}, ) assert len(calls) == 1 From e6ff8d6839cd08ef786ef5bd07cbcba404330961 Mon Sep 17 00:00:00 2001 From: Thorjan Knudsvik Date: Sun, 19 Jul 2020 01:18:31 +0200 Subject: [PATCH 043/362] Adds median to min_max component (#36686) --- homeassistant/components/min_max/sensor.py | 42 ++++++++++++++++------ tests/components/min_max/test_sensor.py | 37 +++++++++++++++++++ 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 6c99a8db60c..a5e31829d97 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -1,4 +1,4 @@ -"""Support for displaying the minimal and the maximal value.""" +"""Support for displaying minimal, maximal, mean or median values.""" import logging import voluptuous as vol @@ -24,6 +24,7 @@ ATTR_MAX_VALUE = "max_value" ATTR_MAX_ENTITY_ID = "max_entity_id" ATTR_COUNT_SENSORS = "count_sensors" ATTR_MEAN = "mean" +ATTR_MEDIAN = "median" ATTR_LAST = "last" ATTR_LAST_ENTITY_ID = "last_entity_id" @@ -32,6 +33,7 @@ ATTR_TO_PROPERTY = [ ATTR_MAX_VALUE, ATTR_MAX_ENTITY_ID, ATTR_MEAN, + ATTR_MEDIAN, ATTR_MIN_VALUE, ATTR_MIN_ENTITY_ID, ATTR_LAST, @@ -47,6 +49,7 @@ SENSOR_TYPES = { ATTR_MIN_VALUE: "min", ATTR_MAX_VALUE: "max", ATTR_MEAN: "mean", + ATTR_MEDIAN: "median", ATTR_LAST: "last", } @@ -80,7 +83,7 @@ def calc_min(sensor_values): val = None entity_id = None for sensor_id, sensor_value in sensor_values: - if sensor_value != STATE_UNKNOWN: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: if val is None or val > sensor_value: entity_id, val = sensor_id, sensor_value return entity_id, val @@ -91,7 +94,7 @@ def calc_max(sensor_values): val = None entity_id = None for sensor_id, sensor_value in sensor_values: - if sensor_value != STATE_UNKNOWN: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: if val is None or val < sensor_value: entity_id, val = sensor_id, sensor_value return entity_id, val @@ -99,15 +102,31 @@ def calc_max(sensor_values): def calc_mean(sensor_values, round_digits): """Calculate mean value, honoring unknown states.""" - sensor_value_sum = 0 - count = 0 + result = [] for _, sensor_value in sensor_values: - if sensor_value != STATE_UNKNOWN: - sensor_value_sum += sensor_value - count += 1 - if count == 0: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + result.append(sensor_value) + if len(result) == 0: return None - return round(sensor_value_sum / count, round_digits) + return round(sum(result) / len(result), round_digits) + + +def calc_median(sensor_values, round_digits): + """Calculate median value, honoring unknown states.""" + result = [] + for _, sensor_value in sensor_values: + if sensor_value not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + result.append(sensor_value) + if len(result) == 0: + return None + result.sort() + if len(result) % 2 == 0: + median1 = result[len(result) // 2] + median2 = result[len(result) // 2 - 1] + median = (median1 + median2) / 2 + else: + median = result[len(result) // 2] + return round(median, round_digits) class MinMaxSensor(Entity): @@ -126,7 +145,7 @@ class MinMaxSensor(Entity): self._name = f"{next(v for k, v in SENSOR_TYPES.items() if self._sensor_type == v)} sensor".capitalize() self._unit_of_measurement = None self._unit_of_measurement_mismatch = False - self.min_value = self.max_value = self.mean = self.last = None + self.min_value = self.max_value = self.mean = self.last = self.median = None self.min_entity_id = self.max_entity_id = self.last_entity_id = None self.count_sensors = len(self._entity_ids) self.states = {} @@ -224,3 +243,4 @@ class MinMaxSensor(Entity): self.min_entity_id, self.min_value = calc_min(sensor_values) self.max_entity_id, self.max_value = calc_max(sensor_values) self.mean = calc_mean(sensor_values, self._round_digits) + self.median = calc_median(sensor_values, self._round_digits) diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index 57ac39f8ee4..bece386a89e 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -1,4 +1,5 @@ """The test for the min/max sensor platform.""" +import statistics import unittest from homeassistant.const import ( @@ -27,6 +28,7 @@ class TestMinMaxSensor(unittest.TestCase): self.mean = round(sum(self.values) / self.count, 2) self.mean_1_digit = round(sum(self.values) / self.count, 1) self.mean_4_digits = round(sum(self.values) / self.count, 4) + self.median = round(statistics.median(self.values), 2) def teardown_method(self, method): """Stop everything that was started.""" @@ -58,6 +60,7 @@ class TestMinMaxSensor(unittest.TestCase): assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") assert self.mean == state.attributes.get("mean") + assert self.median == state.attributes.get("median") def test_max_sensor(self): """Test the max sensor.""" @@ -85,6 +88,7 @@ class TestMinMaxSensor(unittest.TestCase): assert self.min == state.attributes.get("min_value") assert entity_ids[1] == state.attributes.get("max_entity_id") assert self.mean == state.attributes.get("mean") + assert self.median == state.attributes.get("median") def test_mean_sensor(self): """Test the mean sensor.""" @@ -112,6 +116,7 @@ class TestMinMaxSensor(unittest.TestCase): assert entity_ids[2] == state.attributes.get("min_entity_id") assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.median == state.attributes.get("median") def test_mean_1_digit_sensor(self): """Test the mean with 1-digit precision sensor.""" @@ -140,6 +145,7 @@ class TestMinMaxSensor(unittest.TestCase): assert entity_ids[2] == state.attributes.get("min_entity_id") assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.median == state.attributes.get("median") def test_mean_4_digit_sensor(self): """Test the mean with 1-digit precision sensor.""" @@ -168,6 +174,35 @@ class TestMinMaxSensor(unittest.TestCase): assert entity_ids[2] == state.attributes.get("min_entity_id") assert self.max == state.attributes.get("max_value") assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.median == state.attributes.get("median") + + def test_median_sensor(self): + """Test the median sensor.""" + config = { + "sensor": { + "platform": "min_max", + "name": "test_median", + "type": "median", + "entity_ids": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + } + } + + assert setup_component(self.hass, "sensor", config) + + entity_ids = config["sensor"]["entity_ids"] + + for entity_id, value in dict(zip(entity_ids, self.values)).items(): + self.hass.states.set(entity_id, value) + self.hass.block_till_done() + + state = self.hass.states.get("sensor.test_median") + + assert str(float(self.median)) == state.state + assert self.min == state.attributes.get("min_value") + assert entity_ids[2] == state.attributes.get("min_entity_id") + assert self.max == state.attributes.get("max_value") + assert entity_ids[1] == state.attributes.get("max_entity_id") + assert self.mean == state.attributes.get("mean") def test_not_enough_sensor_value(self): """Test that there is nothing done if not enough values available.""" @@ -193,6 +228,7 @@ class TestMinMaxSensor(unittest.TestCase): assert state.attributes.get("min_value") is None assert state.attributes.get("max_entity_id") is None assert state.attributes.get("max_value") is None + assert state.attributes.get("median") is None self.hass.states.set(entity_ids[1], self.values[1]) self.hass.block_till_done() @@ -295,3 +331,4 @@ class TestMinMaxSensor(unittest.TestCase): assert self.min == state.attributes.get("min_value") assert self.max == state.attributes.get("max_value") assert self.mean == state.attributes.get("mean") + assert self.median == state.attributes.get("median") From 650d61e4f3007d1f7d456713d43fbc30b7396ce6 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sun, 19 Jul 2020 00:03:02 +0000 Subject: [PATCH 044/362] [ci skip] Translation update --- homeassistant/components/firmata/translations/ko.json | 7 +++++++ .../components/humidifier/translations/ca.json | 5 +++++ .../components/humidifier/translations/en.json | 5 +++++ .../components/humidifier/translations/ko.json | 10 ++++++++++ .../components/humidifier/translations/pl.json | 10 ++++++++++ .../components/humidifier/translations/ru.json | 10 ++++++++++ homeassistant/components/pi_hole/translations/ca.json | 3 ++- homeassistant/components/pi_hole/translations/en.json | 3 ++- homeassistant/components/pi_hole/translations/no.json | 1 + homeassistant/components/pi_hole/translations/pl.json | 1 + homeassistant/components/pi_hole/translations/ru.json | 3 ++- homeassistant/components/plugwise/translations/pl.json | 2 +- .../components/smartthings/translations/no.json | 2 +- 13 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/firmata/translations/ko.json diff --git a/homeassistant/components/firmata/translations/ko.json b/homeassistant/components/firmata/translations/ko.json new file mode 100644 index 00000000000..753a5851811 --- /dev/null +++ b/homeassistant/components/firmata/translations/ko.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\uc124\uce58\ud558\ub294 \ub3d9\uc548 Firmata \ubcf4\ub4dc\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/ca.json b/homeassistant/components/humidifier/translations/ca.json index 08224cf387f..bf0c1d805f6 100644 --- a/homeassistant/components/humidifier/translations/ca.json +++ b/homeassistant/components/humidifier/translations/ca.json @@ -11,6 +11,11 @@ "is_mode": "{entity_name} est\u00e0 configurat/ada en un mode espec\u00edfic", "is_off": "{entity_name} est\u00e0 apagat/ada", "is_on": "{entity_name} est\u00e0 enc\u00e8s/a" + }, + "trigger_type": { + "target_humidity_changed": "Ha canviat la humitat desitjada de {entity_name}", + "turned_off": "{entity_name} s'ha apagat", + "turned_on": "{entity_name} s'ha engegat" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/en.json b/homeassistant/components/humidifier/translations/en.json index 8cf8f57c3e9..be3f013895d 100644 --- a/homeassistant/components/humidifier/translations/en.json +++ b/homeassistant/components/humidifier/translations/en.json @@ -11,6 +11,11 @@ "is_mode": "{entity_name} is set to a specific mode", "is_off": "{entity_name} is off", "is_on": "{entity_name} is on" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} target humidity changed", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/ko.json b/homeassistant/components/humidifier/translations/ko.json index 89548dc4e35..c484a532156 100644 --- a/homeassistant/components/humidifier/translations/ko.json +++ b/homeassistant/components/humidifier/translations/ko.json @@ -6,6 +6,16 @@ "toggle": "{entity_name} \ud1a0\uae00", "turn_off": "{entity_name} \ub044\uae30", "turn_on": "{entity_name} \ucf1c\uae30" + }, + "condition_type": { + "is_mode": "{entity_name} \uc774(\uac00) \ud2b9\uc815 \ubaa8\ub4dc\ub85c \uc124\uc815\ub418\uc5b4\uc788\uc73c\uba74", + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc838 \uc788\uc73c\uba74", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc838 \uc788\uc73c\uba74" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} \ubaa9\ud45c \uc2b5\ub3c4\uac00 \ubcc0\uacbd\ub420 \ub54c", + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc9c8 \ub54c", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc9c8 \ub54c" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/pl.json b/homeassistant/components/humidifier/translations/pl.json index 0a57eede3b3..77b3241fab9 100644 --- a/homeassistant/components/humidifier/translations/pl.json +++ b/homeassistant/components/humidifier/translations/pl.json @@ -6,6 +6,16 @@ "toggle": "prze\u0142\u0105cz {entity_name}", "turn_off": "wy\u0142\u0105cz {entity_name}", "turn_on": "w\u0142\u0105cz {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} ma ustawiony tryb", + "is_off": "nawil\u017cacz {entity_name} jest wy\u0142\u0105czony", + "is_on": "nawil\u017cacz{entity_name} jest w\u0142\u0105czony" + }, + "trigger_type": { + "target_humidity_changed": "zmieni si\u0119 wilgotno\u015b\u0107 docelowa{entity_name}", + "turned_off": "nast\u0105pi wy\u0142\u0105czenie {entity_name}", + "turned_on": "nast\u0105pi w\u0142\u0105czenie {entity_name}" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/ru.json b/homeassistant/components/humidifier/translations/ru.json index 32e19e8325b..8c2bcde5565 100644 --- a/homeassistant/components/humidifier/translations/ru.json +++ b/homeassistant/components/humidifier/translations/ru.json @@ -6,6 +6,16 @@ "toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u0432 \u0437\u0430\u0434\u0430\u043d\u043d\u043e\u043c \u0440\u0435\u0436\u0438\u043c\u0435 \u0440\u0430\u0431\u043e\u0442\u044b", + "is_off": "{entity_name} \u0432 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", + "is_on": "{entity_name} \u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0446\u0435\u043b\u0435\u0432\u043e\u0439 \u0432\u043b\u0430\u0436\u043d\u043e\u0441\u0442\u0438", + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" } }, "state": { diff --git a/homeassistant/components/pi_hole/translations/ca.json b/homeassistant/components/pi_hole/translations/ca.json index f9f3f1d37d7..134e635253b 100644 --- a/homeassistant/components/pi_hole/translations/ca.json +++ b/homeassistant/components/pi_hole/translations/ca.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "Clau API (opcional)", + "api_key": "Clau API", "host": "Amfitri\u00f3", + "location": "Ubicaci\u00f3", "name": "Nom", "port": "Port", "ssl": "Utilitza SSL", diff --git a/homeassistant/components/pi_hole/translations/en.json b/homeassistant/components/pi_hole/translations/en.json index ceefc0697cd..e6d579cecbc 100644 --- a/homeassistant/components/pi_hole/translations/en.json +++ b/homeassistant/components/pi_hole/translations/en.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API Key (Optional)", + "api_key": "API Key", "host": "Host", + "location": "Location", "name": "Name", "port": "Port", "ssl": "Use SSL", diff --git a/homeassistant/components/pi_hole/translations/no.json b/homeassistant/components/pi_hole/translations/no.json index f6e9203505c..f31e66cb1a4 100644 --- a/homeassistant/components/pi_hole/translations/no.json +++ b/homeassistant/components/pi_hole/translations/no.json @@ -12,6 +12,7 @@ "data": { "api_key": "API-n\u00f8kkel (valgfritt)", "host": "Vert", + "location": "Beliggenhet", "name": "Navn", "port": "", "ssl": "Bruk SSL", diff --git a/homeassistant/components/pi_hole/translations/pl.json b/homeassistant/components/pi_hole/translations/pl.json index c4986e71aa7..f263736382b 100644 --- a/homeassistant/components/pi_hole/translations/pl.json +++ b/homeassistant/components/pi_hole/translations/pl.json @@ -12,6 +12,7 @@ "data": { "api_key": "Klucz API (opcjonalnie)", "host": "Nazwa hosta lub adres IP", + "location": "Lokalizacja", "name": "Nazwa", "port": "Port", "ssl": "U\u017cyj SSL", diff --git a/homeassistant/components/pi_hole/translations/ru.json b/homeassistant/components/pi_hole/translations/ru.json index 50cb5f98d16..ceb9e609d61 100644 --- a/homeassistant/components/pi_hole/translations/ru.json +++ b/homeassistant/components/pi_hole/translations/ru.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "\u041a\u043b\u044e\u0447 API (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", + "api_key": "\u041a\u043b\u044e\u0447 API", "host": "\u0425\u043e\u0441\u0442", + "location": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "port": "\u041f\u043e\u0440\u0442", "ssl": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c SSL", diff --git a/homeassistant/components/plugwise/translations/pl.json b/homeassistant/components/plugwise/translations/pl.json index 8c639bd8865..135b9d838fc 100644 --- a/homeassistant/components/plugwise/translations/pl.json +++ b/homeassistant/components/plugwise/translations/pl.json @@ -8,7 +8,7 @@ "invalid_auth": "Nieudane uwierzytelnienie, sprawd\u017a Smile ID", "unknown": "Nieoczekiwany b\u0142\u0105d." }, - "flow_title": "U\u015bmiech: {name}", + "flow_title": "Smile: {name}", "step": { "user": { "data": { diff --git a/homeassistant/components/smartthings/translations/no.json b/homeassistant/components/smartthings/translations/no.json index c6945783c11..75093b4a591 100644 --- a/homeassistant/components/smartthings/translations/no.json +++ b/homeassistant/components/smartthings/translations/no.json @@ -27,7 +27,7 @@ "location_id": "Lokasjon" }, "description": "Vennligst velg SmartThings lokasjon du vil legge til Home Assistant. Vi \u00e5pner deretter et nytt vindu og ber deg om \u00e5 logge inn og godkjenne installasjon av Home Assistant-integrasjonen p\u00e5 det valgte stedet.", - "title": "Velg Posisjon" + "title": "Velg beliggenhet" }, "user": { "description": "SmartThings konfigureres til \u00e5 sende push-oppdateringer til Home Assistant p\u00e5:\n\" {webhook_url}\n\nHvis dette ikke er riktig, m\u00e5 du oppdatere konfigurasjonen, starte Home Assistant p\u00e5 nytt og pr\u00f8ve p\u00e5 nytt.", From 619707e0e3b835df04ad08b4dfe22b10e65ea54c Mon Sep 17 00:00:00 2001 From: michaeldavie Date: Sun, 19 Jul 2020 05:52:46 -0400 Subject: [PATCH 045/362] Make nested get() statements safe (#37965) --- .../components/environment_canada/weather.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 7bc614bd09e..78ede4dbc5e 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -99,7 +99,7 @@ class ECWeather(WeatherEntity): @property def temperature(self): """Return the temperature.""" - if self.ec_data.conditions.get("temperature").get("value"): + if self.ec_data.conditions.get("temperature", {}).get("value"): return float(self.ec_data.conditions["temperature"]["value"]) if self.ec_data.hourly_forecasts[0].get("temperature"): return float(self.ec_data.hourly_forecasts[0]["temperature"]) @@ -113,35 +113,35 @@ class ECWeather(WeatherEntity): @property def humidity(self): """Return the humidity.""" - if self.ec_data.conditions.get("humidity").get("value"): + if self.ec_data.conditions.get("humidity", {}).get("value"): return float(self.ec_data.conditions["humidity"]["value"]) return None @property def wind_speed(self): """Return the wind speed.""" - if self.ec_data.conditions.get("wind_speed").get("value"): + if self.ec_data.conditions.get("wind_speed", {}).get("value"): return float(self.ec_data.conditions["wind_speed"]["value"]) return None @property def wind_bearing(self): """Return the wind bearing.""" - if self.ec_data.conditions.get("wind_bearing").get("value"): + if self.ec_data.conditions.get("wind_bearing", {}).get("value"): return float(self.ec_data.conditions["wind_bearing"]["value"]) return None @property def pressure(self): """Return the pressure.""" - if self.ec_data.conditions.get("pressure").get("value"): + if self.ec_data.conditions.get("pressure", {}).get("value"): return 10 * float(self.ec_data.conditions["pressure"]["value"]) return None @property def visibility(self): """Return the visibility.""" - if self.ec_data.conditions.get("visibility").get("value"): + if self.ec_data.conditions.get("visibility", {}).get("value"): return float(self.ec_data.conditions["visibility"]["value"]) return None @@ -150,7 +150,7 @@ class ECWeather(WeatherEntity): """Return the weather condition.""" icon_code = None - if self.ec_data.conditions.get("icon_code").get("value"): + if self.ec_data.conditions.get("icon_code", {}).get("value"): icon_code = self.ec_data.conditions["icon_code"]["value"] elif self.ec_data.hourly_forecasts[0].get("icon_code"): icon_code = self.ec_data.hourly_forecasts[0]["icon_code"] From c994904e755431c0101e6d49855c8822f033cf8b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 19 Jul 2020 12:36:59 +0200 Subject: [PATCH 046/362] Bump pychromecast to 7.1.2 (#37976) --- homeassistant/components/cast/manifest.json | 2 +- homeassistant/components/cast/media_player.py | 2 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index b0d49681414..5d807525226 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==7.0.1"], + "requirements": ["pychromecast==7.1.2"], "after_dependencies": ["cloud","zeroconf"], "zeroconf": ["_googlecast._tcp.local."], "codeowners": ["@emontnemery"] diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 21b7d207580..44b6bf451c1 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -279,6 +279,8 @@ class CastDevice(MediaPlayerEntity): cast_info.uuid, cast_info.model_name, cast_info.friendly_name, + None, + None, ), ChromeCastZeroconf.get_zeroconf(), ) diff --git a/requirements_all.txt b/requirements_all.txt index 874bfa6c29a..b1c8741f0cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1247,7 +1247,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==7.0.1 +pychromecast==7.1.2 # homeassistant.components.cmus pycmus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 453feb4fb32..acc69829b71 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -580,7 +580,7 @@ pyblackbird==0.5 pybotvac==0.0.17 # homeassistant.components.cast -pychromecast==7.0.1 +pychromecast==7.1.2 # homeassistant.components.coolmaster pycoolmasternet==0.0.4 From 432cbd31484ef9a82cc6c75fbc139dc58b9b18d2 Mon Sep 17 00:00:00 2001 From: lawtancool <26829131+lawtancool@users.noreply.github.com> Date: Sun, 19 Jul 2020 13:48:08 -0700 Subject: [PATCH 047/362] Add Control4 integration (#37632) Co-authored-by: J. Nick Koston Co-authored-by: Paulus Schoutsen --- .coveragerc | 4 + CODEOWNERS | 1 + homeassistant/components/control4/__init__.py | 226 ++++++++++++++++++ .../components/control4/config_flow.py | 171 +++++++++++++ homeassistant/components/control4/const.py | 18 ++ .../components/control4/director_utils.py | 62 +++++ homeassistant/components/control4/light.py | 206 ++++++++++++++++ .../components/control4/manifest.json | 13 + .../components/control4/strings.json | 31 +++ .../components/control4/translations/en.json | 31 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/control4/__init__.py | 1 + tests/components/control4/test_config_flow.py | 198 +++++++++++++++ 16 files changed, 974 insertions(+) create mode 100644 homeassistant/components/control4/__init__.py create mode 100644 homeassistant/components/control4/config_flow.py create mode 100644 homeassistant/components/control4/const.py create mode 100644 homeassistant/components/control4/director_utils.py create mode 100644 homeassistant/components/control4/light.py create mode 100644 homeassistant/components/control4/manifest.json create mode 100644 homeassistant/components/control4/strings.json create mode 100644 homeassistant/components/control4/translations/en.json create mode 100644 tests/components/control4/__init__.py create mode 100644 tests/components/control4/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0ade0f20790..337842457f0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -139,6 +139,10 @@ omit = homeassistant/components/comfoconnect/* homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py + homeassistant/components/control4/__init__.py + homeassistant/components/control4/light.py + homeassistant/components/control4/const.py + homeassistant/components/control4/director_utils.py homeassistant/components/coolmaster/__init__.py homeassistant/components/coolmaster/climate.py homeassistant/components/coolmaster/const.py diff --git a/CODEOWNERS b/CODEOWNERS index c224a61c068..f367da9325c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -77,6 +77,7 @@ homeassistant/components/cloudflare/* @ludeeus homeassistant/components/comfoconnect/* @michaelarnauts homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core +homeassistant/components/control4/* @lawtancool homeassistant/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund homeassistant/components/coronavirus/* @home_assistant/core diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py new file mode 100644 index 00000000000..43af82678f4 --- /dev/null +++ b/homeassistant/components/control4/__init__.py @@ -0,0 +1,226 @@ +"""The Control4 integration.""" +import asyncio +import json +import logging + +from aiohttp import client_exceptions +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import BadCredentials + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, device_registry as dr, entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + CONF_ACCOUNT, + CONF_CONFIG_LISTENER, + CONF_CONTROLLER_UNIQUE_ID, + CONF_DIRECTOR, + CONF_DIRECTOR_ALL_ITEMS, + CONF_DIRECTOR_MODEL, + CONF_DIRECTOR_SW_VERSION, + CONF_DIRECTOR_TOKEN_EXPIRATION, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["light"] + + +async def async_setup(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Stub to allow setting up this component. + + Configuration through YAML is not supported at this time. + """ + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Control4 from a config entry.""" + entry_data = hass.data[DOMAIN].setdefault(entry.entry_id, {}) + account_session = aiohttp_client.async_get_clientsession(hass) + + config = entry.data + account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) + try: + await account.getAccountBearerToken() + except client_exceptions.ClientError as exception: + _LOGGER.error("Error connecting to Control4 account API: %s", exception) + raise ConfigEntryNotReady + except BadCredentials as exception: + _LOGGER.error( + "Error authenticating with Control4 account API, incorrect username or password: %s", + exception, + ) + return False + entry_data[CONF_ACCOUNT] = account + + controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] + entry_data[CONF_CONTROLLER_UNIQUE_ID] = controller_unique_id + + director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + + director = C4Director( + config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session + ) + entry_data[CONF_DIRECTOR] = director + entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_dict["token_expiration"] + + # Add Control4 controller to device registry + controller_href = (await account.getAccountControllers())["href"] + entry_data[CONF_DIRECTOR_SW_VERSION] = await account.getControllerOSVersion( + controller_href + ) + + _, model, mac_address = controller_unique_id.split("_", 3) + entry_data[CONF_DIRECTOR_MODEL] = model.upper() + + device_registry = await dr.async_get_registry(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, controller_unique_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, + manufacturer="Control4", + name=controller_unique_id, + model=entry_data[CONF_DIRECTOR_MODEL], + sw_version=entry_data[CONF_DIRECTOR_SW_VERSION], + ) + + # Store all items found on controller for platforms to use + director_all_items = await director.getAllItemInfo() + director_all_items = json.loads(director_all_items) + entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items + + # Load options from config entry + entry_data[CONF_SCAN_INTERVAL] = entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + + entry_data[CONF_CONFIG_LISTENER] = entry.add_update_listener(update_listener) + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def update_listener(hass, config_entry): + """Update when config_entry options update.""" + _LOGGER.debug("Config entry was updated, rerunning setup") + await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + hass.data[DOMAIN][entry.entry_id][CONF_CONFIG_LISTENER]() + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + _LOGGER.debug("Unloaded entry for %s", entry.entry_id) + + return unload_ok + + +async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, category: str): + """Return a list of all Control4 items with the specified category.""" + director_all_items = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR_ALL_ITEMS] + return_list = [] + for item in director_all_items: + if "categories" in item and category in item["categories"]: + return_list.append(item) + return return_list + + +class Control4Entity(entity.Entity): + """Base entity for Control4.""" + + def __init__( + self, + entry_data: dict, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + name: str, + idx: int, + device_name: str, + device_manufacturer: str, + device_model: str, + device_id: int, + ): + """Initialize a Control4 entity.""" + self.entry = entry + self.account = entry_data[CONF_ACCOUNT] + self.director = entry_data[CONF_DIRECTOR] + self.director_token_expiry = entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] + self._name = name + self._idx = idx + self._coordinator = coordinator + self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._device_name = device_name + self._device_manufacturer = device_manufacturer + self._device_model = device_model + self._device_id = device_id + + @property + def name(self): + """Return name of entity.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._idx + + @property + def device_info(self): + """Return info of parent Control4 device of entity.""" + return { + "config_entry_id": self.entry.entry_id, + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device_name, + "manufacturer": self._device_manufacturer, + "model": self._device_model, + "via_device": (DOMAIN, self._controller_unique_id), + } + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self._coordinator.last_update_success + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the state of the device.""" + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py new file mode 100644 index 00000000000..03183edbfda --- /dev/null +++ b/homeassistant/components/control4/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow for Control4 integration.""" +from asyncio import TimeoutError as asyncioTimeoutError +import logging + +from aiohttp.client_exceptions import ClientError +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import NotFound, Unauthorized +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import CONF_CONTROLLER_UNIQUE_ID, DEFAULT_SCAN_INTERVAL, MIN_SCAN_INTERVAL +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class Control4Validator: + """Validates that config details can be used to authenticate and communicate with Control4.""" + + def __init__(self, host, username, password, hass): + """Initialize.""" + self.host = host + self.username = username + self.password = password + self.controller_unique_id = None + self.director_bearer_token = None + self.hass = hass + + async def authenticate(self) -> bool: + """Test if we can authenticate with the Control4 account API.""" + try: + account_session = aiohttp_client.async_get_clientsession(self.hass) + account = C4Account(self.username, self.password, account_session) + # Authenticate with Control4 account + await account.getAccountBearerToken() + + # Get controller name + account_controllers = await account.getAccountControllers() + self.controller_unique_id = account_controllers["controllerCommonName"] + + # Get bearer token to communicate with controller locally + self.director_bearer_token = ( + await account.getDirectorBearerToken(self.controller_unique_id) + )["token"] + return True + except (Unauthorized, NotFound): + return False + + async def connect_to_director(self) -> bool: + """Test if we can connect to the local Control4 Director.""" + try: + director_session = aiohttp_client.async_get_clientsession( + self.hass, verify_ssl=False + ) + director = C4Director( + self.host, self.director_bearer_token, director_session + ) + await director.getAllItemInfo() + return True + except (Unauthorized, ClientError, asyncioTimeoutError): + _LOGGER.error("Failed to connect to the Control4 controller") + return False + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Control4.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + hub = Control4Validator( + user_input["host"], + user_input["username"], + user_input["password"], + self.hass, + ) + try: + if not await hub.authenticate(): + raise InvalidAuth + if not await hub.connect_to_director(): + raise CannotConnect + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + controller_unique_id = hub.controller_unique_id + mac = (controller_unique_id.split("_", 3))[2] + formatted_mac = format_mac(mac) + await self.async_set_unique_id(formatted_mac) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=controller_unique_id, + data={ + CONF_HOST: user_input["host"], + CONF_USERNAME: user_input["username"], + CONF_PASSWORD: user_input["password"], + CONF_CONTROLLER_UNIQUE_ID: controller_unique_id, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Control4.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + ): vol.All(cv.positive_int, vol.Clamp(min=MIN_SCAN_INTERVAL)), + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/control4/const.py b/homeassistant/components/control4/const.py new file mode 100644 index 00000000000..27590881985 --- /dev/null +++ b/homeassistant/components/control4/const.py @@ -0,0 +1,18 @@ +"""Constants for the Control4 integration.""" + +DOMAIN = "control4" + +DEFAULT_SCAN_INTERVAL = 5 +MIN_SCAN_INTERVAL = 1 + +CONF_ACCOUNT = "account" +CONF_DIRECTOR = "director" +CONF_DIRECTOR_TOKEN_EXPIRATION = "director_token_expiry" +CONF_DIRECTOR_SW_VERSION = "director_sw_version" +CONF_DIRECTOR_MODEL = "director_model" +CONF_DIRECTOR_ALL_ITEMS = "director_all_items" +CONF_CONTROLLER_UNIQUE_ID = "controller_unique_id" + +CONF_CONFIG_LISTENER = "config_listener" + +CONTROL4_ENTITY_TYPE = 7 diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py new file mode 100644 index 00000000000..fc4ca9e358d --- /dev/null +++ b/homeassistant/components/control4/director_utils.py @@ -0,0 +1,62 @@ +"""Provides data updates from the Control4 controller for platforms.""" +import logging + +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import BadToken + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import ( + CONF_ACCOUNT, + CONF_CONTROLLER_UNIQUE_ID, + CONF_DIRECTOR, + CONF_DIRECTOR_TOKEN_EXPIRATION, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def director_update_data( + hass: HomeAssistant, entry: ConfigEntry, var: str +) -> dict: + """Retrieve data from the Control4 director for update_coordinator.""" + # possibly implement usage of director_token_expiration to start + # token refresh without waiting for error to occur + try: + director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(var) + except BadToken: + _LOGGER.info("Updating Control4 director token") + await refresh_tokens(hass, entry) + director = hass.data[DOMAIN][entry.entry_id][CONF_DIRECTOR] + data = await director.getAllItemVariableValue(var) + return {key["id"]: key for key in data} + + +async def refresh_tokens(hass: HomeAssistant, entry: ConfigEntry): + """Store updated authentication and director tokens in hass.data.""" + config = entry.data + account_session = aiohttp_client.async_get_clientsession(hass) + + account = C4Account(config[CONF_USERNAME], config[CONF_PASSWORD], account_session) + await account.getAccountBearerToken() + + controller_unique_id = config[CONF_CONTROLLER_UNIQUE_ID] + director_token_dict = await account.getDirectorBearerToken(controller_unique_id) + director_session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False) + + director = C4Director( + config[CONF_HOST], director_token_dict[CONF_TOKEN], director_session + ) + director_token_expiry = director_token_dict["token_expiration"] + + _LOGGER.debug("Saving new tokens in hass data") + entry_data = hass.data[DOMAIN][entry.entry_id] + entry_data[CONF_ACCOUNT] = account + entry_data[CONF_DIRECTOR] = director + entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] = director_token_expiry diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py new file mode 100644 index 00000000000..f121219fd36 --- /dev/null +++ b/homeassistant/components/control4/light.py @@ -0,0 +1,206 @@ +"""Platform for Control4 Lights.""" +import asyncio +from datetime import timedelta +import logging + +from pyControl4.error_handling import C4Exception +from pyControl4.light import C4Light + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_TRANSITION, + SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import Control4Entity, get_items_of_category +from .const import CONTROL4_ENTITY_TYPE, DOMAIN +from .director_utils import director_update_data + +_LOGGER = logging.getLogger(__name__) + +CONTROL4_CATEGORY = "lights" +CONTROL4_NON_DIMMER_VAR = "LIGHT_STATE" +CONTROL4_DIMMER_VAR = "LIGHT_LEVEL" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Set up Control4 lights from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + scan_interval = entry_data[CONF_SCAN_INTERVAL] + _LOGGER.debug( + "Scan interval = %s", scan_interval, + ) + + async def async_update_data_non_dimmer(): + """Fetch data from Control4 director for non-dimmer lights.""" + try: + return await director_update_data(hass, entry, CONTROL4_NON_DIMMER_VAR) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + async def async_update_data_dimmer(): + """Fetch data from Control4 director for dimmer lights.""" + try: + return await director_update_data(hass, entry, CONTROL4_DIMMER_VAR) + except C4Exception as err: + raise UpdateFailed(f"Error communicating with API: {err}") + + non_dimmer_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=async_update_data_non_dimmer, + update_interval=timedelta(seconds=scan_interval), + ) + dimmer_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="light", + update_method=async_update_data_dimmer, + update_interval=timedelta(seconds=scan_interval), + ) + + # Fetch initial data so we have data when entities subscribe + await non_dimmer_coordinator.async_refresh() + await dimmer_coordinator.async_refresh() + + items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY) + for item in items_of_category: + if item["type"] == CONTROL4_ENTITY_TYPE: + item_name = item["name"] + item_id = item["id"] + item_parent_id = item["parentId"] + item_is_dimmer = item["capabilities"]["dimmer"] + + if item_is_dimmer: + item_coordinator = dimmer_coordinator + else: + item_coordinator = non_dimmer_coordinator + + for parent_item in items_of_category: + if parent_item["id"] == item_parent_id: + item_manufacturer = parent_item["manufacturer"] + item_device_name = parent_item["name"] + item_model = parent_item["model"] + async_add_entities( + [ + Control4Light( + entry_data, + entry, + item_coordinator, + item_name, + item_id, + item_device_name, + item_manufacturer, + item_model, + item_parent_id, + item_is_dimmer, + ) + ], + True, + ) + + +class Control4Light(Control4Entity, LightEntity): + """Control4 light entity.""" + + def __init__( + self, + entry_data: dict, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + name: str, + idx: int, + device_name: str, + device_manufacturer: str, + device_model: str, + device_id: int, + is_dimmer: bool, + ): + """Initialize Control4 light entity.""" + super().__init__( + entry_data, + entry, + coordinator, + name, + idx, + device_name, + device_manufacturer, + device_model, + device_id, + ) + self._is_dimmer = is_dimmer + self._c4_light = None + + async def async_added_to_hass(self): + """When entity is added to hass.""" + await super().async_added_to_hass() + self._c4_light = C4Light(self.director, self._idx) + + @property + def is_on(self): + """Return whether this light is on or off.""" + return self._coordinator.data[self._idx]["value"] > 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._is_dimmer: + return round(self._coordinator.data[self._idx]["value"] * 2.55) + return None + + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = 0 + if self._is_dimmer: + flags |= SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION + return flags + + async def async_turn_on(self, **kwargs) -> None: + """Turn the entity on.""" + if self._is_dimmer: + if ATTR_TRANSITION in kwargs: + transition_length = kwargs[ATTR_TRANSITION] * 1000 + else: + transition_length = 0 + if ATTR_BRIGHTNESS in kwargs: + brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100 + else: + brightness = 100 + await self._c4_light.rampToLevel(brightness, transition_length) + else: + transition_length = 0 + await self._c4_light.setLevel(100) + if transition_length == 0: + transition_length = 1000 + delay_time = (transition_length / 1000) + 0.7 + _LOGGER.debug("Delaying light update by %s seconds", delay_time) + await asyncio.sleep(delay_time) + await self._coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + if self._is_dimmer: + if ATTR_TRANSITION in kwargs: + transition_length = kwargs[ATTR_TRANSITION] * 1000 + else: + transition_length = 0 + await self._c4_light.rampToLevel(0, transition_length) + else: + transition_length = 0 + await self._c4_light.setLevel(0) + if transition_length == 0: + transition_length = 1500 + delay_time = (transition_length / 1000) + 0.7 + _LOGGER.debug("Delaying light update by %s seconds", delay_time) + await asyncio.sleep(delay_time) + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/control4/manifest.json b/homeassistant/components/control4/manifest.json new file mode 100644 index 00000000000..0d61b080745 --- /dev/null +++ b/homeassistant/components/control4/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "control4", + "name": "Control4", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/control4", + "requirements": ["pyControl4==0.0.6"], + "ssdp": [ + { + "st": "c4:director" + } + ], + "codeowners": ["@lawtancool"] +} diff --git a/homeassistant/components/control4/strings.json b/homeassistant/components/control4/strings.json new file mode 100644 index 00000000000..34331bc18fa --- /dev/null +++ b/homeassistant/components/control4/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "description": "Please enter your Control4 account details and the IP address of your local controller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between updates" + } + } + } + } +} diff --git a/homeassistant/components/control4/translations/en.json b/homeassistant/components/control4/translations/en.json new file mode 100644 index 00000000000..035be90356a --- /dev/null +++ b/homeassistant/components/control4/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::username%]", + "username": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your Control4 account details and the IP address of your local controller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between updates" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 23fdc656af6..c1dbd1d05c0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -29,6 +29,7 @@ FLOWS = [ "bsblan", "cast", "cert_expiry", + "control4", "coolmaster", "coronavirus", "daikin", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 7271252c36f..d58842fe88e 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -12,6 +12,11 @@ SSDP = { "manufacturer": "ARCAM" } ], + "control4": [ + { + "st": "c4:director" + } + ], "deconz": [ { "manufacturer": "Royal Philips Electronics" diff --git a/requirements_all.txt b/requirements_all.txt index b1c8741f0cc..a8115a94796 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1167,6 +1167,9 @@ py17track==2.2.2 # homeassistant.components.hdmi_cec pyCEC==0.4.13 +# homeassistant.components.control4 +pyControl4==0.0.6 + # homeassistant.components.tplink pyHS100==0.3.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index acc69829b71..4fc18960e2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -539,6 +539,9 @@ py-melissa-climate==2.0.0 # homeassistant.components.seventeentrack py17track==2.2.2 +# homeassistant.components.control4 +pyControl4==0.0.6 + # homeassistant.components.tplink pyHS100==0.3.5.1 diff --git a/tests/components/control4/__init__.py b/tests/components/control4/__init__.py new file mode 100644 index 00000000000..8995968d5dd --- /dev/null +++ b/tests/components/control4/__init__.py @@ -0,0 +1 @@ +"""Tests for the Control4 integration.""" diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py new file mode 100644 index 00000000000..6d3293b147a --- /dev/null +++ b/tests/components/control4/test_config_flow.py @@ -0,0 +1,198 @@ +"""Test the Control4 config flow.""" +import datetime + +from pyControl4.account import C4Account +from pyControl4.director import C4Director +from pyControl4.error_handling import Unauthorized + +from homeassistant import config_entries, setup +from homeassistant.components.control4.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_USERNAME, +) + +from tests.async_mock import AsyncMock, patch +from tests.common import MockConfigEntry + + +def _get_mock_c4_account( + getAccountControllers={ + "controllerCommonName": "control4_model_00AA00AA00AA", + "href": "https://apis.control4.com/account/v3/rest/accounts/000000", + "name": "Name", + }, + getDirectorBearerToken={ + "token": "token", + "token_expiration": datetime.datetime(2020, 7, 15, 13, 50, 15, 26940), + }, +): + c4_account_mock = AsyncMock(C4Account) + + c4_account_mock.getAccountControllers.return_value = getAccountControllers + c4_account_mock.getDirectorBearerToken.return_value = getDirectorBearerToken + + return c4_account_mock + + +def _get_mock_c4_director(getAllItemInfo={}): + c4_director_mock = AsyncMock(C4Director) + c4_director_mock.getAllItemInfo.return_value = getAllItemInfo + + return c4_director_mock + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + c4_account = _get_mock_c4_account() + c4_director = _get_mock_c4_director() + with patch( + "homeassistant.components.control4.config_flow.C4Account", + return_value=c4_account, + ), patch( + "homeassistant.components.control4.config_flow.C4Director", + return_value=c4_director, + ), patch( + "homeassistant.components.control4.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.control4.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "control4_model_00AA00AA00AA" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + "controller_unique_id": "control4_model_00AA00AA00AA", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.C4Account", + side_effect=Unauthorized("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_unexpected_exception(hass): + """Test we handle an unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.C4Account", + side_effect=ValueError("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.control4.config_flow.Control4Validator.authenticate", + return_value=True, + ), patch( + "homeassistant.components.control4.config_flow.C4Director", + side_effect=Unauthorized("message"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_option_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 4}, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: 4, + } + + +async def test_option_flow_defaults(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + } From bfba44f6bb1066664d34a7fe83130b6799d7b89a Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 19 Jul 2020 18:05:53 -0400 Subject: [PATCH 048/362] Force updates for ZHA light group entity members (Part 2) (#37995) --- homeassistant/components/zha/light.py | 29 ++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index f1bae3dd4c2..6fefc795460 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1,5 +1,4 @@ """Lights on Zigbee Home Automation networks.""" -import asyncio from collections import Counter from datetime import timedelta import functools @@ -33,7 +32,10 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import State, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util @@ -73,6 +75,7 @@ UNSUPPORTED_ATTRIBUTE = 0x86 STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, light.DOMAIN) PARALLEL_UPDATES = 0 +SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" SUPPORT_GROUP_LIGHT = ( SUPPORT_BRIGHTNESS @@ -380,6 +383,12 @@ class Light(BaseLight, ZhaEntity): self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) + await self.async_accept_signal( + None, + SIGNAL_LIGHT_GROUP_STATE_CHANGED, + self._maybe_force_refresh, + signal_override=True, + ) async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" @@ -470,6 +479,12 @@ class Light(BaseLight, ZhaEntity): await self.async_get_state(from_cache=False) self.async_write_ha_state() + async def _maybe_force_refresh(self, signal): + """Force update the state if the signal contains the entity id for this entity.""" + if self.entity_id in signal["entity_ids"]: + await self.async_get_state(from_cache=False) + self.async_write_ha_state() + @STRICT_MATCH( channel_names=CHANNEL_ON_OFF, @@ -570,8 +585,8 @@ class LightGroup(BaseLight, ZhaGroupEntity): async def _force_member_updates(self): """Force the update of member entities to ensure the states are correct for bulbs that don't report their state.""" - component = self.hass.data[light.DOMAIN] - entities = [component.get_entity(entity_id) for entity_id in self._entity_ids] - tasks = [entity.async_get_state(from_cache=False) for entity in entities] - if tasks: - await asyncio.gather(*tasks) + async_dispatcher_send( + self.hass, + SIGNAL_LIGHT_GROUP_STATE_CHANGED, + {"entity_ids": self._entity_ids}, + ) From 9092b83869f6fafc38bd1e78e5f4076e29b0fdf4 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Mon, 20 Jul 2020 00:03:01 +0000 Subject: [PATCH 049/362] [ci skip] Translation update --- .../components/airly/translations/es.json | 2 +- .../components/binary_sensor/translations/es.json | 2 +- .../components/braviatv/translations/es.json | 2 +- .../components/brother/translations/es.json | 2 +- .../components/bsblan/translations/es.json | 2 +- .../components/cert_expiry/translations/es.json | 4 ++-- .../components/control4/translations/en.json | 14 +++++++------- homeassistant/components/demo/translations/es.json | 6 ------ .../components/denonavr/translations/es.json | 2 +- .../components/directv/translations/es.json | 2 +- .../components/doorbird/translations/es.json | 2 +- .../components/firmata/translations/es.json | 7 +++++++ .../components/fritzbox/translations/es.json | 2 +- .../components/harmony/translations/es.json | 2 +- .../components/huawei_lte/translations/es.json | 2 +- .../components/humidifier/translations/es.json | 10 ++++++++++ .../humidifier/translations/zh-Hant.json | 10 ++++++++++ .../components/iaqualink/translations/es.json | 2 +- .../components/konnected/translations/es.json | 10 ++++------ .../components/melcloud/translations/es.json | 4 ++-- .../components/monoprice/translations/es.json | 2 +- .../components/notify/translations/es.json | 2 +- .../components/notion/translations/es.json | 2 +- homeassistant/components/nws/translations/es.json | 2 +- .../components/openuv/translations/es.json | 4 ++-- .../components/person/translations/es.json | 2 +- .../components/pi_hole/translations/es.json | 3 ++- .../components/pi_hole/translations/zh-Hant.json | 3 ++- .../components/point/translations/es.json | 12 ++++++------ .../components/rachio/translations/es.json | 2 +- homeassistant/components/roku/translations/es.json | 6 +++--- .../components/roomba/translations/es.json | 2 +- .../components/samsungtv/translations/es.json | 6 +++--- .../components/sense/translations/es.json | 2 +- .../components/simplisafe/translations/es.json | 2 +- .../components/solaredge/translations/es.json | 2 +- .../components/synology_dsm/translations/es.json | 4 ++-- .../components/system_health/translations/es.json | 2 +- .../components/tesla/translations/es.json | 2 +- .../components/unifi/translations/es.json | 6 ------ .../components/vacuum/translations/es.json | 2 +- .../components/vesync/translations/es.json | 2 +- .../components/vilfo/translations/es.json | 4 ++-- .../components/vizio/translations/es.json | 8 ++++---- .../components/withings/translations/es.json | 2 +- .../components/xiaomi_miio/translations/es.json | 2 +- .../components/zwave/translations/es.json | 2 +- 47 files changed, 97 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/firmata/translations/es.json diff --git a/homeassistant/components/airly/translations/es.json b/homeassistant/components/airly/translations/es.json index 353acfe2fb8..dececf29a69 100644 --- a/homeassistant/components/airly/translations/es.json +++ b/homeassistant/components/airly/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "Clave API de Airly", + "api_key": "Clave API", "latitude": "Latitud", "longitude": "Longitud", "name": "Nombre de la integraci\u00f3n" diff --git a/homeassistant/components/binary_sensor/translations/es.json b/homeassistant/components/binary_sensor/translations/es.json index 75b8a33026f..a3e48832468 100644 --- a/homeassistant/components/binary_sensor/translations/es.json +++ b/homeassistant/components/binary_sensor/translations/es.json @@ -131,7 +131,7 @@ "on": "H\u00famedo" }, "motion": { - "off": "Sin movimiento", + "off": "No detectado", "on": "Detectado" }, "occupancy": { diff --git a/homeassistant/components/braviatv/translations/es.json b/homeassistant/components/braviatv/translations/es.json index 61dac5c9d62..8995161f5de 100644 --- a/homeassistant/components/braviatv/translations/es.json +++ b/homeassistant/components/braviatv/translations/es.json @@ -19,7 +19,7 @@ }, "user": { "data": { - "host": "Nombre host del televisor o direcci\u00f3n IP" + "host": "Host" }, "description": "Configura la integraci\u00f3n del televisor Sony Bravia. Si tienes problemas con la configuraci\u00f3n, ve a: https://www.home-assistant.io/integrations/braviatv\n\nAseg\u00farate de que tu televisor est\u00e1 encendido.", "title": "Televisor Sony Bravia" diff --git a/homeassistant/components/brother/translations/es.json b/homeassistant/components/brother/translations/es.json index af05a39d9c9..51e1492be13 100644 --- a/homeassistant/components/brother/translations/es.json +++ b/homeassistant/components/brother/translations/es.json @@ -13,7 +13,7 @@ "step": { "user": { "data": { - "host": "Nombre del host o direcci\u00f3n IP de la impresora", + "host": "Host", "type": "Tipo de impresora" }, "description": "Configura la integraci\u00f3n de impresoras Brother. Si tienes problemas con la configuraci\u00f3n, ve a: https://www.home-assistant.io/integrations/brother" diff --git a/homeassistant/components/bsblan/translations/es.json b/homeassistant/components/bsblan/translations/es.json index 9d90f95e3d8..e22dbfa75ec 100644 --- a/homeassistant/components/bsblan/translations/es.json +++ b/homeassistant/components/bsblan/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "host": "Host o direcci\u00f3n IP", + "host": "Host", "passkey": "Clave de acceso", "port": "Puerto" }, diff --git a/homeassistant/components/cert_expiry/translations/es.json b/homeassistant/components/cert_expiry/translations/es.json index d616634fdea..8f62d763063 100644 --- a/homeassistant/components/cert_expiry/translations/es.json +++ b/homeassistant/components/cert_expiry/translations/es.json @@ -12,9 +12,9 @@ "step": { "user": { "data": { - "host": "El nombre de host del certificado", + "host": "Host", "name": "El nombre del certificado", - "port": "El puerto del certificado" + "port": "Puerto" }, "title": "Defina el certificado para probar" } diff --git a/homeassistant/components/control4/translations/en.json b/homeassistant/components/control4/translations/en.json index 035be90356a..d8bb94fc0ed 100644 --- a/homeassistant/components/control4/translations/en.json +++ b/homeassistant/components/control4/translations/en.json @@ -1,19 +1,19 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Device is already configured" }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" }, "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::ip%]", - "password": "[%key:common::config_flow::data::username%]", - "username": "[%key:common::config_flow::data::password%]" + "host": "IP Address", + "password": "Password", + "username": "Username" }, "description": "Please enter your Control4 account details and the IP address of your local controller." } diff --git a/homeassistant/components/demo/translations/es.json b/homeassistant/components/demo/translations/es.json index bcf9dbbcbcf..19ebc05d089 100644 --- a/homeassistant/components/demo/translations/es.json +++ b/homeassistant/components/demo/translations/es.json @@ -1,12 +1,6 @@ { "options": { "step": { - "init": { - "data": { - "one": "Vacio", - "other": "Vacio" - } - }, "options_1": { "data": { "bool": "Booleano opcional", diff --git a/homeassistant/components/denonavr/translations/es.json b/homeassistant/components/denonavr/translations/es.json index 69568002c35..2332d61c967 100644 --- a/homeassistant/components/denonavr/translations/es.json +++ b/homeassistant/components/denonavr/translations/es.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n para este AVR Denon ya est\u00e1 en marcha.", - "connection_error": "No se ha podido conectar, por favor, int\u00e9ntelo de nuevo.", + "connection_error": "Fallo en la conexi\u00f3n, por favor int\u00e9ntalo de nuevo, desconectar la alimentaci\u00f3n y los cables de ethernet y reconectarlos puede ayudar.", "not_denonavr_manufacturer": "No es un Receptor AVR Denon AVR en Red, el fabricante detectado no concuerda", "not_denonavr_missing": "No es un Receptor AVR Denon AVR en Red, la informaci\u00f3n detectada no est\u00e1 completa" }, diff --git a/homeassistant/components/directv/translations/es.json b/homeassistant/components/directv/translations/es.json index e6a0d6d07ea..f1d896e698b 100644 --- a/homeassistant/components/directv/translations/es.json +++ b/homeassistant/components/directv/translations/es.json @@ -14,7 +14,7 @@ }, "user": { "data": { - "host": "Host o direcci\u00f3n IP" + "host": "Host" } } } diff --git a/homeassistant/components/doorbird/translations/es.json b/homeassistant/components/doorbird/translations/es.json index b9c77b9ae91..2bf3ff7fc25 100644 --- a/homeassistant/components/doorbird/translations/es.json +++ b/homeassistant/components/doorbird/translations/es.json @@ -14,7 +14,7 @@ "step": { "user": { "data": { - "host": "Host (Direcci\u00f3n IP)", + "host": "Host", "name": "Nombre del dispositivo", "password": "Contrase\u00f1a", "username": "Usuario" diff --git a/homeassistant/components/firmata/translations/es.json b/homeassistant/components/firmata/translations/es.json new file mode 100644 index 00000000000..3cd5b23ceb6 --- /dev/null +++ b/homeassistant/components/firmata/translations/es.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "No se ha podido conectar a la placa Firmata durante la configuraci\u00f3n" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/es.json b/homeassistant/components/fritzbox/translations/es.json index d677acde160..69dad751e63 100644 --- a/homeassistant/components/fritzbox/translations/es.json +++ b/homeassistant/components/fritzbox/translations/es.json @@ -20,7 +20,7 @@ }, "user": { "data": { - "host": "Host o direcci\u00f3n IP", + "host": "Host", "password": "Contrase\u00f1a", "username": "Usuario" }, diff --git a/homeassistant/components/harmony/translations/es.json b/homeassistant/components/harmony/translations/es.json index 97656e5441d..9fbca3b97d3 100644 --- a/homeassistant/components/harmony/translations/es.json +++ b/homeassistant/components/harmony/translations/es.json @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Nombre del host o direcci\u00f3n IP", + "host": "Host", "name": "Nombre del concentrador" }, "title": "Configurar Logitech Harmony Hub" diff --git a/homeassistant/components/huawei_lte/translations/es.json b/homeassistant/components/huawei_lte/translations/es.json index b9d4ae2afc8..268fdf8eff5 100644 --- a/homeassistant/components/huawei_lte/translations/es.json +++ b/homeassistant/components/huawei_lte/translations/es.json @@ -33,7 +33,7 @@ "step": { "init": { "data": { - "name": "Nombre del servicio de notificaci\u00f3n", + "name": "Nombre del servicio de notificaci\u00f3n (el cambio requiere reiniciar)", "recipient": "Destinatarios de notificaciones por SMS", "track_new_devices": "Rastrea nuevos dispositivos" } diff --git a/homeassistant/components/humidifier/translations/es.json b/homeassistant/components/humidifier/translations/es.json index 2c867e0bc73..357ca767534 100644 --- a/homeassistant/components/humidifier/translations/es.json +++ b/homeassistant/components/humidifier/translations/es.json @@ -6,6 +6,16 @@ "toggle": "Alternar {entity_name}", "turn_off": "Apagar {entity_name}", "turn_on": "Encender {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} est\u00e1 configurado en un modo espec\u00edfico", + "is_off": "{entity_name} est\u00e1 apagado", + "is_on": "{entity_name} est\u00e1 activado" + }, + "trigger_type": { + "target_humidity_changed": "La humedad objetivo ha cambiado en {entity_name}", + "turned_off": "{entity_name} desactivado", + "turned_on": "{entity_name} activado" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/zh-Hant.json b/homeassistant/components/humidifier/translations/zh-Hant.json index c067d97d956..3e37ba38f64 100644 --- a/homeassistant/components/humidifier/translations/zh-Hant.json +++ b/homeassistant/components/humidifier/translations/zh-Hant.json @@ -6,6 +6,16 @@ "toggle": "\u5207\u63db{entity_name}", "turn_off": "\u95dc\u9589{entity_name}", "turn_on": "\u958b\u555f{entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name}\u8a2d\u5b9a\u70ba\u6307\u5b9a\u6a21\u5f0f", + "is_off": "{entity_name}\u5df2\u95dc\u9589", + "is_on": "{entity_name}\u5df2\u958b\u555f" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name}\u8a2d\u5b9a\u6fd5\u5ea6\u5df2\u8b8a\u66f4", + "turned_off": "{entity_name}\u5df2\u95dc\u9589", + "turned_on": "{entity_name}\u5df2\u958b\u555f" } }, "state": { diff --git a/homeassistant/components/iaqualink/translations/es.json b/homeassistant/components/iaqualink/translations/es.json index 74ae609c0d2..95ebdf89c98 100644 --- a/homeassistant/components/iaqualink/translations/es.json +++ b/homeassistant/components/iaqualink/translations/es.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Usuario / correo electr\u00f3nico" + "username": "Usuario" }, "description": "Por favor, introduzca el nombre de usuario y la contrase\u00f1a de su cuenta de iAqualink.", "title": "Con\u00e9ctese a iAqualink" diff --git a/homeassistant/components/konnected/translations/es.json b/homeassistant/components/konnected/translations/es.json index 0b3bff1ff92..f27fe036fa7 100644 --- a/homeassistant/components/konnected/translations/es.json +++ b/homeassistant/components/konnected/translations/es.json @@ -20,8 +20,8 @@ }, "user": { "data": { - "host": "Direcci\u00f3n IP del dispositivo Konnected", - "port": "Puerto del dispositivo Konnected" + "host": "Direcci\u00f3n IP", + "port": "Puerto" }, "description": "Introduzca la informaci\u00f3n del host de su panel Konnected." } @@ -32,9 +32,7 @@ "not_konn_panel": "No es un dispositivo Konnected.io reconocido" }, "error": { - "bad_host": "URL del host de la API de invalidaci\u00f3n no v\u00e1lida", - "one": "", - "other": "otros" + "bad_host": "URL del host de la API de invalidaci\u00f3n no v\u00e1lida" }, "step": { "options_binary": { @@ -101,7 +99,7 @@ "pause": "Pausa entre pulsos (ms) (opcional)", "repeat": "Tiempos de repetici\u00f3n (-1 = infinito) (opcional)" }, - "description": "Por favor, seleccione las opciones de salida para {zone}", + "description": "Selecciona las opciones de salida para {zone}: state {state}", "title": "Configurar la salida conmutable" } } diff --git a/homeassistant/components/melcloud/translations/es.json b/homeassistant/components/melcloud/translations/es.json index 9dbb9d4f1f6..caba17be17a 100644 --- a/homeassistant/components/melcloud/translations/es.json +++ b/homeassistant/components/melcloud/translations/es.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "password": "Contrase\u00f1a de MELCloud.", - "username": "Correo electr\u00f3nico utilizado para iniciar sesi\u00f3n en MELCloud." + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" }, "description": "Con\u00e9ctate usando tu cuenta de MELCloud.", "title": "Con\u00e9ctese a MELCloud" diff --git a/homeassistant/components/monoprice/translations/es.json b/homeassistant/components/monoprice/translations/es.json index fabc33234b6..549b7134234 100644 --- a/homeassistant/components/monoprice/translations/es.json +++ b/homeassistant/components/monoprice/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "port": "Puerto serie", + "port": "Puerto", "source_1": "Nombre de la fuente #1", "source_2": "Nombre de la fuente #2", "source_3": "Nombre de la fuente #3", diff --git a/homeassistant/components/notify/translations/es.json b/homeassistant/components/notify/translations/es.json index d92f73d4a77..edd2d8dc728 100644 --- a/homeassistant/components/notify/translations/es.json +++ b/homeassistant/components/notify/translations/es.json @@ -1,3 +1,3 @@ { - "title": "Notificar" + "title": "Notificaciones" } \ No newline at end of file diff --git a/homeassistant/components/notion/translations/es.json b/homeassistant/components/notion/translations/es.json index ca40862f475..2ea7fbb0db7 100644 --- a/homeassistant/components/notion/translations/es.json +++ b/homeassistant/components/notion/translations/es.json @@ -11,7 +11,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Usuario/correo electr\u00f3nico" + "username": "Usuario" }, "title": "Completa tu informaci\u00f3n" } diff --git a/homeassistant/components/nws/translations/es.json b/homeassistant/components/nws/translations/es.json index 0dd768d15d0..4c1107dd05e 100644 --- a/homeassistant/components/nws/translations/es.json +++ b/homeassistant/components/nws/translations/es.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "Clave API (correo electr\u00f3nico)", + "api_key": "Clave API", "latitude": "Latitud", "longitude": "Longitud", "station": "C\u00f3digo de estaci\u00f3n METAR" diff --git a/homeassistant/components/openuv/translations/es.json b/homeassistant/components/openuv/translations/es.json index 4eb27857310..45e566ac2af 100644 --- a/homeassistant/components/openuv/translations/es.json +++ b/homeassistant/components/openuv/translations/es.json @@ -5,12 +5,12 @@ }, "error": { "identifier_exists": "Coordenadas ya registradas", - "invalid_api_key": "Clave API inv\u00e1lida" + "invalid_api_key": "Clave API no v\u00e1lida" }, "step": { "user": { "data": { - "api_key": "Clave API de OpenUV", + "api_key": "Clave API", "elevation": "Elevaci\u00f3n", "latitude": "Latitud", "longitude": "Longitud" diff --git a/homeassistant/components/person/translations/es.json b/homeassistant/components/person/translations/es.json index c87164c5f12..98fca470569 100644 --- a/homeassistant/components/person/translations/es.json +++ b/homeassistant/components/person/translations/es.json @@ -1,7 +1,7 @@ { "state": { "_": { - "home": "Casa", + "home": "En casa", "not_home": "Fuera de casa" } }, diff --git a/homeassistant/components/pi_hole/translations/es.json b/homeassistant/components/pi_hole/translations/es.json index 9725843cef6..08391a45f63 100644 --- a/homeassistant/components/pi_hole/translations/es.json +++ b/homeassistant/components/pi_hole/translations/es.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "Clave API (Opcional)", + "api_key": "Clave API", "host": "Host", + "location": "Ubicaci\u00f3n", "name": "Nombre", "port": "Puerto", "ssl": "Usar SSL", diff --git a/homeassistant/components/pi_hole/translations/zh-Hant.json b/homeassistant/components/pi_hole/translations/zh-Hant.json index 9864e557439..df1d3c44b6f 100644 --- a/homeassistant/components/pi_hole/translations/zh-Hant.json +++ b/homeassistant/components/pi_hole/translations/zh-Hant.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API \u5bc6\u9470\uff08\u9078\u9805\uff09", + "api_key": "API \u5bc6\u9470", "host": "\u4e3b\u6a5f\u7aef", + "location": "\u5ea7\u6a19", "name": "\u540d\u7a31", "port": "\u901a\u8a0a\u57e0", "ssl": "\u4f7f\u7528 SSL", diff --git a/homeassistant/components/point/translations/es.json b/homeassistant/components/point/translations/es.json index 04e0498fcdf..a7247b3d9b3 100644 --- a/homeassistant/components/point/translations/es.json +++ b/homeassistant/components/point/translations/es.json @@ -3,16 +3,16 @@ "abort": { "already_setup": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n", - "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n", + "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.", "external_setup": "Point se ha configurado correctamente a partir de otro flujo.", - "no_flows": "Es necesario configurar Point antes de poder autenticarse con \u00e9l. [Echa un vistazo a las instrucciones] (https://www.home-assistant.io/components/point/)." + "no_flows": "El componente no est\u00e1 configurado. Consulta la documentaci\u00f3n." }, "create_entry": { - "default": "Autenticado correctamente con Minut para tu(s) dispositivo(s) Point" + "default": "Autenticado correctamente" }, "error": { "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar.", - "no_token": "No autenticado con Minut" + "no_token": "Token de acceso no v\u00e1lido" }, "step": { "auth": { @@ -23,8 +23,8 @@ "data": { "flow_impl": "Proveedor" }, - "description": "Elige a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n quieres autenticarte con Point.", - "title": "Proveedor de autenticaci\u00f3n" + "description": "\u00bfQuieres comenzar a configurar?", + "title": "Selecciona el m\u00e9todo de autenticaci\u00f3n" } } } diff --git a/homeassistant/components/rachio/translations/es.json b/homeassistant/components/rachio/translations/es.json index ee7bdfeb26d..7e4a03c138a 100644 --- a/homeassistant/components/rachio/translations/es.json +++ b/homeassistant/components/rachio/translations/es.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "api_key": "La clave API para la cuenta Rachio." + "api_key": "Clave API" }, "description": "Necesitar\u00e1s la clave API de https://app.rach.io/. Selecciona 'Account Settings' y luego haz clic en 'GET API KEY'.", "title": "Conectar a tu dispositivo Rachio" diff --git a/homeassistant/components/roku/translations/es.json b/homeassistant/components/roku/translations/es.json index 222dd1eddec..78fb2580927 100644 --- a/homeassistant/components/roku/translations/es.json +++ b/homeassistant/components/roku/translations/es.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "El dispositivo Roku ya est\u00e1 configurado", + "already_configured": "El dispositivo ya est\u00e1 configurado", "unknown": "Error inesperado" }, "error": { - "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo." + "cannot_connect": "Fallo al conectar" }, "flow_title": "Roku: {name}", "step": { @@ -15,7 +15,7 @@ }, "user": { "data": { - "host": "Host o direcci\u00f3n IP" + "host": "Host" }, "description": "Introduce tu informaci\u00f3n de Roku." } diff --git a/homeassistant/components/roomba/translations/es.json b/homeassistant/components/roomba/translations/es.json index 22f1ead1a56..c96508c7944 100644 --- a/homeassistant/components/roomba/translations/es.json +++ b/homeassistant/components/roomba/translations/es.json @@ -9,7 +9,7 @@ "blid": "BLID", "continuous": "Continuo", "delay": "Retardo", - "host": "Nombre del host o direcci\u00f3n IP", + "host": "Host", "password": "Contrase\u00f1a" }, "description": "Actualmente recuperar el BLID y la contrase\u00f1a es un proceso manual. Sigue los pasos descritos en la documentaci\u00f3n en: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials", diff --git a/homeassistant/components/samsungtv/translations/es.json b/homeassistant/components/samsungtv/translations/es.json index b8f789420e5..308df08de0d 100644 --- a/homeassistant/components/samsungtv/translations/es.json +++ b/homeassistant/components/samsungtv/translations/es.json @@ -3,19 +3,19 @@ "abort": { "already_configured": "Este televisor Samsung ya est\u00e1 configurado.", "already_in_progress": "La configuraci\u00f3n del televisor Samsung ya est\u00e1 en marcha.", - "auth_missing": "Home Assistant no est\u00e1 autenticado para conectarse a este televisor Samsung.", + "auth_missing": "Home Assistant no est\u00e1 autorizado para conectarse a este televisor Samsung. Revisa la configuraci\u00f3n de tu televisor para autorizar a Home Assistant.", "not_successful": "No se puede conectar a este dispositivo Samsung TV.", "not_supported": "Esta televisi\u00f3n Samsung actualmente no es compatible." }, "flow_title": "Televisor Samsung: {model}", "step": { "confirm": { - "description": "\u00bfDesea configurar el televisor Samsung {model} ? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor pidiendo autenticaci\u00f3n. Las configuraciones manuales para este televisor se sobrescribir\u00e1n.", + "description": "\u00bfQuieres configurar la televisi\u00f3n Samsung {model}? Si nunca la has conectado a Home Assistant antes deber\u00edas ver una ventana en tu TV pidiendo autorizaci\u00f3n. Cualquier configuraci\u00f3n manual de esta TV se sobreescribir\u00e1.", "title": "Samsung TV" }, "user": { "data": { - "host": "Host o direcci\u00f3n IP", + "host": "Host", "name": "Nombre" }, "description": "Introduce la informaci\u00f3n de tu televisi\u00f3n Samsung. Si nunca antes te conectaste con Home Assistant, deber\u00edas ver un mensaje en tu televisi\u00f3n pidiendo autorizaci\u00f3n." diff --git a/homeassistant/components/sense/translations/es.json b/homeassistant/components/sense/translations/es.json index f80a5da1d44..1199823081c 100644 --- a/homeassistant/components/sense/translations/es.json +++ b/homeassistant/components/sense/translations/es.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "Direcci\u00f3n de correo electr\u00f3nico", + "email": "Correo electr\u00f3nico", "password": "Contrase\u00f1a" }, "title": "Conectar a tu Sense Energy Monitor" diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json index 8dbf1248fd6..7ed09529ccc 100644 --- a/homeassistant/components/simplisafe/translations/es.json +++ b/homeassistant/components/simplisafe/translations/es.json @@ -12,7 +12,7 @@ "data": { "code": "C\u00f3digo (utilizado en el interfaz de usuario de Home Assistant)", "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" + "username": "Correo electr\u00f3nico" }, "title": "Introduce tu informaci\u00f3n" } diff --git a/homeassistant/components/solaredge/translations/es.json b/homeassistant/components/solaredge/translations/es.json index f3a97d29f50..7a8b55fc649 100644 --- a/homeassistant/components/solaredge/translations/es.json +++ b/homeassistant/components/solaredge/translations/es.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "api_key": "La clave de la API para este sitio", + "api_key": "Clave API", "name": "El nombre de esta instalaci\u00f3n", "site_id": "La identificaci\u00f3n del sitio de SolarEdge" }, diff --git a/homeassistant/components/synology_dsm/translations/es.json b/homeassistant/components/synology_dsm/translations/es.json index 390f67d4667..a498333e049 100644 --- a/homeassistant/components/synology_dsm/translations/es.json +++ b/homeassistant/components/synology_dsm/translations/es.json @@ -21,7 +21,7 @@ "link": { "data": { "password": "Contrase\u00f1a", - "port": "Puerto (opcional)", + "port": "Puerto", "ssl": "Usar SSL/TLS para conectar con tu NAS", "username": "Usuario" }, @@ -32,7 +32,7 @@ "data": { "host": "Host", "password": "Contrase\u00f1a", - "port": "Puerto (opcional)", + "port": "Puerto", "ssl": "Usar SSL/TLS para conectar con tu NAS", "username": "Usuario" }, diff --git a/homeassistant/components/system_health/translations/es.json b/homeassistant/components/system_health/translations/es.json index 11ee35782b1..ada0964a358 100644 --- a/homeassistant/components/system_health/translations/es.json +++ b/homeassistant/components/system_health/translations/es.json @@ -1,3 +1,3 @@ { - "title": "Estado del Sistema" + "title": "Estado del sistema" } \ No newline at end of file diff --git a/homeassistant/components/tesla/translations/es.json b/homeassistant/components/tesla/translations/es.json index e0f5f81e5ae..8bb659d377f 100644 --- a/homeassistant/components/tesla/translations/es.json +++ b/homeassistant/components/tesla/translations/es.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" + "username": "Correo electr\u00f3nico" }, "description": "Por favor, introduzca su informaci\u00f3n.", "title": "Tesla - Configuraci\u00f3n" diff --git a/homeassistant/components/unifi/translations/es.json b/homeassistant/components/unifi/translations/es.json index 1867bd89ffd..da67cd1bcaf 100644 --- a/homeassistant/components/unifi/translations/es.json +++ b/homeassistant/components/unifi/translations/es.json @@ -44,12 +44,6 @@ "description": "Configurar dispositivo de seguimiento", "title": "Opciones UniFi 1/3" }, - "init": { - "data": { - "one": "vac\u00edo", - "other": "vac\u00edo" - } - }, "simple_options": { "data": { "block_client": "Acceso controlado a la red de los clientes", diff --git a/homeassistant/components/vacuum/translations/es.json b/homeassistant/components/vacuum/translations/es.json index 0cf61c498f2..87a79a4e5da 100644 --- a/homeassistant/components/vacuum/translations/es.json +++ b/homeassistant/components/vacuum/translations/es.json @@ -16,7 +16,7 @@ "state": { "_": { "cleaning": "Limpiando", - "docked": "En base", + "docked": "En la base", "error": "Error", "idle": "Inactivo", "off": "Apagado", diff --git a/homeassistant/components/vesync/translations/es.json b/homeassistant/components/vesync/translations/es.json index 9eac2f6155d..f4d94672f48 100644 --- a/homeassistant/components/vesync/translations/es.json +++ b/homeassistant/components/vesync/translations/es.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Contrase\u00f1a", - "username": "Direcci\u00f3n de correo electr\u00f3nico" + "username": "Correo electr\u00f3nico" }, "title": "Introduzca el nombre de usuario y la contrase\u00f1a" } diff --git a/homeassistant/components/vilfo/translations/es.json b/homeassistant/components/vilfo/translations/es.json index 97f4b8d417e..0fbfed923cf 100644 --- a/homeassistant/components/vilfo/translations/es.json +++ b/homeassistant/components/vilfo/translations/es.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "access_token": "Token de acceso para la API del Router Vilfo", - "host": "Nombre de host o IP del router" + "access_token": "Token de acceso", + "host": "Host" }, "description": "Configure la integraci\u00f3n del Router Vilfo. Necesita su nombre de host/IP del Router Vilfo y un token de acceso a la API. Para obtener informaci\u00f3n adicional sobre esta integraci\u00f3n y c\u00f3mo obtener esos detalles, visite: https://www.home-assistant.io/integrations/vilfo", "title": "Conectar con el Router Vilfo" diff --git a/homeassistant/components/vizio/translations/es.json b/homeassistant/components/vizio/translations/es.json index ad496be3836..9daaa0973b7 100644 --- a/homeassistant/components/vizio/translations/es.json +++ b/homeassistant/components/vizio/translations/es.json @@ -7,8 +7,8 @@ "error": { "cannot_connect": "No se pudo conectar", "complete_pairing_failed": "No se pudo completar el emparejamiento. Aseg\u00farate de que el PIN que has proporcionado es correcto y que el televisor sigue encendido y conectado a la red antes de volver a enviarlo.", - "host_exists": "El host ya est\u00e1 configurado.", - "name_exists": "Nombre ya configurado." + "host_exists": "Ya existe un VIZIO SmartCast Device configurado con ese host.", + "name_exists": "Ya existe un VIZIO SmartCast Device configurado con ese nombre." }, "step": { "pair_tv": { @@ -30,11 +30,11 @@ "data": { "access_token": "Token de acceso", "device_class": "Tipo de dispositivo", - "host": "< Host / IP > : ", + "host": "Host", "name": "Nombre" }, "description": "El token de acceso solo se necesita para las televisiones. Si est\u00e1s configurando una televisi\u00f3n y a\u00fan no tienes un token de acceso, d\u00e9jalo en blanco para iniciar el proceso de sincronizaci\u00f3n.", - "title": "Configurar el cliente de Vizio SmartCast" + "title": "VIZIO SmartCast Device" } } }, diff --git a/homeassistant/components/withings/translations/es.json b/homeassistant/components/withings/translations/es.json index 392f300260a..e59b6e96775 100644 --- a/homeassistant/components/withings/translations/es.json +++ b/homeassistant/components/withings/translations/es.json @@ -25,7 +25,7 @@ }, "reauth": { "description": "El perfil \"{profile}\" debe volver a autenticarse para continuar recibiendo datos de Withings.", - "title": "Volver a autenticar a {profile}" + "title": "Re-autentificar el perfil" } } } diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index e9d28698760..748b3da5b31 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -16,7 +16,7 @@ "name": "Nombre del Gateway", "token": "Token API" }, - "description": "Necesitar\u00e1s el Token API, consulta https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token para instrucciones.", + "description": "Necesitar\u00e1s el token de la API de 32 caracteres, revisa https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token para m\u00e1s instrucciones. Por favor, ten en cuenta que este token es diferente de la clave utilizada por la integraci\u00f3n de Xiaomi Aqara.", "title": "Conectar con un Xiaomi Gateway" }, "user": { diff --git a/homeassistant/components/zwave/translations/es.json b/homeassistant/components/zwave/translations/es.json index 0588ab6076b..02b95f0e028 100644 --- a/homeassistant/components/zwave/translations/es.json +++ b/homeassistant/components/zwave/translations/es.json @@ -11,7 +11,7 @@ "user": { "data": { "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)", - "usb_path": "Ruta USB" + "usb_path": "Ruta del dispositivo USB" }, "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n", "title": "Configurar Z-Wave" From 967a168ab766867fca534fe402ec1a4c149bc398 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sun, 19 Jul 2020 20:40:08 -0400 Subject: [PATCH 050/362] Update comment about parallel updates to match the documentation (#37964) --- homeassistant/helpers/entity_platform.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ee60563ba2a..d101abe2b1a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -79,7 +79,8 @@ class EntityPlatform: If parallel updates is set to 0, we skip the semaphore. If parallel updates is set to a number, we initialize the semaphore to that number. - Default for entities with `async_update` method is 1. Otherwise it's 0. + The default value for parallel requests is decided based on the first entity that is added to Home Assistant. + It's 0 if the entity defines the async_update method, else it's 1. """ if self.parallel_updates_created: return self.parallel_updates From 2c3618e2c7619f7d7545ea561d9b553bd1a62817 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Sun, 19 Jul 2020 17:48:08 -0700 Subject: [PATCH 051/362] Close androidtv ADB socket connection when Home Assistant stops (#37973) * Close the ADB connection on HA stop * Get the test to pass * Remove unnecessary test code * Register the callback sooner * '_async_stop' -> 'async_close' * 'async_close' method -> '_async_close' function --- .../components/androidtv/media_player.py | 8 ++++++++ .../components/androidtv/test_media_player.py | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 8971b04c044..6bf44d1a16b 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -40,6 +40,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_PAUSED, @@ -230,6 +231,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) raise PlatformNotReady + async def _async_close(event): + """Close the ADB socket connection when HA stops.""" + await aftv.adb_close() + + # Close the ADB connection when HA stops + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + device_args = [ aftv, config[CONF_NAME], diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index adece1430ca..bf16957b07f 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -47,6 +47,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PLATFORM, + EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_PLAYING, STATE_STANDBY, @@ -1154,3 +1155,22 @@ async def test_services_firetv(hass): await _test_service(hass, entity_id, SERVICE_MEDIA_STOP, "back") await _test_service(hass, entity_id, SERVICE_TURN_OFF, "adb_shell") await _test_service(hass, entity_id, SERVICE_TURN_ON, "adb_shell") + + +async def test_connection_closed_on_ha_stop(hass): + """Test that the ADB socket connection is closed when HA stops.""" + patch_key, entity_id = _setup(CONFIG_ANDROIDTV_ADB_SERVER) + + with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_OFF)[patch_key]: + assert await async_setup_component( + hass, DOMAIN, CONFIG_ANDROIDTV_ADB_SERVER + ) + await hass.async_block_till_done() + + with patch( + "androidtv.androidtv.androidtv_async.AndroidTVAsync.adb_close" + ) as adb_close: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert adb_close.called From 41421b56a4e915c3487243b0e0354a97e3d5f3c2 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sun, 19 Jul 2020 22:02:45 -0700 Subject: [PATCH 052/362] Bumpy pyobihai to make last reboot update as needed (#37914) --- homeassistant/components/obihai/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index bbcb2e4bc85..bb72a967605 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -2,6 +2,6 @@ "domain": "obihai", "name": "Obihai", "documentation": "https://www.home-assistant.io/integrations/obihai", - "requirements": ["pyobihai==1.2.1"], + "requirements": ["pyobihai==1.2.3"], "codeowners": ["@dshokouhi"] } diff --git a/requirements_all.txt b/requirements_all.txt index a8115a94796..767f5effad5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1515,7 +1515,7 @@ pynx584==0.5 pynzbgetapi==0.2.0 # homeassistant.components.obihai -pyobihai==1.2.1 +pyobihai==1.2.3 # homeassistant.components.ombi pyombi==0.1.10 From 890562e3ae6b3dbb70931e4d31aeffccb6caabc4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2020 19:52:41 -1000 Subject: [PATCH 053/362] Index the entity registry (#37994) --- homeassistant/helpers/entity_registry.py | 53 ++++++++++++++---------- tests/common.py | 1 + tests/helpers/test_entity_registry.py | 11 +++++ 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b9b9af6f5c1..c4c445b2be9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -124,6 +124,7 @@ class EntityRegistry: """Initialize the registry.""" self.hass = hass self.entities: Dict[str, RegistryEntry] + self._index: Dict[Tuple[str, str, str], str] = {} self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self.hass.bus.async_listen( EVENT_DEVICE_REGISTRY_UPDATED, self.async_device_removed @@ -160,14 +161,7 @@ class EntityRegistry: self, domain: str, platform: str, unique_id: str ) -> Optional[str]: """Check if an entity_id is currently registered.""" - for entity in self.entities.values(): - if ( - entity.domain == domain - and entity.platform == platform - and entity.unique_id == unique_id - ): - return entity.entity_id - return None + return self._index.get((domain, platform, unique_id)) @callback def async_generate_entity_id( @@ -270,7 +264,7 @@ class EntityRegistry: original_name=original_name, original_icon=original_icon, ) - self.entities[entity_id] = entity + self._register_entry(entity) _LOGGER.info("Registered new %s.%s entity: %s", domain, platform, entity_id) self.async_schedule_save() @@ -283,7 +277,7 @@ class EntityRegistry: @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" - self.entities.pop(entity_id) + self._unregister_entry(self.entities[entity_id]) self.hass.bus.async_fire( EVENT_ENTITY_REGISTRY_UPDATED, {"action": "remove", "entity_id": entity_id} ) @@ -380,27 +374,22 @@ class EntityRegistry: entity_id = changes["entity_id"] = new_entity_id if new_unique_id is not _UNDEF: - conflict = next( - ( - entity - for entity in self.entities.values() - if entity.unique_id == new_unique_id - and entity.domain == old.domain - and entity.platform == old.platform - ), - None, + conflict_entity_id = self.async_get_entity_id( + old.domain, old.platform, new_unique_id ) - if conflict: + if conflict_entity_id: raise ValueError( f"Unique id '{new_unique_id}' is already in use by " - f"'{conflict.entity_id}'" + f"'{conflict_entity_id}'" ) changes["unique_id"] = new_unique_id if not changes: return old - new = self.entities[entity_id] = attr.evolve(old, **changes) + self._remove_index(old) + new = attr.evolve(old, **changes) + self._register_entry(new) self.async_schedule_save() @@ -451,6 +440,7 @@ class EntityRegistry: ) self.entities = entities + self._rebuild_index() @callback def async_schedule_save(self) -> None: @@ -494,6 +484,25 @@ class EntityRegistry: ]: self.async_remove(entity_id) + def _register_entry(self, entry: RegistryEntry) -> None: + self.entities[entry.entity_id] = entry + self._add_index(entry) + + def _add_index(self, entry: RegistryEntry) -> None: + self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id + + def _unregister_entry(self, entry: RegistryEntry) -> None: + self._remove_index(entry) + del self.entities[entry.entity_id] + + def _remove_index(self, entry: RegistryEntry) -> None: + del self._index[(entry.domain, entry.platform, entry.unique_id)] + + def _rebuild_index(self) -> None: + self._index = {} + for entry in self.entities.values(): + self._add_index(entry) + @singleton(DATA_REGISTRY) async def async_get_registry(hass: HomeAssistantType) -> EntityRegistry: diff --git a/tests/common.py b/tests/common.py index 1436b0f5a8a..db060bc6b91 100644 --- a/tests/common.py +++ b/tests/common.py @@ -351,6 +351,7 @@ def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) registry.entities = mock_entries or OrderedDict() + registry._rebuild_index() hass.data[entity_registry.DATA_REGISTRY] = registry return registry diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 285f43b6d4d..97d8af7d0ee 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -428,6 +428,8 @@ async def test_update_entity_unique_id(registry): entry = registry.async_get_or_create( "light", "hue", "5678", config_entry=mock_config ) + assert registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id + new_unique_id = "1234" with patch.object(registry, "async_schedule_save") as mock_schedule_save: updated_entry = registry.async_update_entity( @@ -437,6 +439,9 @@ async def test_update_entity_unique_id(registry): assert updated_entry.unique_id == new_unique_id assert mock_schedule_save.call_count == 1 + assert registry.async_get_entity_id("light", "hue", "5678") is None + assert registry.async_get_entity_id("light", "hue", "1234") == entry.entity_id + async def test_update_entity_unique_id_conflict(registry): """Test migration raises when unique_id already in use.""" @@ -452,6 +457,8 @@ async def test_update_entity_unique_id_conflict(registry): ) as mock_schedule_save, pytest.raises(ValueError): registry.async_update_entity(entry.entity_id, new_unique_id=entry2.unique_id) assert mock_schedule_save.call_count == 0 + assert registry.async_get_entity_id("light", "hue", "5678") == entry.entity_id + assert registry.async_get_entity_id("light", "hue", "1234") == entry2.entity_id async def test_update_entity(registry): @@ -473,6 +480,10 @@ async def test_update_entity(registry): assert getattr(updated_entry, attr_name) == new_value assert getattr(updated_entry, attr_name) != getattr(entry, attr_name) + assert ( + registry.async_get_entity_id("light", "hue", "5678") + == updated_entry.entity_id + ) entry = updated_entry From 92d72f26c78f93a8a39971fe4bb7c7ff6673e338 Mon Sep 17 00:00:00 2001 From: Jesse Newland Date: Mon, 20 Jul 2020 00:55:50 -0500 Subject: [PATCH 054/362] Fix notify.slack service calls using data_template (#37980) --- homeassistant/components/slack/notify.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index aec7de1bd88..b7f3d81feb0 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -224,7 +224,10 @@ class SlackNotificationService(BaseNotificationService): async def async_send_message(self, message, **kwargs): """Send a message to Slack.""" - data = kwargs.get(ATTR_DATA, {}) + data = kwargs.get(ATTR_DATA) + + if data is None: + data = {} try: DATA_SCHEMA(data) From 6ea5c8aed9f113bc1ec58e449491e10112012e85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jul 2020 20:32:05 -1000 Subject: [PATCH 055/362] Index the device registry (#37990) --- homeassistant/helpers/device_registry.py | 156 +++++++++++++++++++---- tests/common.py | 1 + tests/helpers/test_device_registry.py | 15 +++ 3 files changed, 144 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index f9a7d6660da..b2e3bfd7a32 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,7 +1,7 @@ """Provide a way to connect entities belonging to one device.""" from collections import OrderedDict import logging -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union import uuid import attr @@ -32,6 +32,11 @@ CONNECTION_NETWORK_MAC = "mac" CONNECTION_UPNP = "upnp" CONNECTION_ZIGBEE = "zigbee" +IDX_CONNECTIONS = "connections" +IDX_IDENTIFIERS = "identifiers" +REGISTERED_DEVICE = "registered" +DELETED_DEVICE = "deleted" + @attr.s(slots=True, frozen=True) class DeletedDeviceEntry: @@ -98,11 +103,13 @@ class DeviceRegistry: devices: Dict[str, DeviceEntry] deleted_devices: Dict[str, DeletedDeviceEntry] + _devices_index: Dict[str, Dict[str, Dict[str, str]]] def __init__(self, hass: HomeAssistantType) -> None: """Initialize the device registry.""" self.hass = hass self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._clear_index() @callback def async_get(self, device_id: str) -> Optional[DeviceEntry]: @@ -114,25 +121,84 @@ class DeviceRegistry: self, identifiers: set, connections: set ) -> Optional[DeviceEntry]: """Check if device is registered.""" - for device in self.devices.values(): - if any(iden in device.identifiers for iden in identifiers) or any( - conn in device.connections for conn in connections - ): - return device - return None + device_id = self._async_get_device_id_from_index( + REGISTERED_DEVICE, identifiers, connections + ) + if device_id is None: + return None + return self.devices[device_id] - @callback def _async_get_deleted_device( self, identifiers: set, connections: set ) -> Optional[DeletedDeviceEntry]: + """Check if device is deleted.""" + device_id = self._async_get_device_id_from_index( + DELETED_DEVICE, identifiers, connections + ) + if device_id is None: + return None + return self.deleted_devices[device_id] + + def _async_get_device_id_from_index( + self, index: str, identifiers: set, connections: set + ) -> Optional[str]: """Check if device has previously been registered.""" - for device in self.deleted_devices.values(): - if any(iden in device.identifiers for iden in identifiers) or any( - conn in device.connections for conn in connections - ): - return device + devices_index = self._devices_index[index] + for identifier in identifiers: + if identifier in devices_index[IDX_IDENTIFIERS]: + return devices_index[IDX_IDENTIFIERS][identifier] + if not connections: + return None + for connection in _normalize_connections(connections): + if connection in devices_index[IDX_CONNECTIONS]: + return devices_index[IDX_CONNECTIONS][connection] return None + def _add_device(self, device: Union[DeviceEntry, DeletedDeviceEntry]) -> None: + """Add a device and index it.""" + if isinstance(device, DeletedDeviceEntry): + devices_index = self._devices_index[DELETED_DEVICE] + self.deleted_devices[device.id] = device + else: + devices_index = self._devices_index[REGISTERED_DEVICE] + self.devices[device.id] = device + + _add_device_to_index(devices_index, device) + + def _remove_device(self, device: Union[DeviceEntry, DeletedDeviceEntry]) -> None: + """Remove a device and remove it from the index.""" + if isinstance(device, DeletedDeviceEntry): + devices_index = self._devices_index[DELETED_DEVICE] + self.deleted_devices.pop(device.id) + else: + devices_index = self._devices_index[REGISTERED_DEVICE] + self.devices.pop(device.id) + + _remove_device_from_index(devices_index, device) + + def _update_device(self, old_device: DeviceEntry, new_device: DeviceEntry) -> None: + """Update a device and the index.""" + self.devices[new_device.id] = new_device + + devices_index = self._devices_index[REGISTERED_DEVICE] + _remove_device_from_index(devices_index, old_device) + _add_device_to_index(devices_index, new_device) + + def _clear_index(self): + """Clear the index.""" + self._devices_index = { + REGISTERED_DEVICE: {IDX_IDENTIFIERS: {}, IDX_CONNECTIONS: {}}, + DELETED_DEVICE: {IDX_IDENTIFIERS: {}, IDX_CONNECTIONS: {}}, + } + + def _rebuild_index(self): + """Create the index after loading devices.""" + self._clear_index() + for device in self.devices.values(): + _add_device_to_index(self._devices_index[REGISTERED_DEVICE], device) + for device in self.deleted_devices.values(): + _add_device_to_index(self._devices_index[DELETED_DEVICE], device) + @callback def async_get_or_create( self, @@ -156,11 +222,8 @@ class DeviceRegistry: if connections is None: connections = set() - - connections = { - (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) - for key, value in connections - } + else: + connections = _normalize_connections(connections) device = self.async_get_device(identifiers, connections) @@ -169,9 +232,9 @@ class DeviceRegistry: if deleted_device is None: device = DeviceEntry(is_new=True) else: - self.deleted_devices.pop(deleted_device.id) + self._remove_device(deleted_device) device = deleted_device.to_device_entry() - self.devices[device.id] = device + self._add_device(device) if via_device is not None: via = self.async_get_device({via_device}, set()) @@ -301,7 +364,8 @@ class DeviceRegistry: if not changes: return old - new = self.devices[device_id] = attr.evolve(old, **changes) + new = attr.evolve(old, **changes) + self._update_device(old, new) self.async_schedule_save() self.hass.bus.async_fire( @@ -317,12 +381,15 @@ class DeviceRegistry: @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" - device = self.devices.pop(device_id) - self.deleted_devices[device_id] = DeletedDeviceEntry( - config_entries=device.config_entries, - connections=device.connections, - identifiers=device.identifiers, - id=device.id, + device = self.devices[device_id] + self._remove_device(device) + self._add_device( + DeletedDeviceEntry( + config_entries=device.config_entries, + connections=device.connections, + identifiers=device.identifiers, + id=device.id, + ) ) self.hass.bus.async_fire( EVENT_DEVICE_REGISTRY_UPDATED, {"action": "remove", "device_id": device_id} @@ -371,6 +438,7 @@ class DeviceRegistry: self.devices = devices self.deleted_devices = deleted_devices + self._rebuild_index() @callback def async_schedule_save(self) -> None: @@ -422,9 +490,11 @@ class DeviceRegistry: continue if config_entries == {config_entry_id}: # Permanently remove the device from the device registry. - del self.deleted_devices[deleted_device.id] + self._remove_device(deleted_device) else: config_entries = config_entries - {config_entry_id} + # No need to reindex here since we currently + # do not have a lookup by config entry self.deleted_devices[deleted_device.id] = attr.evolve( deleted_device, config_entries=config_entries ) @@ -536,3 +606,33 @@ def async_setup_cleanup(hass: HomeAssistantType, dev_reg: DeviceRegistry) -> Non await debounced_cleanup.async_call() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, startup_clean) + + +def _normalize_connections(connections: set) -> set: + """Normalize connections to ensure we can match mac addresses.""" + return { + (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) + for key, value in connections + } + + +def _add_device_to_index( + devices_index: dict, device: Union[DeviceEntry, DeletedDeviceEntry] +) -> None: + """Add a device to the index.""" + for identifier in device.identifiers: + devices_index[IDX_IDENTIFIERS][identifier] = device.id + for connection in device.connections: + devices_index[IDX_CONNECTIONS][connection] = device.id + + +def _remove_device_from_index( + devices_index: dict, device: Union[DeviceEntry, DeletedDeviceEntry] +) -> None: + """Remove a device from the index.""" + for identifier in device.identifiers: + if identifier in devices_index[IDX_IDENTIFIERS]: + del devices_index[IDX_IDENTIFIERS][identifier] + for connection in device.connections: + if connection in devices_index[IDX_CONNECTIONS]: + del devices_index[IDX_CONNECTIONS][connection] diff --git a/tests/common.py b/tests/common.py index db060bc6b91..5fa2ba59ed1 100644 --- a/tests/common.py +++ b/tests/common.py @@ -371,6 +371,7 @@ def mock_device_registry(hass, mock_entries=None, mock_deleted_entries=None): registry = device_registry.DeviceRegistry(hass) registry.devices = mock_entries or OrderedDict() registry.deleted_devices = mock_deleted_entries or OrderedDict() + registry._rebuild_index() hass.data[device_registry.DATA_REGISTRY] = registry return registry diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 82fadc35dd2..181a012807a 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -562,6 +562,21 @@ async def test_update(registry): assert updated_entry.identifiers == new_identifiers assert updated_entry.via_device_id == "98765B" + assert registry.async_get_device({("hue", "456")}, {}) is None + assert registry.async_get_device({("bla", "123")}, {}) is None + + assert registry.async_get_device({("hue", "654")}, {}) == updated_entry + assert registry.async_get_device({("bla", "321")}, {}) == updated_entry + + assert ( + registry.async_get_device( + {}, {(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} + ) + == updated_entry + ) + + assert registry.async_get(updated_entry.id) is not None + async def test_update_remove_config_entries(hass, registry, update_events): """Make sure we do not get duplicate entries.""" From 36ee9ff58f3f77bd7d4e8f6b3fb049a522dc0d27 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Mon, 20 Jul 2020 18:33:56 +1000 Subject: [PATCH 056/362] Don't advertise switch devices as dimmable lights (#37978) This issue has been corrected and then reverted multiple times. It seems that the core issue was a casing one (On/off vs On/Off) ; for better matching with a real Hue hub, also add the productname. Tested to work with echo 2 and echo 5. --- homeassistant/components/emulated_hue/hue_api.py | 11 +++++------ tests/components/emulated_hue/test_hue_api.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 5eb32939d51..739baf0c425 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -744,12 +744,11 @@ def entity_to_json(config, entity): retval["modelid"] = "HASS123" retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) else: - # Dimmable light (Zigbee Device ID: 0x0100) - # Supports groups, scenes, on/off and dimming - # Reports fixed brightness for compatibility with Alexa. - retval["type"] = "Dimmable light" - retval["modelid"] = "HASS123" - retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) + # On/Off light (ZigBee Device ID: 0x0000) + # Supports groups, scenes and on/off control + retval["type"] = "On/Off light" + retval["productname"] = "On/Off light" + retval["modelid"] = "HASS321" return retval diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 4ffc7cd7f0e..99940e47133 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -252,7 +252,7 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): ) assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True - assert light_without_brightness_json["type"] == "Dimmable light" + assert light_without_brightness_json["type"] == "On/Off light" async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client): From 65eedcf4342c87979aedb9c094d5bf0c5334f657 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Mon, 20 Jul 2020 10:56:22 +0200 Subject: [PATCH 057/362] Disable polling for ozw entities (#38005) --- homeassistant/components/ozw/entity.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/ozw/entity.py b/homeassistant/components/ozw/entity.py index d64beb0ba34..deb70af1bb5 100644 --- a/homeassistant/components/ozw/entity.py +++ b/homeassistant/components/ozw/entity.py @@ -247,6 +247,11 @@ class ZWaveDeviceEntity(Entity): self.on_value_update() self.async_write_ha_state() + @property + def should_poll(self): + """No polling needed.""" + return False + async def _delete_callback(self, values_id): """Remove this entity.""" if not self.values: From d0d4e08a2aa5247da0e0587b6b9dbaf48fdf908d Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Mon, 20 Jul 2020 11:49:05 +0200 Subject: [PATCH 058/362] Force updates for ozw sensors (#38003) * Force updates and disable polling * Move force_update to sensor --- homeassistant/components/ozw/sensor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/ozw/sensor.py b/homeassistant/components/ozw/sensor.py index 309c2784405..453015991b7 100644 --- a/homeassistant/components/ozw/sensor.py +++ b/homeassistant/components/ozw/sensor.py @@ -87,6 +87,11 @@ class ZwaveSensorBase(ZWaveDeviceEntity): return False return True + @property + def force_update(self) -> bool: + """Force updates.""" + return True + class ZWaveNumericSensor(ZwaveSensorBase): """Representation of a Z-Wave sensor.""" From bedb0753f3210e9154d3219e21e0a08b96050979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Kr=C3=B3l?= Date: Mon, 20 Jul 2020 11:52:52 +0200 Subject: [PATCH 059/362] Add Wolflink integration (#34104) * WOLF Smart-set integration * Removed translations. Changed device class of timestamp. Added new test for unknown exception * Remove unit_of_measurement from hours sensor * Code cleanup. Pull Request comments fixes * ConnectError import change. Removed DEVICE_CLASS_TIMESTAMP * Add unique id guard with tests. Use common translations. Move device_id resolution to config_flow. * Remove debug print --- .coveragerc | 3 + CODEOWNERS | 1 + homeassistant/components/wolflink/__init__.py | 103 ++++++++++ .../components/wolflink/config_flow.py | 93 +++++++++ homeassistant/components/wolflink/const.py | 93 +++++++++ .../components/wolflink/manifest.json | 8 + homeassistant/components/wolflink/sensor.py | 182 ++++++++++++++++++ .../components/wolflink/strings.json | 27 +++ .../components/wolflink/strings.sensor.json | 87 +++++++++ .../components/wolflink/translations/en.json | 28 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/wolflink/__init__.py | 1 + tests/components/wolflink/test_config_flow.py | 139 +++++++++++++ 15 files changed, 772 insertions(+) create mode 100644 homeassistant/components/wolflink/__init__.py create mode 100644 homeassistant/components/wolflink/config_flow.py create mode 100644 homeassistant/components/wolflink/const.py create mode 100644 homeassistant/components/wolflink/manifest.json create mode 100644 homeassistant/components/wolflink/sensor.py create mode 100644 homeassistant/components/wolflink/strings.json create mode 100644 homeassistant/components/wolflink/strings.sensor.json create mode 100644 homeassistant/components/wolflink/translations/en.json create mode 100644 tests/components/wolflink/__init__.py create mode 100644 tests/components/wolflink/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 337842457f0..fd314287f87 100644 --- a/.coveragerc +++ b/.coveragerc @@ -934,6 +934,9 @@ omit = homeassistant/components/wiffi/* homeassistant/components/wink/* homeassistant/components/wirelesstag/* + homeassistant/components/wolflink/__init__.py + homeassistant/components/wolflink/sensor.py + homeassistant/components/wolflink/const.py homeassistant/components/worldtidesinfo/sensor.py homeassistant/components/worxlandroid/sensor.py homeassistant/components/x10/light.py diff --git a/CODEOWNERS b/CODEOWNERS index f367da9325c..711398ae455 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -459,6 +459,7 @@ homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wiffi/* @mampfes homeassistant/components/withings/* @vangorra homeassistant/components/wled/* @frenck +homeassistant/components/wolflink/* @adamkrol93 homeassistant/components/workday/* @fabaff homeassistant/components/worldclock/* @fabaff homeassistant/components/xbox_live/* @MartinHjelmare diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py new file mode 100644 index 00000000000..b037a0b7d21 --- /dev/null +++ b/homeassistant/components/wolflink/__init__.py @@ -0,0 +1,103 @@ +"""The Wolf SmartSet Service integration.""" +from _datetime import timedelta +import logging + +from httpcore import ConnectError +from wolf_smartset.token_auth import InvalidAuth +from wolf_smartset.wolf_client import WolfClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + COORDINATOR, + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, + PARAMETERS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Wolf SmartSet Service component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Wolf SmartSet Service from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + device_name = entry.data[DEVICE_NAME] + device_id = entry.data[DEVICE_ID] + gateway_id = entry.data[DEVICE_GATEWAY] + _LOGGER.debug( + "Setting up wolflink integration for device: %s (id: %s, gateway: %s)", + device_name, + device_id, + gateway_id, + ) + + wolf_client = WolfClient(username, password) + + parameters = await fetch_parameters(wolf_client, gateway_id, device_id) + + async def async_update_data(): + """Update all stored entities for Wolf SmartSet.""" + try: + values = await wolf_client.fetch_value(gateway_id, device_id, parameters) + return {v.value_id: v.value for v in values} + except ConnectError as exception: + raise UpdateFailed(f"Error communicating with API: {exception}") + except InvalidAuth: + raise UpdateFailed("Invalid authentication during update.") + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="wolflink", + update_method=async_update_data, + update_interval=timedelta(minutes=1), + ) + + await coordinator.async_refresh() + + hass.data[DOMAIN][entry.entry_id] = {} + hass.data[DOMAIN][entry.entry_id][PARAMETERS] = parameters + hass.data[DOMAIN][entry.entry_id][COORDINATOR] = coordinator + hass.data[DOMAIN][entry.entry_id][DEVICE_ID] = device_id + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "sensor") + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def fetch_parameters(client: WolfClient, gateway_id: int, device_id: int): + """ + Fetch all available parameters with usage of WolfClient. + + By default Reglertyp entity is removed because API will not provide value for this parameter. + """ + try: + fetched_parameters = await client.fetch_parameters(gateway_id, device_id) + return [param for param in fetched_parameters if param.name != "Reglertyp"] + except ConnectError as exception: + raise UpdateFailed(f"Error communicating with API: {exception}") + except InvalidAuth: + raise UpdateFailed("Invalid authentication during update.") diff --git a/homeassistant/components/wolflink/config_flow.py b/homeassistant/components/wolflink/config_flow.py new file mode 100644 index 00000000000..f54789cef78 --- /dev/null +++ b/homeassistant/components/wolflink/config_flow.py @@ -0,0 +1,93 @@ +"""Config flow for Wolf SmartSet Service integration.""" +import logging + +from httpcore import ConnectError +import voluptuous as vol +from wolf_smartset.token_auth import InvalidAuth +from wolf_smartset.wolf_client import WolfClient + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import ( # pylint:disable=unused-import + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Wolf SmartSet Service.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize with empty username and password.""" + self.username = None + self.password = None + self.fetched_systems = None + + async def async_step_user(self, user_input=None): + """Handle the initial step to get connection parameters.""" + errors = {} + if user_input is not None: + wolf_client = WolfClient( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + try: + self.fetched_systems = await wolf_client.fetch_system_list() + except ConnectError: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.username = user_input[CONF_USERNAME] + self.password = user_input[CONF_PASSWORD] + return await self.async_step_device() + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + async def async_step_device(self, user_input=None): + """Allow user to select device from devices connected to specified account.""" + errors = {} + if user_input is not None: + device_name = user_input[DEVICE_NAME] + system = [ + device for device in self.fetched_systems if device.name == device_name + ] + device_id = system[0].id + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[DEVICE_NAME], + data={ + CONF_USERNAME: self.username, + CONF_PASSWORD: self.password, + DEVICE_NAME: device_name, + DEVICE_GATEWAY: system[0].gateway, + DEVICE_ID: device_id, + }, + ) + + data_schema = vol.Schema( + { + vol.Required(DEVICE_NAME): vol.In( + [info.name for info in self.fetched_systems] + ) + } + ) + return self.async_show_form( + step_id="device", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/wolflink/const.py b/homeassistant/components/wolflink/const.py new file mode 100644 index 00000000000..ac5bbad48dc --- /dev/null +++ b/homeassistant/components/wolflink/const.py @@ -0,0 +1,93 @@ +"""Constants for the Wolf SmartSet Service integration.""" + +DOMAIN = "wolflink" + +COORDINATOR = "coordinator" +PARAMETERS = "parameters" +DEVICE_ID = "device_id" +DEVICE_GATEWAY = "device_gateway" +DEVICE_NAME = "device_name" + +STATES = { + "Ein": "ein", + "Deaktiviert": "deaktiviert", + "Aus": "aus", + "Standby": "standby", + "Auto": "auto", + "Permanent": "permanent", + "Initialisierung": "initialisierung", + "Antilegionellenfunktion": "antilegionellenfunktion", + "Fernschalter ein": "fernschalter_ein", + "1x Warmwasser": "1_x_warmwasser", + "Bereit, keine Ladung": "bereit_keine_ladung", + "Solarbetrieb": "solarbetrieb", + "Reduzierter Betrieb": "reduzierter_betrieb", + "SmartHome": "smart_home", + "SmartGrid": "smart_grid", + "Ruhekontakt": "ruhekontakt", + "Vorspülen": "vorspulen", + "Zünden": "zunden", + "Stabilisierung": "stabilisierung", + "Ventilprüfung": "ventilprufung", + "Nachspülen": "nachspulen", + "Softstart": "softstart", + "Taktsperre": "taktsperre", + "Betrieb ohne Brenner": "betrieb_ohne_brenner", + "Abgasklappe": "abgasklappe", + "Störung": "storung", + "Gradienten Überwachung": "gradienten_uberwachung", + "Gasdruck": "gasdruck", + "Spreizung hoch": "spreizung_hoch", + "Spreizung KF": "spreizung_kf", + "Test": "test", + "Start": "start", + "Frost Heizkreis": "frost_heizkreis", + "Frost Warmwasser": "frost_warmwasser", + "Schornsteinfeger": "schornsteinfeger", + "Kombibetrieb": "kombibetrieb", + "Parallelbetrieb": "parallelbetrieb", + "Warmwasserbetrieb": "warmwasserbetrieb", + "Warmwassernachlauf": "warmwassernachlauf", + "Mindest-Kombizeit": "mindest_kombizeit", + "Heizbetrieb": "heizbetrieb", + "Nachlauf Heizkreispumpe": "nachlauf_heizkreispumpe", + "Frostschutz": "frostschutz", + "Kaskadenbetrieb": "kaskadenbetrieb", + "GLT-Betrieb": "glt_betrieb", + "Kalibration": "kalibration", + "Kalibration Heizbetrieb": "kalibration_heizbetrieb", + "Kalibration Warmwasserbetrieb": "kalibration_warmwasserbetrieb", + "Kalibration Kombibetrieb": "kalibration_kombibetrieb", + "Warmwasser Schnellstart": "warmwasser_schnellstart", + "Externe Deaktivierung": "externe_deaktivierung", + "Heizung": "heizung", + "Warmwasser": "warmwasser", + "Kombigerät": "kombigerat", + "Kombigerät mit Solareinbindung": "kombigerat_mit_solareinbindung", + "Heizgerät mit Speicher": "heizgerat_mit_speicher", + "Nur Heizgerät": "nur_heizgerat", + "Aktiviert": "ktiviert", + "Sparen": "sparen", + "Estrichtrocknung": "estrichtrocknung", + "Telefonfernschalter": "telefonfernschalter", + "Partymodus": "partymodus", + "Urlaubsmodus": "urlaubsmodus", + "Automatik ein": "automatik_ein", + "Automatik aus": "automatik_aus", + "Permanentbetrieb": "permanentbetrieb", + "Sparbetrieb": "sparbetrieb", + "AutoOnCool": "auto_on_cool", + "AutoOffCool": "auto_off_cool", + "PermCooling": "perm_cooling", + "Absenkbetrieb": "absenkbetrieb", + "Eco": "eco", + "Absenkstop": "absenkstop", + "AT Abschaltung": "at_abschaltung", + "RT Abschaltung": "rt_abschaltung", + "AT Frostschutz": "at_frostschutz", + "RT Frostschutz": "rt_frostschutz", + "DHWPrior": "dhw_prior", + "Cooling": "cooling", + "TPW": "tpw", + "Warmwasservorrang": "warmwasservorrang", +} diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json new file mode 100644 index 00000000000..c188c090369 --- /dev/null +++ b/homeassistant/components/wolflink/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "wolflink", + "name": "Wolf SmartSet Service", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/wolflink", + "requirements": ["wolf_smartset==0.1.4"], + "codeowners": ["@adamkrol93"] +} diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py new file mode 100644 index 00000000000..a9deced9e91 --- /dev/null +++ b/homeassistant/components/wolflink/sensor.py @@ -0,0 +1,182 @@ +"""The Wolf SmartSet sensors.""" +import logging + +from wolf_smartset.models import ( + HoursParameter, + ListItemParameter, + Parameter, + PercentageParameter, + Pressure, + SimpleParameter, + Temperature, +) + +from homeassistant.components.wolflink.const import ( + COORDINATOR, + DEVICE_ID, + DOMAIN, + PARAMETERS, + STATES, +) +from homeassistant.const import ( + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_BAR, + TEMP_CELSIUS, + TIME_HOURS, +) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up all entries for Wolf Platform.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS] + device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID] + + entities = [] + for parameter in parameters: + if isinstance(parameter, Temperature): + entities.append(WolfLinkTemperature(coordinator, parameter, device_id)) + if isinstance(parameter, Pressure): + entities.append(WolfLinkPressure(coordinator, parameter, device_id)) + if isinstance(parameter, PercentageParameter): + entities.append(WolfLinkPercentage(coordinator, parameter, device_id)) + if isinstance(parameter, ListItemParameter): + entities.append(WolfLinkState(coordinator, parameter, device_id)) + if isinstance(parameter, HoursParameter): + entities.append(WolfLinkHours(coordinator, parameter, device_id)) + if isinstance(parameter, SimpleParameter): + entities.append(WolfLinkSensor(coordinator, parameter, device_id)) + + async_add_entities(entities, True) + + +class WolfLinkSensor(Entity): + """Base class for all Wolf entities.""" + + def __init__(self, coordinator, wolf_object: Parameter, device_id): + """Initialize.""" + self.coordinator = coordinator + self.wolf_object = wolf_object + self.device_id = device_id + + @property + def name(self): + """Return the name.""" + return f"{self.wolf_object.name}" + + @property + def state(self): + """Return the state.""" + return self.coordinator.data[self.wolf_object.value_id] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + "parameter_id": self.wolf_object.parameter_id, + "value_id": self.wolf_object.value_id, + "parent": self.wolf_object.parent, + } + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{self.device_id}:{self.wolf_object.parameter_id}" + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update the sensor.""" + await self.coordinator.async_request_refresh() + _LOGGER.debug("Updating %s", self.coordinator.data[self.wolf_object.value_id]) + + +class WolfLinkHours(WolfLinkSensor): + """Class for hour based entities.""" + + @property + def icon(self): + """Icon to display in the front Aend.""" + return "mdi:clock" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return TIME_HOURS + + +class WolfLinkTemperature(WolfLinkSensor): + """Class for temperature based entities.""" + + @property + def device_class(self): + """Return the device_class.""" + return DEVICE_CLASS_TEMPERATURE + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return TEMP_CELSIUS + + +class WolfLinkPressure(WolfLinkSensor): + """Class for pressure based entities.""" + + @property + def device_class(self): + """Return the device_class.""" + return DEVICE_CLASS_PRESSURE + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return PRESSURE_BAR + + +class WolfLinkPercentage(WolfLinkSensor): + """Class for percentage based entities.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self.wolf_object.unit + + +class WolfLinkState(WolfLinkSensor): + """Class for entities which has defined list of state.""" + + @property + def device_class(self): + """Return the device class.""" + return "wolflink__state" + + @property + def state(self): + """Return the state converting with supported values.""" + state = self.coordinator.data[self.wolf_object.value_id] + resolved_state = [ + item for item in self.wolf_object.items if item.value == int(state) + ] + if resolved_state: + resolved_name = resolved_state[0].name + return STATES.get(resolved_name, resolved_name) + return state diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json new file mode 100644 index 00000000000..4a98f93318f --- /dev/null +++ b/homeassistant/components/wolflink/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "WOLF SmartSet connection" + }, + "device": { + "data": { + "device_name": "Device" + }, + "title": "Select WOLF device" + } + } + } +} diff --git a/homeassistant/components/wolflink/strings.sensor.json b/homeassistant/components/wolflink/strings.sensor.json new file mode 100644 index 00000000000..2ce7df6fae5 --- /dev/null +++ b/homeassistant/components/wolflink/strings.sensor.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "ein": "Enabled", + "deaktiviert": "Inactive", + "aus": "Disabled", + "standby": "Standby", + "auto": "Auto", + "permanent": "Permament", + "initialisierung": "Initialization", + "antilegionellenfunktion": "Anti-legionella Function", + "fernschalter_ein": "Remote control enabled", + "1_x_warmwasser": "1 x DHW", + "bereit_keine_ladung": "Ready, not loading", + "solarbetrieb": "Solar mode", + "reduzierter_betrieb": "Limited mode", + "smart_home": "SmartHome", + "smart_grid": "SmartGrid", + "ruhekontakt": "Rest contact", + "vorspulen": "Entry rinsing", + "zunden": "Ignition", + "stabilisierung": "Stablization", + "ventilprufung": "Valve test", + "nachspulen": "Post-flush", + "softstart": "Soft start", + "taktsperre": "Anti-cycle", + "betrieb_ohne_brenner": "Working without burner", + "abgasklappe": "Flue gas damper", + "storung": "Fault", + "gradienten_uberwachung": "Gradient monitoring", + "gasdruck": "Gas pressure", + "spreizung_hoch": "dT too wide", + "spreizung_kf": "Spread KF", + "test": "Test", + "start": "Start", + "frost_heizkreis": "Heating circuit frost", + "frost_warmwasser": "DHW frost", + "schornsteinfeger": "Emissions test", + "kombibetrieb": "Combi mode", + "parallelbetrieb": "Parallel mode", + "warmwasserbetrieb": "DHW mode", + "warmwassernachlauf": "DHW run-on", + "heizbetrieb": "Heating mode", + "nachlauf_heizkreispumpe": "Heating circuit pump run-on", + "frostschutz": "Frost protection", + "kaskadenbetrieb": "Cascade operation", + "glt_betrieb": "BMS mode", + "kalibration": "Calibration", + "kalibration_heizbetrieb": "Heating mode calibration", + "kalibration_warmwasserbetrieb": "DHW calibration", + "kalibration_kombibetrieb": "Combi mode calibration", + "warmwasser_schnellstart": "DHW quick start", + "externe_deaktivierung": "External deactivation", + "heizung": "Heating", + "warmwasser": "DHW", + "kombigerat": "Combi boiler", + "kombigerat_mit_solareinbindung": "Combi boiler with solar integration", + "heizgerat_mit_speicher": "Boiler with cylinder", + "nur_heizgerat": "Boiler only", + "aktiviert": "Activated", + "sparen": "Economy", + "estrichtrocknung": "Screed drying", + "telefonfernschalter": "Telephone remote switch", + "partymodus": "Party mode", + "urlaubsmodus": "Holiday mode", + "automatik_ein": "Automatic ON", + "automatik_aus": "Automatic OFF", + "permanentbetrieb": "Permanent mode", + "sparbetrieb": "Economy mode", + "auto_on_cool": "AutoOnCool", + "auto_off_cool": "AutoOffCool", + "perm_cooling": "PermCooling", + "absenkbetrieb": "Setback mode", + "eco": "Eco", + "absenkstop": "Setback stop", + "at_abschaltung": "OT shutdown", + "rt_abschaltung": "RT shutdown", + "at_frostschutz": "OT frost protection", + "rt_frostschutz": "RT frost protection", + "dhw_prior": "DHWPrior", + "cooling": "Cooling", + "tpw": "TPW", + "warmwasservorrang": "DHW priority", + "mindest_kombizeit": "Minimum combi time" + } + } +} diff --git a/homeassistant/components/wolflink/translations/en.json b/homeassistant/components/wolflink/translations/en.json new file mode 100644 index 00000000000..3158df621fa --- /dev/null +++ b/homeassistant/components/wolflink/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password" + }, + "title": "WOLF SmartSet connection" + }, + "device": { + "data": { + "device_name": "Device" + }, + "title": "Select WOLF device" + } + }, + "title": "Wolf SmartSet Service" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c1dbd1d05c0..a1e062d4e28 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -186,6 +186,7 @@ FLOWS = [ "wiffi", "withings", "wled", + "wolflink", "xiaomi_aqara", "xiaomi_miio", "zerproc", diff --git a/requirements_all.txt b/requirements_all.txt index 767f5effad5..d21858d3dfa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2212,6 +2212,9 @@ withings-api==2.1.6 # homeassistant.components.wled wled==0.4.3 +# homeassistant.components.wolflink +wolf_smartset==0.1.4 + # homeassistant.components.xbee xbee-helper==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fc18960e2b..fe3741e3f76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -978,6 +978,9 @@ withings-api==2.1.6 # homeassistant.components.wled wled==0.4.3 +# homeassistant.components.wolflink +wolf_smartset==0.1.4 + # homeassistant.components.bluesound # homeassistant.components.rest # homeassistant.components.startca diff --git a/tests/components/wolflink/__init__.py b/tests/components/wolflink/__init__.py new file mode 100644 index 00000000000..dea7c5195ad --- /dev/null +++ b/tests/components/wolflink/__init__.py @@ -0,0 +1 @@ +"""Tests for the Wolf SmartSet Service integration.""" diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py new file mode 100644 index 00000000000..f2074f482eb --- /dev/null +++ b/tests/components/wolflink/test_config_flow.py @@ -0,0 +1,139 @@ +"""Test the Wolf SmartSet Service config flow.""" +from httpcore import ConnectError +from wolf_smartset.models import Device +from wolf_smartset.token_auth import InvalidAuth + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.wolflink.const import ( + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +CONFIG = { + DEVICE_NAME: "test-device", + DEVICE_ID: 1234, + DEVICE_GATEWAY: 5678, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +INPUT_CONFIG = { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], +} + +DEVICE = Device(CONFIG[DEVICE_ID], CONFIG[DEVICE_GATEWAY], CONFIG[DEVICE_NAME]) + + +async def test_show_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_device_step_form(hass): + """Test we get the second step of config.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + return_value=[DEVICE], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "device" + + +async def test_create_entry(hass): + """Test entity creation from device step.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + return_value=[DEVICE], + ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + result_create_entry = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device_name": CONFIG[DEVICE_NAME]}, + ) + + assert result_create_entry["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result_create_entry["title"] == CONFIG[DEVICE_NAME] + assert result_create_entry["data"] == CONFIG + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + side_effect=ConnectError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} + + +async def test_already_configured_error(hass): + """Test already configured while creating entry.""" + with patch( + "homeassistant.components.wolflink.config_flow.WolfClient.fetch_system_list", + return_value=[DEVICE], + ), patch("homeassistant.components.wolflink.async_setup_entry", return_value=True): + + MockConfigEntry( + domain=DOMAIN, unique_id=CONFIG[DEVICE_ID], data=CONFIG + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=INPUT_CONFIG + ) + + result_create_entry = await hass.config_entries.flow.async_configure( + result["flow_id"], {"device_name": CONFIG[DEVICE_NAME]}, + ) + + assert result_create_entry["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result_create_entry["reason"] == "already_configured" From 2a975db9cfa0580e93039760218770efc0f7448c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2020 11:58:22 +0200 Subject: [PATCH 060/362] Bump codecov/codecov-action from v1.0.10 to v1.0.11 (#38006) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from v1.0.10 to v1.0.11. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Commits](https://github.com/codecov/codecov-action/compare/v1.0.10...6d208f5b527841fb050f92f778e86cb808dacdcb) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a17a4dc318f..f5d80984806 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -781,4 +781,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.0.10 + uses: codecov/codecov-action@v1.0.11 From d5a03b4d6a53878e0f3713c274376ba476682507 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 20 Jul 2020 10:04:57 -0400 Subject: [PATCH 061/362] Cleanup async_accept_signal in ZHA (#38009) --- homeassistant/components/zha/binary_sensor.py | 2 +- homeassistant/components/zha/climate.py | 2 +- homeassistant/components/zha/cover.py | 6 +++--- homeassistant/components/zha/device_tracker.py | 2 +- homeassistant/components/zha/entity.py | 11 ++++++----- homeassistant/components/zha/fan.py | 2 +- homeassistant/components/zha/light.py | 6 +++--- homeassistant/components/zha/lock.py | 2 +- homeassistant/components/zha/sensor.py | 4 ++-- homeassistant/components/zha/switch.py | 2 +- 10 files changed, 20 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 0ed931e92da..2548060c0fd 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -80,7 +80,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() await self.get_device_class() - await self.async_accept_signal( + self.async_accept_signal( self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 7e2a0e147a7..7ffb727bacc 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -393,7 +393,7 @@ class Thermostat(ZhaEntity, ClimateEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated ) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 235368080f0..45114c677af 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -66,7 +66,7 @@ class ZhaCover(ZhaEntity, CoverEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._cover_channel, SIGNAL_ATTR_UPDATED, self.async_set_position ) @@ -213,10 +213,10 @@ class Shade(ZhaEntity, CoverEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_open_closed ) - await self.async_accept_signal( + self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.async_set_level ) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 2a53fc3bf3c..824435e6337 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -55,7 +55,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """Run when about to be added to hass.""" await super().async_added_to_hass() if self._battery_channel: - await self.async_accept_signal( + self.async_accept_signal( self._battery_channel, SIGNAL_ATTR_UPDATED, self.async_battery_percentage_remaining_updated, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 2deea13e08b..d583f89c9bc 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -114,7 +114,8 @@ class BaseZhaEntity(LogMixin, entity.Entity): unsub() self._unsubs.remove(unsub) - async def async_accept_signal( + @callback + def async_accept_signal( self, channel: ChannelType, signal: str, func: CALLABLE_T, signal_override=False ): """Accept a signal from a channel.""" @@ -162,7 +163,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" self.remove_future = asyncio.Future() - await self.async_accept_signal( + self.async_accept_signal( None, f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", self.async_remove, @@ -175,7 +176,7 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): if last_state: self.async_restore_last_state(last_state) - await self.async_accept_signal( + self.async_accept_signal( None, f"{self.zha_device.available_signal}_entity", self.async_state_changed, @@ -231,14 +232,14 @@ class ZhaGroupEntity(BaseZhaEntity): async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( None, f"{SIGNAL_REMOVE_GROUP}_0x{self._group_id:04x}", self.async_remove, signal_override=True, ) - await self.async_accept_signal( + self.async_accept_signal( None, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{self._group_id:04x}", self.async_remove, diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index c7a13a4f34f..ac8edd10be5 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -135,7 +135,7 @@ class ZhaFan(BaseFan, ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 6fefc795460..51b0633ecf2 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -372,18 +372,18 @@ class Light(BaseLight, ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) if self._level_channel: - await self.async_accept_signal( + self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.set_level ) refresh_interval = random.randint(*[x * 60 for x in self._REFRESH_INTERVAL]) self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) - await self.async_accept_signal( + self.async_accept_signal( None, SIGNAL_LIGHT_GROUP_STATE_CHANGED, self._maybe_force_refresh, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index d70c1e2e7f3..d951e7ada19 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -60,7 +60,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 86969c5fe96..38a9f19dce2 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -98,10 +98,10 @@ class Sensor(ZhaEntity): await super().async_added_to_hass() self._device_state_attributes.update(await self.async_state_attr_provider()) - await self.async_accept_signal( + self.async_accept_signal( self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) - await self.async_accept_signal( + self.async_accept_signal( self._channel, SIGNAL_STATE_ATTR, self.async_update_state_attribute ) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 9a7fc7aa6b0..07c40c7175e 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -92,7 +92,7 @@ class Switch(BaseSwitch, ZhaEntity): async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() - await self.async_accept_signal( + self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state ) From dd459a785536d3b328d2b8f6eaff8e47293754ec Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Mon, 20 Jul 2020 10:23:59 -0400 Subject: [PATCH 062/362] Fix issue with Insteon events not firing correctly (#37974) --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index d1a31117fb9..cdcd07a403b 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -2,6 +2,6 @@ "domain": "insteon", "name": "Insteon", "documentation": "https://www.home-assistant.io/integrations/insteon", - "requirements": ["pyinsteon==1.0.5"], + "requirements": ["pyinsteon==1.0.7"], "codeowners": ["@teharris1"] } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index d21858d3dfa..d3876d0757e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1386,7 +1386,7 @@ pyialarm==0.3 pyicloud==0.9.7 # homeassistant.components.insteon -pyinsteon==1.0.5 +pyinsteon==1.0.7 # homeassistant.components.intesishome pyintesishome==1.7.5 From 19870ea867b50c4c2aaaa8140f4c3cd30688f762 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 20 Jul 2020 13:35:30 -0500 Subject: [PATCH 063/362] Fix ozw color temp (#38012) * Fix color temp math * Ran black --fast * Update test_light.py * tweaking mireds * updating comments * fixing test_light to match standards * fixing comments, need coffee --- homeassistant/components/ozw/light.py | 15 ++++++++---- tests/components/ozw/test_light.py | 33 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ozw/light.py b/homeassistant/components/ozw/light.py index c985a5b7f41..d9e3ed2a51d 100644 --- a/homeassistant/components/ozw/light.py +++ b/homeassistant/components/ozw/light.py @@ -30,8 +30,8 @@ COLOR_CHANNEL_COLD_WHITE = 0x02 COLOR_CHANNEL_RED = 0x04 COLOR_CHANNEL_GREEN = 0x08 COLOR_CHANNEL_BLUE = 0x10 -TEMP_COLOR_MAX = 500 # mireds (inverted) -TEMP_COLOR_MIN = 154 +TEMP_COLOR_MAX = 500 # mired equivalent to 2000K +TEMP_COLOR_MIN = 154 # mired equivalent to 6500K TEMP_COLOR_DIFF = TEMP_COLOR_MAX - TEMP_COLOR_MIN @@ -193,10 +193,15 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): rgbw = f"#00000000{white:02x}" elif color_temp is not None: - cold = round((TEMP_COLOR_MAX - round(color_temp)) / TEMP_COLOR_DIFF * 255) + # Limit color temp to min/max values + cold = max( + 0, + min( + 255, + round((TEMP_COLOR_MAX - round(color_temp)) / TEMP_COLOR_DIFF * 255), + ), + ) warm = 255 - cold - if warm < 0: - warm = 0 rgbw = f"#000000{warm:02x}{cold:02x}" if rgbw and self.values.color: diff --git a/tests/components/ozw/test_light.py b/tests/components/ozw/test_light.py index 67eebdfdea7..c1d92688825 100644 --- a/tests/components/ozw/test_light.py +++ b/tests/components/ozw/test_light.py @@ -316,6 +316,39 @@ async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): assert state.state == "on" assert state.attributes["color_temp"] == 465 + # Test setting invalid color temp + new_color = 120 + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.led_bulb_6_multi_colour_level", "color_temp": new_color}, + blocking=True, + ) + assert len(sent_messages) == 19 + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": "#00000000ff", "ValueIDKey": 659341335} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = byte_to_zwave_brightness(255) + light_msg.encode() + light_rgb_msg.decode() + light_rgb_msg.payload["Value"] = "#00000000ff" + light_rgb_msg.encode() + receive_message(light_msg) + receive_message(light_rgb_msg) + await hass.async_block_till_done() + + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "on" + assert state.attributes["color_temp"] == 154 + async def test_no_rgb_light(hass, light_no_rgb_data, light_no_rgb_msg, sent_messages): """Test setting up config entry.""" From 59063a7d61a28decec67bdffbddac23beacc7fef Mon Sep 17 00:00:00 2001 From: Ryan <2199132+rsnodgrass@users.noreply.github.com> Date: Mon, 20 Jul 2020 14:07:36 -0700 Subject: [PATCH 064/362] Add scrape sensor name to logs (#38020) --- homeassistant/components/scrape/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 4119f7e4c6b..3d25e4a34ae 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -122,7 +122,7 @@ class ScrapeSensor(Entity): """Get the latest data from the source and updates the state.""" self.rest.update() if self.rest.data is None: - _LOGGER.error("Unable to retrieve data") + _LOGGER.error("Unable to retrieve data for %s", self.name) return raw_data = BeautifulSoup(self.rest.data, "html.parser") @@ -139,7 +139,7 @@ class ScrapeSensor(Entity): value = tag.text _LOGGER.debug(value) except IndexError: - _LOGGER.error("Unable to extract data from HTML") + _LOGGER.error("Unable to extract data from HTML for %s", self.name) return if self._value_template is not None: From 83d4e5bbb734f77701073710beb74dd6b524195e Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Tue, 21 Jul 2020 00:03:00 +0000 Subject: [PATCH 065/362] [ci skip] Translation update --- .../components/arcam_fmj/translations/pl.json | 3 + .../components/awair/translations/pl.json | 26 ++++++ .../components/bond/translations/pl.json | 17 ++++ .../components/control4/translations/ko.json | 31 +++++++ .../components/control4/translations/pl.json | 21 +++++ .../components/control4/translations/ru.json | 31 +++++++ .../components/control4/translations/sl.json | 31 +++++++ .../components/dexcom/translations/pl.json | 18 ++++ .../components/enocean/translations/pl.json | 3 + .../components/guardian/translations/pl.json | 1 + .../homekit_controller/translations/pl.json | 2 +- .../components/hue/translations/pl.json | 3 + .../humidifier/translations/sl.json | 14 +++ .../components/icloud/translations/pl.json | 2 +- .../components/mqtt/translations/pl.json | 4 +- .../components/netatmo/translations/sl.json | 7 ++ .../components/pi_hole/translations/ko.json | 3 +- .../components/pi_hole/translations/sl.json | 11 +++ .../plum_lightpad/translations/pl.json | 9 +- .../components/poolsense/translations/pl.json | 20 +++++ .../components/ps4/translations/pl.json | 2 +- .../components/smarthab/translations/pl.json | 7 +- .../components/sms/translations/pl.json | 12 +++ .../squeezebox/translations/pl.json | 19 +++- .../components/syncthru/translations/pl.json | 3 + .../components/tile/translations/pl.json | 2 +- .../components/toon/translations/pl.json | 2 + .../components/wolflink/translations/en.json | 49 +++++------ .../components/wolflink/translations/ko.json | 27 ++++++ .../components/wolflink/translations/pl.json | 20 +++++ .../wolflink/translations/sensor.bg.json | 14 +++ .../wolflink/translations/sensor.en.json | 87 +++++++++++++++++++ .../wolflink/translations/sensor.ru.json | 20 +++++ 33 files changed, 483 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/awair/translations/pl.json create mode 100644 homeassistant/components/bond/translations/pl.json create mode 100644 homeassistant/components/control4/translations/ko.json create mode 100644 homeassistant/components/control4/translations/pl.json create mode 100644 homeassistant/components/control4/translations/ru.json create mode 100644 homeassistant/components/control4/translations/sl.json create mode 100644 homeassistant/components/humidifier/translations/sl.json create mode 100644 homeassistant/components/pi_hole/translations/sl.json create mode 100644 homeassistant/components/poolsense/translations/pl.json create mode 100644 homeassistant/components/sms/translations/pl.json create mode 100644 homeassistant/components/wolflink/translations/ko.json create mode 100644 homeassistant/components/wolflink/translations/pl.json create mode 100644 homeassistant/components/wolflink/translations/sensor.bg.json create mode 100644 homeassistant/components/wolflink/translations/sensor.en.json create mode 100644 homeassistant/components/wolflink/translations/sensor.ru.json diff --git a/homeassistant/components/arcam_fmj/translations/pl.json b/homeassistant/components/arcam_fmj/translations/pl.json index 6a2c18cbd44..7b2d5da76e5 100644 --- a/homeassistant/components/arcam_fmj/translations/pl.json +++ b/homeassistant/components/arcam_fmj/translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/awair/translations/pl.json b/homeassistant/components/awair/translations/pl.json new file mode 100644 index 00000000000..07983402c42 --- /dev/null +++ b/homeassistant/components/awair/translations/pl.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane.", + "no_devices": "Nie znaleziono urz\u0105dze\u0144 w sieci.", + "reauth_successful": "Token dost\u0119pu pomy\u015blnie zaktualizowano." + }, + "error": { + "auth": "Token dost\u0119pu" + }, + "step": { + "reauth": { + "data": { + "access_token": "Token dost\u0119pu", + "email": "Adres e-mail" + } + }, + "user": { + "data": { + "access_token": "Token dost\u0119pu", + "email": "Adres e-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/pl.json b/homeassistant/components/bond/translations/pl.json new file mode 100644 index 00000000000..10b6433daee --- /dev/null +++ b/homeassistant/components/bond/translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "access_token": "Token dost\u0119pu", + "host": "Nazwa hosta lub adres IP" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/ko.json b/homeassistant/components/control4/translations/ko.json new file mode 100644 index 00000000000..ca36da40c18 --- /dev/null +++ b/homeassistant/components/control4/translations/ko.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "Control4 \uacc4\uc815 \uc138\ubd80 \uc815\ubcf4\uc640 \ub85c\uceec \ucee8\ud2b8\ub864\ub7ec\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\uc5c5\ub370\uc774\ud2b8 \uac04\uaca9(\ucd08)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/pl.json b/homeassistant/components/control4/translations/pl.json new file mode 100644 index 00000000000..615bf304fb4 --- /dev/null +++ b/homeassistant/components/control4/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "host": "Adres IP", + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::username%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/ru.json b/homeassistant/components/control4/translations/ru.json new file mode 100644 index 00000000000..315fbb7b3f3 --- /dev/null +++ b/homeassistant/components/control4/translations/ru.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "IP-\u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Control4 \u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0412\u0430\u0448\u0435\u0433\u043e \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u0430." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043c\u0435\u0436\u0434\u0443 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f\u043c\u0438 (\u0441\u0435\u043a.)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/sl.json b/homeassistant/components/control4/translations/sl.json new file mode 100644 index 00000000000..f259716cce2 --- /dev/null +++ b/homeassistant/components/control4/translations/sl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "host": "IP naslov", + "password": "Geslo", + "username": "Uporabni\u0161ko ime" + }, + "description": "Vnesite podatke o ra\u010dunu Control4 in IP naslov lokalnega regulatorja." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekunde med posodobitvami" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/dexcom/translations/pl.json b/homeassistant/components/dexcom/translations/pl.json index c3e4e95f47b..a35fc314e57 100644 --- a/homeassistant/components/dexcom/translations/pl.json +++ b/homeassistant/components/dexcom/translations/pl.json @@ -1,4 +1,22 @@ { + "config": { + "abort": { + "already_configured_account": "Konto jest ju\u017c skonfigurowane." + }, + "error": { + "account_error": "Niepoprawne uwierzytelnienie.", + "session_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::username%]" + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/enocean/translations/pl.json b/homeassistant/components/enocean/translations/pl.json index e4a7a36a3a2..5b4deff04bb 100644 --- a/homeassistant/components/enocean/translations/pl.json +++ b/homeassistant/components/enocean/translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, "flow_title": "Konfiguracja ENOcean", "step": { "detect": { diff --git a/homeassistant/components/guardian/translations/pl.json b/homeassistant/components/guardian/translations/pl.json index 61df3bfd913..22706a1babc 100644 --- a/homeassistant/components/guardian/translations/pl.json +++ b/homeassistant/components/guardian/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", "connection_error": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia z urz\u0105dzeniem Guardian, spr\u00f3buj ponownie." }, "step": { diff --git a/homeassistant/components/homekit_controller/translations/pl.json b/homeassistant/components/homekit_controller/translations/pl.json index 498d927ffb8..94e3422338b 100644 --- a/homeassistant/components/homekit_controller/translations/pl.json +++ b/homeassistant/components/homekit_controller/translations/pl.json @@ -7,7 +7,7 @@ "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", - "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144" + "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144." }, "error": { "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", diff --git a/homeassistant/components/hue/translations/pl.json b/homeassistant/components/hue/translations/pl.json index 80f5e31bc0e..01147d35663 100644 --- a/homeassistant/components/hue/translations/pl.json +++ b/homeassistant/components/hue/translations/pl.json @@ -26,6 +26,9 @@ "title": "Hub Link" }, "manual": { + "data": { + "host": "Nazwa hosta lub adres IP" + }, "title": "R\u0119czna konfiguracja mostu Hue" } } diff --git a/homeassistant/components/humidifier/translations/sl.json b/homeassistant/components/humidifier/translations/sl.json new file mode 100644 index 00000000000..141d98c38dd --- /dev/null +++ b/homeassistant/components/humidifier/translations/sl.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "condition_type": { + "is_mode": "{entity_name} je nastavljen na dolo\u010den na\u010din", + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} spremenjena ciljna vla\u017enost", + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/icloud/translations/pl.json b/homeassistant/components/icloud/translations/pl.json index 20e3f8c2fb4..74d9fa42c27 100644 --- a/homeassistant/components/icloud/translations/pl.json +++ b/homeassistant/components/icloud/translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane.", - "no_device": "\u017badne z Twoich urz\u0105dze\u0144 nie ma aktywowanej funkcji \"Znajd\u017a m\u00f3j iPhone\"" + "no_device": "\u017badne z Twoich urz\u0105dze\u0144 nie ma aktywowanej funkcji \"Znajd\u017a m\u00f3j iPhone\"." }, "error": { "login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o", diff --git a/homeassistant/components/mqtt/translations/pl.json b/homeassistant/components/mqtt/translations/pl.json index f5fe53d5fce..8c90db3e773 100644 --- a/homeassistant/components/mqtt/translations/pl.json +++ b/homeassistant/components/mqtt/translations/pl.json @@ -58,9 +58,9 @@ "broker": { "data": { "broker": "Po\u015brednik", - "password": "Has\u0142o", + "password": "[%key_id:common::config_flow::data::password%]", "port": "Port", - "username": "U\u017cytkownik" + "username": "[%key_id:common::config_flow::data::username%]" }, "description": "Wprowad\u017a informacje o po\u0142\u0105czeniu po\u015brednika MQTT" }, diff --git a/homeassistant/components/netatmo/translations/sl.json b/homeassistant/components/netatmo/translations/sl.json index 7a617d5f866..e9ec3bc8b97 100644 --- a/homeassistant/components/netatmo/translations/sl.json +++ b/homeassistant/components/netatmo/translations/sl.json @@ -13,5 +13,12 @@ "title": "Izberite medoto za preverjanje pristnosti" } } + }, + "options": { + "step": { + "public_weather_areas": { + "title": "Javni vremenski senzor Netatmo" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/ko.json b/homeassistant/components/pi_hole/translations/ko.json index 8d52c0fce2a..0f057e9c7be 100644 --- a/homeassistant/components/pi_hole/translations/ko.json +++ b/homeassistant/components/pi_hole/translations/ko.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "API \ud0a4 (\uc120\ud0dd \uc0ac\ud56d)", + "api_key": "API \ud0a4", "host": "\ud638\uc2a4\ud2b8", + "location": "\uc704\uce58", "name": "\uc774\ub984", "port": "\ud3ec\ud2b8", "ssl": "SSL \uc0ac\uc6a9", diff --git a/homeassistant/components/pi_hole/translations/sl.json b/homeassistant/components/pi_hole/translations/sl.json new file mode 100644 index 00000000000..cd46d19f38c --- /dev/null +++ b/homeassistant/components/pi_hole/translations/sl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location": "Lokacija" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plum_lightpad/translations/pl.json b/homeassistant/components/plum_lightpad/translations/pl.json index 063db5c268a..121744d0f0d 100644 --- a/homeassistant/components/plum_lightpad/translations/pl.json +++ b/homeassistant/components/plum_lightpad/translations/pl.json @@ -1,9 +1,16 @@ { "config": { + "abort": { + "already_configured": "Us\u0142uga jest ju\u017c skonfigurowana." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia." + }, "step": { "user": { "data": { - "password": "Has\u0142o" + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::email%]" } } } diff --git a/homeassistant/components/poolsense/translations/pl.json b/homeassistant/components/poolsense/translations/pl.json new file mode 100644 index 00000000000..29d7011f0c1 --- /dev/null +++ b/homeassistant/components/poolsense/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "[%key_id:common::config_flow::data::password%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/pl.json b/homeassistant/components/ps4/translations/pl.json index 8cbdfe3d0b7..01698f26554 100644 --- a/homeassistant/components/ps4/translations/pl.json +++ b/homeassistant/components/ps4/translations/pl.json @@ -3,7 +3,7 @@ "abort": { "credential_error": "B\u0142\u0105d podczas pobierania danych logowania.", "devices_configured": "Wszystkie znalezione urz\u0105dzenia s\u0105 ju\u017c skonfigurowane.", - "no_devices_found": "W sieci nie znaleziono urz\u0105dze\u0144 PlayStation 4.", + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 PlayStation 4.", "port_987_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 987.", "port_997_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 997." }, diff --git a/homeassistant/components/smarthab/translations/pl.json b/homeassistant/components/smarthab/translations/pl.json index 09cd3af488a..7279eb6ca79 100644 --- a/homeassistant/components/smarthab/translations/pl.json +++ b/homeassistant/components/smarthab/translations/pl.json @@ -2,13 +2,14 @@ "config": { "error": { "service": "B\u0142\u0105d podczas pr\u00f3by osi\u0105gni\u0119cia SmartHab. Us\u0142uga mo\u017ce by\u0107 wy\u0142\u0105czna. Sprawd\u017a po\u0142\u0105czenie.", - "unknown_error": "Niespodziewany b\u0142\u0105d", - "wrong_login": "Niepoprawna autoryzacja" + "unknown_error": "Nieoczekiwany b\u0142\u0105d.", + "wrong_login": "Niepoprawne uwierzytelnienie." }, "step": { "user": { "data": { - "password": "Has\u0142o" + "email": "Adres e-mail", + "password": "[%key_id:common::config_flow::data::password%]" }, "title": "Konfiguracja SmartHab" } diff --git a/homeassistant/components/sms/translations/pl.json b/homeassistant/components/sms/translations/pl.json new file mode 100644 index 00000000000..e315dd7c5bf --- /dev/null +++ b/homeassistant/components/sms/translations/pl.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/squeezebox/translations/pl.json b/homeassistant/components/squeezebox/translations/pl.json index a7f144f59e9..a4339711918 100644 --- a/homeassistant/components/squeezebox/translations/pl.json +++ b/homeassistant/components/squeezebox/translations/pl.json @@ -1,10 +1,25 @@ { "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "Nieoczekiwany b\u0142\u0105d." + }, "step": { "edit": { "data": { - "password": "Has\u0142o", - "port": "Port" + "host": "Nazwa hosta lub adres IP", + "password": "[%key_id:common::config_flow::data::password%]", + "port": "Port", + "username": "[%key_id:common::config_flow::data::username%]" + } + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" } } } diff --git a/homeassistant/components/syncthru/translations/pl.json b/homeassistant/components/syncthru/translations/pl.json index 63dea2d9184..bd174d000c8 100644 --- a/homeassistant/components/syncthru/translations/pl.json +++ b/homeassistant/components/syncthru/translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, "error": { "invalid_url": "Nieprawid\u0142owy URL", "unknown_state": "Nieznany stan drukarki, sprawd\u017a adres URL i \u0142\u0105czno\u015b\u0107 sieciow\u0105" diff --git a/homeassistant/components/tile/translations/pl.json b/homeassistant/components/tile/translations/pl.json index b8b737c37a3..09fbdc93241 100644 --- a/homeassistant/components/tile/translations/pl.json +++ b/homeassistant/components/tile/translations/pl.json @@ -4,7 +4,7 @@ "user": { "data": { "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika" + "username": "Adres e-mail" } } } diff --git a/homeassistant/components/toon/translations/pl.json b/homeassistant/components/toon/translations/pl.json index 40bf4e2015a..43dc3d635c4 100644 --- a/homeassistant/components/toon/translations/pl.json +++ b/homeassistant/components/toon/translations/pl.json @@ -1,8 +1,10 @@ { "config": { "abort": { + "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.", "client_id": "Identyfikator klienta z konfiguracji jest nieprawid\u0142owy.", "client_secret": "Tajny klucz klienta z konfiguracji jest nieprawid\u0142owy.", + "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon.", "no_app": "Musisz skonfigurowa\u0107 Toon, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Zapoznaj si\u0119 z [instrukcj\u0105](https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "Nieoczekiwany b\u0142\u0105d podczas uwierzytelniania." diff --git a/homeassistant/components/wolflink/translations/en.json b/homeassistant/components/wolflink/translations/en.json index 3158df621fa..18148bea38a 100644 --- a/homeassistant/components/wolflink/translations/en.json +++ b/homeassistant/components/wolflink/translations/en.json @@ -1,28 +1,27 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect, please try again", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "username": "Username", - "password": "Password" + "config": { + "abort": { + "already_configured": "Device is already configured" }, - "title": "WOLF SmartSet connection" - }, - "device": { - "data": { - "device_name": "Device" + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" }, - "title": "Select WOLF device" - } - }, - "title": "Wolf SmartSet Service" - } -} + "step": { + "device": { + "data": { + "device_name": "Device" + }, + "title": "Select WOLF device" + }, + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "WOLF SmartSet connection" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/ko.json b/homeassistant/components/wolflink/translations/ko.json new file mode 100644 index 00000000000..614604a56c1 --- /dev/null +++ b/homeassistant/components/wolflink/translations/ko.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", + "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "device": { + "data": { + "device_name": "\uae30\uae30" + }, + "title": "WOLF \uae30\uae30 \uc120\ud0dd\ud558\uae30" + }, + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "title": "WOLF SmartSet \uc5f0\uacb0" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/pl.json b/homeassistant/components/wolflink/translations/pl.json new file mode 100644 index 00000000000..c59e4297382 --- /dev/null +++ b/homeassistant/components/wolflink/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_auth": "Niepoprawne uwierzytelnienie.", + "unknown": "[%key::common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key_id:common::config_flow::data::password%]", + "username": "[%key_id:common::config_flow::data::username%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.bg.json b/homeassistant/components/wolflink/translations/sensor.bg.json new file mode 100644 index 00000000000..d9a9400e4d5 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.bg.json @@ -0,0 +1,14 @@ +{ + "state": { + "wolflink__state": { + "test": "\u0422\u0435\u0441\u0442", + "tpw": "TPW", + "urlaubsmodus": "\u0412\u0430\u043a\u0430\u043d\u0446\u0438\u043e\u043d\u0435\u043d \u0440\u0435\u0436\u0438\u043c", + "ventilprufung": "\u0422\u0435\u0441\u0442 \u043d\u0430 \u043a\u043b\u0430\u043f\u0430\u043d\u0430", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW \u0431\u044a\u0440\u0437 \u0441\u0442\u0430\u0440\u0442", + "warmwasserbetrieb": "\u0420\u0435\u0436\u0438\u043c \u043d\u0430 DHW", + "warmwasservorrang": "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u043d\u0430 DHW" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.en.json b/homeassistant/components/wolflink/translations/sensor.en.json new file mode 100644 index 00000000000..ea60e233907 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.en.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "Flue gas damper", + "absenkbetrieb": "Setback mode", + "absenkstop": "Setback stop", + "aktiviert": "Activated", + "antilegionellenfunktion": "Anti-legionella Function", + "at_abschaltung": "OT shutdown", + "at_frostschutz": "OT frost protection", + "aus": "Disabled", + "auto": "Auto", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatic OFF", + "automatik_ein": "Automatic ON", + "bereit_keine_ladung": "Ready, not loading", + "betrieb_ohne_brenner": "Working without burner", + "cooling": "Cooling", + "deaktiviert": "Inactive", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "Enabled", + "estrichtrocknung": "Screed drying", + "externe_deaktivierung": "External deactivation", + "fernschalter_ein": "Remote control enabled", + "frost_heizkreis": "Heating circuit frost", + "frost_warmwasser": "DHW frost", + "frostschutz": "Frost protection", + "gasdruck": "Gas pressure", + "glt_betrieb": "BMS mode", + "gradienten_uberwachung": "Gradient monitoring", + "heizbetrieb": "Heating mode", + "heizgerat_mit_speicher": "Boiler with cylinder", + "heizung": "Heating", + "initialisierung": "Initialization", + "kalibration": "Calibration", + "kalibration_heizbetrieb": "Heating mode calibration", + "kalibration_kombibetrieb": "Combi mode calibration", + "kalibration_warmwasserbetrieb": "DHW calibration", + "kaskadenbetrieb": "Cascade operation", + "kombibetrieb": "Combi mode", + "kombigerat": "Combi boiler", + "kombigerat_mit_solareinbindung": "Combi boiler with solar integration", + "mindest_kombizeit": "Minimum combi time", + "nachlauf_heizkreispumpe": "Heating circuit pump run-on", + "nachspulen": "Post-flush", + "nur_heizgerat": "Boiler only", + "parallelbetrieb": "Parallel mode", + "partymodus": "Party mode", + "perm_cooling": "PermCooling", + "permanent": "Permament", + "permanentbetrieb": "Permanent mode", + "reduzierter_betrieb": "Limited mode", + "rt_abschaltung": "RT shutdown", + "rt_frostschutz": "RT frost protection", + "ruhekontakt": "Rest contact", + "schornsteinfeger": "Emissions test", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Soft start", + "solarbetrieb": "Solar mode", + "sparbetrieb": "Economy mode", + "sparen": "Economy", + "spreizung_hoch": "dT too wide", + "spreizung_kf": "Spread KF", + "stabilisierung": "Stablization", + "standby": "Standby", + "start": "Start", + "storung": "Fault", + "taktsperre": "Anti-cycle", + "telefonfernschalter": "Telephone remote switch", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Holiday mode", + "ventilprufung": "Valve test", + "vorspulen": "Entry rinsing", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW quick start", + "warmwasserbetrieb": "DHW mode", + "warmwassernachlauf": "DHW run-on", + "warmwasservorrang": "DHW priority", + "zunden": "Ignition" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ru.json b/homeassistant/components/wolflink/translations/sensor.ru.json new file mode 100644 index 00000000000..218b962a096 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ru.json @@ -0,0 +1,20 @@ +{ + "state": { + "wolflink__state": { + "stabilisierung": "\u0421\u0442\u0430\u0431\u0438\u043b\u0438\u0437\u0430\u0446\u0438\u044f", + "standby": "\u041e\u0436\u0438\u0434\u0430\u043d\u0438\u0435", + "start": "\u0417\u0430\u043f\u0443\u0441\u043a", + "storung": "\u041e\u0448\u0438\u0431\u043a\u0430", + "taktsperre": "\u0410\u043d\u0442\u0438-\u0446\u0438\u043a\u043b", + "test": "\u0422\u0435\u0441\u0442", + "ventilprufung": "\u0422\u0435\u0441\u0442 \u043a\u043b\u0430\u043f\u0430\u043d\u0430", + "vorspulen": "\u041f\u0440\u043e\u043c\u044b\u0432\u043a\u0430 \u0432\u0445\u043e\u0434\u0430", + "warmwasser": "\u0413\u0412\u0421", + "warmwasser_schnellstart": "\u0411\u044b\u0441\u0442\u0440\u044b\u0439 \u0437\u0430\u043f\u0443\u0441\u043a \u0413\u0412\u0421", + "warmwasserbetrieb": "\u0420\u0435\u0436\u0438\u043c \u0413\u0412\u0421", + "warmwassernachlauf": "\u0417\u0430\u043f\u0443\u0441\u043a \u0413\u0412\u0421", + "warmwasservorrang": "\u041f\u0440\u0438\u043e\u0440\u0438\u0442\u0435\u0442 \u0413\u0412\u0421", + "zunden": "\u0417\u0430\u0436\u0438\u0433\u0430\u043d\u0438\u0435" + } + } +} \ No newline at end of file From 7bc8caca9626156b30d1e179a8833f1be88b92fe Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Mon, 20 Jul 2020 22:00:11 -0700 Subject: [PATCH 066/362] Check if robot has boundaries to update (#38030) --- homeassistant/components/neato/vacuum.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 2f5703615b6..841b160ad30 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -264,12 +264,13 @@ class NeatoConnectedVacuum(StateVacuumEntity): maps["name"], robot_boundaries, ) - self._robot_boundaries += robot_boundaries["data"]["boundaries"] - _LOGGER.debug( - "List of boundaries for '%s': %s", - self.entity_id, - self._robot_boundaries, - ) + if "boundaries" in robot_boundaries["data"]: + self._robot_boundaries += robot_boundaries["data"]["boundaries"] + _LOGGER.debug( + "List of boundaries for '%s': %s", + self.entity_id, + self._robot_boundaries, + ) @property def name(self): From 60009ec2f96001fd95713f0a835feac77513d156 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jul 2020 20:18:31 -1000 Subject: [PATCH 067/362] Use event loop scheduling for tracking time patterns (#38021) * Use event loop scheduling for tracking time patterns * make patching of time targetable * patch time tests since time can tick to match during the test * fix more tests * time can only move forward * time can only move forward * back to 100% coverage * simplify since the event loop time cannot move backwards * simplify some more * revert simplify * Revert "revert simplify" This reverts commit bd42f232f6f4e0876fbfe7ce5abf363779761acd. * Revert "simplify some more" This reverts commit 2a6c57d51487d4fc85140033a5cadbc800e61287. * Revert "simplify since the event loop time cannot move backwards" This reverts commit 3b13714ef489b5c5661ae0254b12f137d047fc26. * Attempt another simplify * time does not move backwards in the last two * remove next_time <= now check * fix previous merge error --- homeassistant/helpers/event.py | 44 ++- tests/common.py | 12 +- tests/components/automation/test_time.py | 6 +- .../automation/test_time_pattern.py | 322 +++++++++++------- tests/components/recorder/test_init.py | 16 +- tests/components/utility_meter/test_sensor.py | 18 +- tests/components/zwave/test_init.py | 2 +- tests/conftest.py | 64 ++++ tests/helpers/test_event.py | 119 +++++-- 9 files changed, 404 insertions(+), 199 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index ecbf88d67a9..3f0c2db3b2f 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,4 +1,5 @@ """Helpers for listening to events.""" +import asyncio from datetime import datetime, timedelta import functools as ft import logging @@ -563,6 +564,9 @@ def async_track_sunset( track_sunset = threaded_listener_factory(async_track_sunset) +# For targeted patching in tests +pattern_utc_now = dt_util.utcnow + @callback @bind_hass @@ -590,7 +594,7 @@ def async_track_utc_time_change( matching_minutes = dt_util.parse_time_expression(minute, 0, 59) matching_hours = dt_util.parse_time_expression(hour, 0, 23) - next_time = None + next_time: datetime = dt_util.utcnow() def calculate_next(now: datetime) -> None: """Calculate and set the next time the trigger should fire.""" @@ -603,29 +607,37 @@ def async_track_utc_time_change( # Make sure rolling back the clock doesn't prevent the timer from # triggering. - last_now: Optional[datetime] = None + cancel_callback: Optional[asyncio.TimerHandle] = None + calculate_next(next_time) @callback - def pattern_time_change_listener(event: Event) -> None: + def pattern_time_change_listener() -> None: """Listen for matching time_changed events.""" - nonlocal next_time, last_now + nonlocal next_time, cancel_callback - now = event.data[ATTR_NOW] + now = pattern_utc_now() + hass.async_run_job(action, dt_util.as_local(now) if local else now) - if last_now is None or now < last_now: - # Time rolled back or next time not yet calculated - calculate_next(now) + calculate_next(now + timedelta(seconds=1)) - last_now = now + cancel_callback = hass.loop.call_at( + hass.loop.time() + next_time.timestamp() - time.time(), + pattern_time_change_listener, + ) - if next_time <= now: - hass.async_run_job(action, dt_util.as_local(now) if local else now) - calculate_next(now + timedelta(seconds=1)) + cancel_callback = hass.loop.call_at( + hass.loop.time() + next_time.timestamp() - time.time(), + pattern_time_change_listener, + ) - # We can't use async_track_point_in_utc_time here because it would - # break in the case that the system time abruptly jumps backwards. - # Our custom last_now logic takes care of resolving that scenario. - return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener) + @callback + def unsub_pattern_time_change_listener() -> None: + """Cancel the call_later.""" + nonlocal cancel_callback + assert cancel_callback is not None + cancel_callback.cancel() + + return unsub_pattern_time_change_listener track_utc_time_change = threaded_listener_factory(async_track_utc_time_change) diff --git a/tests/common.py b/tests/common.py index 5fa2ba59ed1..bcb66428f6b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -285,7 +285,7 @@ fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message) @ha.callback -def async_fire_time_changed(hass, datetime_): +def async_fire_time_changed(hass, datetime_, fire_all=False): """Fire a time changes event.""" hass.bus.async_fire(EVENT_TIME_CHANGED, {"now": date_util.as_utc(datetime_)}) @@ -298,9 +298,13 @@ def async_fire_time_changed(hass, datetime_): future_seconds = task.when() - hass.loop.time() mock_seconds_into_future = datetime_.timestamp() - time.time() - if mock_seconds_into_future >= future_seconds: - task._run() - task.cancel() + if fire_all or mock_seconds_into_future >= future_seconds: + with patch( + "homeassistant.helpers.event.pattern_utc_now", + return_value=date_util.as_utc(datetime_), + ): + task._run() + task.cancel() fire_time_changed = threadsafe_callback_factory(async_fire_time_changed) diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 0ba85467fcd..81f1657e0a2 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -46,7 +46,11 @@ async def test_if_fires_using_at(hass, calls): }, ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=5, minute=0, second=0)) + now = dt_util.utcnow() + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0) + ) await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/automation/test_time_pattern.py b/tests/components/automation/test_time_pattern.py index 01aa32f318f..b5141f088e4 100644 --- a/tests/components/automation/test_time_pattern.py +++ b/tests/components/automation/test_time_pattern.py @@ -1,4 +1,5 @@ """The tests for the time_pattern automation.""" +from asynctest.mock import patch import pytest import homeassistant.components.automation as automation @@ -23,53 +24,67 @@ def setup_comp(hass): async def test_if_fires_when_hour_matches(hass, calls): """Test for firing if hour is matching.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, hour=3 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() assert len(calls) == 1 await common.async_turn_off(hass) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 1, hour=0)) await hass.async_block_till_done() assert len(calls) == 1 async def test_if_fires_when_minute_matches(hass, calls): """Test for firing if minutes are matching.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, minute=30 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(minute=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -77,23 +92,30 @@ async def test_if_fires_when_minute_matches(hass, calls): async def test_if_fires_when_second_matches(hass, calls): """Test for firing if seconds are matching.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, second=30 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(second=0)) + async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) await hass.async_block_till_done() assert len(calls) == 1 @@ -101,23 +123,32 @@ async def test_if_fires_when_second_matches(hass, calls): async def test_if_fires_when_all_matches(hass, calls): """Test for firing if everything matches.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, hour=4 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=3)) + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -125,47 +156,66 @@ async def test_if_fires_when_all_matches(hass, calls): async def test_if_fires_periodic_seconds(hass, calls): """Test for firing periodically every second.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/2", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, second=1 + ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0, minute=0, second=2)) - await hass.async_block_till_done() - assert len(calls) == 1 + assert len(calls) >= 1 async def test_if_fires_periodic_minutes(hass, calls): """Test for firing periodically every minute.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0, minute=2, second=0)) + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, minute=1 + ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -173,23 +223,32 @@ async def test_if_fires_periodic_minutes(hass, calls): async def test_if_fires_periodic_hours(hass, calls): """Test for firing periodically every hour.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, hour=1 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=2, minute=0, second=0)) + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) + ) await hass.async_block_till_done() assert len(calls) == 1 @@ -197,28 +256,41 @@ async def test_if_fires_periodic_hours(hass, calls): async def test_default_values(hass, calls): """Test for firing at 2 minutes every hour.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, + now = dt_util.utcnow() + time_that_will_not_match_right_away = dt_util.utcnow().replace( + year=now.year + 1, minute=1 + ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) ) - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=0)) + await hass.async_block_till_done() + assert len(calls) == 1 + + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=1, minute=2, second=1) + ) await hass.async_block_till_done() assert len(calls) == 1 - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=2, second=1)) - - await hass.async_block_till_done() - assert len(calls) == 1 - - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=2, minute=2, second=0)) + async_fire_time_changed( + hass, now.replace(year=now.year + 2, hour=2, minute=2, second=0) + ) await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 46db4782628..a4f70fc09a6 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -17,14 +17,18 @@ from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import Events, RecorderRuns, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import MATCH_ALL, STATE_LOCKED, STATE_UNLOCKED -from homeassistant.core import ATTR_NOW, EVENT_TIME_CHANGED, Context, callback +from homeassistant.core import Context, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from .common import wait_recording_done from tests.async_mock import patch -from tests.common import get_test_home_assistant, init_recorder_component +from tests.common import ( + async_fire_time_changed, + get_test_home_assistant, + init_recorder_component, +) class TestRecorder(unittest.TestCase): @@ -335,15 +339,15 @@ def test_auto_purge(hass_recorder): tz = dt_util.get_time_zone("Europe/Copenhagen") dt_util.set_default_time_zone(tz) - test_time = tz.localize(datetime(2020, 1, 1, 4, 12, 0)) + now = dt_util.utcnow() + test_time = tz.localize(datetime(now.year + 1, 1, 1, 4, 12, 0)) + async_fire_time_changed(hass, test_time) with patch( "homeassistant.components.recorder.purge.purge_old_data", return_value=True ) as purge_old_data: for delta in (-1, 0, 1): - hass.bus.fire( - EVENT_TIME_CHANGED, {ATTR_NOW: test_time + timedelta(seconds=delta)} - ) + async_fire_time_changed(hass, test_time + timedelta(seconds=delta)) hass.block_till_done() hass.data[DATA_INSTANCE].block_till_done() diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 09145fc4e4e..c1613c53a20 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -260,49 +260,49 @@ async def _test_self_reset(hass, config, start_time, expect_reset=True): assert state.state == "5" -async def test_self_reset_hourly(hass): +async def test_self_reset_hourly(hass, legacy_patchable_time): """Test hourly reset of meter.""" await _test_self_reset( hass, gen_config("hourly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_daily(hass): +async def test_self_reset_daily(hass, legacy_patchable_time): """Test daily reset of meter.""" await _test_self_reset( hass, gen_config("daily"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_weekly(hass): +async def test_self_reset_weekly(hass, legacy_patchable_time): """Test weekly reset of meter.""" await _test_self_reset( hass, gen_config("weekly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_monthly(hass): +async def test_self_reset_monthly(hass, legacy_patchable_time): """Test monthly reset of meter.""" await _test_self_reset( hass, gen_config("monthly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_reset_quarterly(hass): +async def test_self_reset_quarterly(hass, legacy_patchable_time): """Test quarterly reset of meter.""" await _test_self_reset( hass, gen_config("quarterly"), "2017-03-31T23:59:00.000000+00:00" ) -async def test_self_reset_yearly(hass): +async def test_self_reset_yearly(hass, legacy_patchable_time): """Test yearly reset of meter.""" await _test_self_reset( hass, gen_config("yearly"), "2017-12-31T23:59:00.000000+00:00" ) -async def test_self_no_reset_yearly(hass): +async def test_self_no_reset_yearly(hass, legacy_patchable_time): """Test yearly reset of meter does not occur after 1st January.""" await _test_self_reset( hass, @@ -312,7 +312,7 @@ async def test_self_no_reset_yearly(hass): ) -async def test_reset_yearly_offset(hass): +async def test_reset_yearly_offset(hass, legacy_patchable_time): """Test yearly reset of meter.""" await _test_self_reset( hass, @@ -321,7 +321,7 @@ async def test_reset_yearly_offset(hass): ) -async def test_no_reset_yearly_offset(hass): +async def test_no_reset_yearly_offset(hass, legacy_patchable_time): """Test yearly reset of meter.""" await _test_self_reset( hass, diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index d1f141582ca..12b2c59ca81 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -119,7 +119,7 @@ async def test_erronous_network_key_fails_validation(hass, mock_openzwave): zwave.CONFIG_SCHEMA({"zwave": {"network_key": value}}) -async def test_auto_heal_midnight(hass, mock_openzwave): +async def test_auto_heal_midnight(hass, mock_openzwave, legacy_patchable_time): """Test network auto-heal at midnight.""" await async_setup_component(hass, "zwave", {"zwave": {"autoheal": True}}) await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index c3f600f9693..5c90dcb063e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Set up some common test helper things.""" import asyncio +import datetime import functools import logging import threading @@ -387,8 +388,71 @@ def legacy_patchable_time(): return async_unsub + @ha.callback + @loader.bind_hass + def async_track_utc_time_change( + hass, action, hour=None, minute=None, second=None, local=False + ): + """Add a listener that will fire if time matches a pattern.""" + # We do not have to wrap the function with time pattern matching logic + # if no pattern given + if all(val is None for val in (hour, minute, second)): + + @ha.callback + def time_change_listener(ev) -> None: + """Fire every time event that comes in.""" + hass.async_run_job(action, ev.data[ATTR_NOW]) + + return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener) + + matching_seconds = event.dt_util.parse_time_expression(second, 0, 59) + matching_minutes = event.dt_util.parse_time_expression(minute, 0, 59) + matching_hours = event.dt_util.parse_time_expression(hour, 0, 23) + + next_time = None + + def calculate_next(now) -> None: + """Calculate and set the next time the trigger should fire.""" + nonlocal next_time + + localized_now = event.dt_util.as_local(now) if local else now + next_time = event.dt_util.find_next_time_expression_time( + localized_now, matching_seconds, matching_minutes, matching_hours + ) + + # Make sure rolling back the clock doesn't prevent the timer from + # triggering. + last_now = None + + @ha.callback + def pattern_time_change_listener(ev) -> None: + """Listen for matching time_changed events.""" + nonlocal next_time, last_now + + now = ev.data[ATTR_NOW] + + if last_now is None or now < last_now: + # Time rolled back or next time not yet calculated + calculate_next(now) + + last_now = now + + if next_time <= now: + hass.async_run_job( + action, event.dt_util.as_local(now) if local else now + ) + calculate_next(now + datetime.timedelta(seconds=1)) + + # We can't use async_track_point_in_utc_time here because it would + # break in the case that the system time abruptly jumps backwards. + # Our custom last_now logic takes care of resolving that scenario. + return hass.bus.async_listen(EVENT_TIME_CHANGED, pattern_time_change_listener) + with patch( "homeassistant.helpers.event.async_track_point_in_utc_time", async_track_point_in_utc_time, + ), patch( + "homeassistant.helpers.event.async_track_utc_time_change", + async_track_utc_time_change, ): yield diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 99b4cad6eca..784bb673f77 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -748,22 +748,24 @@ async def test_async_track_time_change(hass): wildcard_runs = [] specific_runs = [] + now = dt_util.utcnow() + unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(1)) unsub_utc = async_track_utc_time_change( hass, lambda x: specific_runs.append(1), second=[0, 30] ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 15)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 15)) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30)) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -771,7 +773,7 @@ async def test_async_track_time_change(hass): unsub() unsub_utc() - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30)) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -781,25 +783,27 @@ async def test_periodic_task_minute(hass): """Test periodic tasks per minute.""" specific_runs = [] + now = dt_util.utcnow() + unsub = async_track_utc_time_change( hass, lambda x: specific_runs.append(1), minute="/5", second=0 ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 3, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 3, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0)) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0)) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -808,33 +812,35 @@ async def test_periodic_task_hour(hass): """Test periodic tasks per hour.""" specific_runs = [] + now = dt_util.utcnow() + unsub = async_track_utc_time_change( hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 25, 1, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 1, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 3 unsub() - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 3 @@ -843,12 +849,14 @@ async def test_periodic_task_wrong_input(hass): """Test periodic tasks with wrong input.""" specific_runs = [] + now = dt_util.utcnow() + with pytest.raises(ValueError): async_track_utc_time_change( hass, lambda x: specific_runs.append(1), hour="/two" ) - async_fire_time_changed(hass, datetime(2014, 5, 2, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 2, 0, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 0 @@ -857,33 +865,37 @@ async def test_periodic_task_clock_rollback(hass): """Test periodic tasks with the time rolling backwards.""" specific_runs = [] + now = dt_util.utcnow() + unsub = async_track_utc_time_change( hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0), fire_all=True + ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(2014, 5, 24, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 0, 0, 0), fire_all=True) await hass.async_block_till_done() assert len(specific_runs) == 3 - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 4 unsub() - async_fire_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 4 @@ -892,19 +904,21 @@ async def test_periodic_task_duplicate_time(hass): """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] + now = dt_util.utcnow() + unsub = async_track_utc_time_change( hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 ) - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0)) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -917,23 +931,39 @@ async def test_periodic_task_entering_dst(hass): dt_util.set_default_time_zone(timezone) specific_runs = [] - unsub = async_track_time_change( - hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + now = dt_util.utcnow() + time_that_will_not_match_right_away = timezone.localize( + datetime(now.year + 1, 3, 25, 2, 31, 0) ) - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 25, 1, 50, 0))) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_time_change( + hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + ) + + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 25, 1, 50, 0)) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 25, 3, 50, 0))) + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 25, 3, 50, 0)) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 26, 1, 50, 0))) + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 26, 1, 50, 0)) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 - async_fire_time_changed(hass, timezone.localize(datetime(2018, 3, 26, 2, 50, 0))) + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 1, 3, 26, 2, 50, 0)) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 @@ -946,30 +976,45 @@ async def test_periodic_task_leaving_dst(hass): dt_util.set_default_time_zone(timezone) specific_runs = [] - unsub = async_track_time_change( - hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + now = dt_util.utcnow() + + time_that_will_not_match_right_away = timezone.localize( + datetime(now.year + 1, 10, 28, 2, 28, 0), is_dst=True ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_time_change( + hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + ) + async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False) + hass, timezone.localize(datetime(now.year + 1, 10, 28, 2, 5, 0), is_dst=False) ) await hass.async_block_till_done() assert len(specific_runs) == 0 async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False) + hass, timezone.localize(datetime(now.year + 1, 10, 28, 2, 55, 0), is_dst=False) ) await hass.async_block_till_done() assert len(specific_runs) == 1 async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True) + hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 45, 0), is_dst=True) ) await hass.async_block_till_done() - assert len(specific_runs) == 1 + assert len(specific_runs) == 2 async_fire_time_changed( - hass, timezone.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True) + hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 55, 0), is_dst=True) + ) + await hass.async_block_till_done() + assert len(specific_runs) == 2 + + async_fire_time_changed( + hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 55, 0), is_dst=True) ) await hass.async_block_till_done() assert len(specific_runs) == 2 From d9dba9142c81504c3b47155f4733e84c45af4f86 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Tue, 21 Jul 2020 09:44:00 +0200 Subject: [PATCH 068/362] Move data on import in rfxtrx integration into ConfigEntry (#38022) * Move all data imported from yaml to ConfigEntry * Revert changes that prevent updating yaml entry * Cleanup code around time conversion --- homeassistant/components/rfxtrx/__init__.py | 27 +++++++++---------- .../components/rfxtrx/binary_sensor.py | 11 +++----- .../components/rfxtrx/config_flow.py | 1 - homeassistant/components/rfxtrx/const.py | 1 - homeassistant/components/rfxtrx/cover.py | 4 +-- homeassistant/components/rfxtrx/light.py | 4 +-- homeassistant/components/rfxtrx/sensor.py | 3 +-- homeassistant/components/rfxtrx/switch.py | 4 +-- 8 files changed, 23 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 10b036e9eb9..81b5cf93392 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -29,7 +29,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_EVENT, - DATA_RFXTRX_CONFIG, DEVICE_PACKET_TYPE_LIGHTING4, EVENT_RFXTRX_EVENT, SERVICE_SEND, @@ -105,7 +104,9 @@ DEVICE_DATA_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional(CONF_OFF_DELAY): vol.Any(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_OFF_DELAY): vol.All( + cv.time_period, cv.positive_timedelta, lambda value: value.total_seconds() + ), vol.Optional(CONF_DATA_BITS): cv.positive_int, vol.Optional(CONF_COMMAND_ON): cv.byte, vol.Optional(CONF_COMMAND_OFF): cv.byte, @@ -135,21 +136,20 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the RFXtrx component.""" if DOMAIN not in config: - hass.data[DATA_RFXTRX_CONFIG] = BASE_SCHEMA({}) return True - hass.data[DATA_RFXTRX_CONFIG] = config[DOMAIN] + data = { + CONF_HOST: config[DOMAIN].get(CONF_HOST), + CONF_PORT: config[DOMAIN].get(CONF_PORT), + CONF_DEVICE: config[DOMAIN].get(CONF_DEVICE), + CONF_DEBUG: config[DOMAIN].get(CONF_DEBUG), + CONF_AUTOMATIC_ADD: config[DOMAIN].get(CONF_AUTOMATIC_ADD), + CONF_DEVICES: config[DOMAIN][CONF_DEVICES], + } hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: config[DOMAIN].get(CONF_HOST), - CONF_PORT: config[DOMAIN].get(CONF_PORT), - CONF_DEVICE: config[DOMAIN].get(CONF_DEVICE), - CONF_DEBUG: config[DOMAIN][CONF_DEBUG], - }, + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data, ) ) return True @@ -169,11 +169,10 @@ async def async_setup_entry(hass, entry: config_entries.ConfigEntry): def setup_internal(hass, config): """Set up the RFXtrx component.""" - # Setup some per device config device_events = set() device_bits = {} - for event_code, event_config in hass.data[DATA_RFXTRX_CONFIG][CONF_DEVICES].items(): + for event_code, event_config in config[CONF_DEVICES].items(): event = get_rfx_object(event_code) device_id = get_device_id( event.device, data_bits=event_config.get(CONF_DATA_BITS) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 20782766e29..82e8765fb49 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -24,12 +24,7 @@ from . import ( get_pt2262_cmd, get_rfx_object, ) -from .const import ( - COMMAND_OFF_LIST, - COMMAND_ON_LIST, - DATA_RFXTRX_CONFIG, - DEVICE_PACKET_TYPE_LIGHTING4, -) +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DEVICE_PACKET_TYPE_LIGHTING4 _LOGGER = logging.getLogger(__name__) @@ -43,7 +38,7 @@ async def async_setup_entry( device_ids = set() pt2262_devices = [] - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data def supported(event): return isinstance(event, rfxtrxmod.ControlEvent) @@ -197,5 +192,5 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self.async_write_ha_state() self._delay_listener = evt.async_call_later( - self.hass, self._off_delay.total_seconds(), off_delay_listener + self.hass, self._off_delay, off_delay_listener ) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 0bd8854ca41..0cdaa8146ec 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -16,7 +16,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config=None): """Handle the initial step.""" - await self.async_set_unique_id(DOMAIN) self._abort_if_unique_id_configured(import_config) return self.async_create_entry(title="RFXTRX", data=import_config) diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index 7626c082f45..cd3c4b9a62e 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -21,5 +21,4 @@ SERVICE_SEND = "send" DEVICE_PACKET_TYPE_LIGHTING4 = 0x13 -DATA_RFXTRX_CONFIG = "rfxtrx_config" EVENT_RFXTRX_EVENT = "rfxtrx_event" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index e8ed8498580..db3f5d38131 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -14,7 +14,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up config entry.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data device_ids = set() def supported(event): diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 9a986b96bb9..81633f847c4 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -20,7 +20,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up config entry.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data device_ids = set() def supported(event): diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 129e4f6f9d5..337af41940f 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -20,7 +20,6 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import DATA_RFXTRX_CONFIG _LOGGER = logging.getLogger(__name__) @@ -57,7 +56,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up platform.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data data_ids = set() def supported(event): diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 3b0290f5546..4b5c6919910 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -17,7 +17,7 @@ from . import ( get_device_id, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DATA_RFXTRX_CONFIG +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST DATA_SWITCH = f"{DOMAIN}_switch" @@ -28,7 +28,7 @@ async def async_setup_entry( hass, config_entry, async_add_entities, ): """Set up config entry.""" - discovery_info = hass.data[DATA_RFXTRX_CONFIG] + discovery_info = config_entry.data device_ids = set() def supported(event): From 1fc37fec7be3cdb17beaa34388c8b1abc31fab2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2020 12:54:35 +0200 Subject: [PATCH 069/362] Bump actions/setup-python from v2 to v2.1.1 (#38034) Bumps [actions/setup-python](https://github.com/actions/setup-python) from v2 to v2.1.1. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v2...0c28554988f6ccf1a4e2818e703679796e41a214) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f5d80984806..c17d6ab36c1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment @@ -75,7 +75,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -119,7 +119,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -163,7 +163,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -229,7 +229,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -276,7 +276,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -323,7 +323,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -367,7 +367,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -414,7 +414,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -469,7 +469,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -516,7 +516,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -548,7 +548,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v2.1.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} From 01d2d2f315025f47e5752c673c585511d0115c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 21 Jul 2020 20:37:54 +0300 Subject: [PATCH 070/362] Fix wolflink datetime import (#38028) --- homeassistant/components/wolflink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index b037a0b7d21..cce9d542446 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -1,5 +1,5 @@ """The Wolf SmartSet Service integration.""" -from _datetime import timedelta +from datetime import timedelta import logging from httpcore import ConnectError From 908b72370b4017b01f96790360775cc6b5192ce4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2020 21:36:21 +0200 Subject: [PATCH 071/362] Correct arguments to MQTT will_set (#38036) --- homeassistant/components/mqtt/__init__.py | 3 ++- tests/components/mqtt/test_init.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index b88c536d6a3..dac6527b268 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -716,7 +716,8 @@ class MQTT: self._mqttc.will_set( # pylint: disable=no-value-for-parameter *attr.astuple( will_message, - filter=lambda attr, value: attr.name != "subscribed_topic", + filter=lambda attr, value: attr.name + not in ["subscribed_topic", "timestamp"], ) ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3dee1dc874b..a6eb7e46f59 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -799,13 +799,13 @@ async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): ) async def test_custom_will_message(hass, mqtt_client_mock, mqtt_mock): """Test will message.""" - mqtt_client_mock.will_set.assert_called_with("death", "death", 0, False, None) + mqtt_client_mock.will_set.assert_called_with("death", "death", 0, False) async def test_default_will_message(hass, mqtt_client_mock, mqtt_mock): """Test will message.""" mqtt_client_mock.will_set.assert_called_with( - "homeassistant/status", "offline", 0, False, None + "homeassistant/status", "offline", 0, False ) From fa0e12ffe8e0fb337864b37c518abb0e27090b65 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jul 2020 23:10:34 +0200 Subject: [PATCH 072/362] Use keywords for MQTT birth and will (#38040) --- homeassistant/components/mqtt/__init__.py | 18 ++++++++---------- tests/components/mqtt/test_init.py | 6 ++++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index dac6527b268..a0527cfe427 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -714,11 +714,10 @@ class MQTT: if will_message is not None: self._mqttc.will_set( # pylint: disable=no-value-for-parameter - *attr.astuple( - will_message, - filter=lambda attr, value: attr.name - not in ["subscribed_topic", "timestamp"], - ) + topic=will_message.topic, + payload=will_message.payload, + qos=will_message.qos, + retain=will_message.retain, ) async def async_publish( @@ -865,11 +864,10 @@ class MQTT: birth_message = Message(**self.conf[CONF_BIRTH_MESSAGE]) self.hass.add_job( self.async_publish( # pylint: disable=no-value-for-parameter - *attr.astuple( - birth_message, - filter=lambda attr, value: attr.name - not in ["subscribed_topic", "timestamp"], - ) + topic=birth_message.topic, + payload=birth_message.payload, + qos=birth_message.qos, + retain=birth_message.retain, ) ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a6eb7e46f59..15d92b9a311 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -799,13 +799,15 @@ async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): ) async def test_custom_will_message(hass, mqtt_client_mock, mqtt_mock): """Test will message.""" - mqtt_client_mock.will_set.assert_called_with("death", "death", 0, False) + mqtt_client_mock.will_set.assert_called_with( + topic="death", payload="death", qos=0, retain=False + ) async def test_default_will_message(hass, mqtt_client_mock, mqtt_mock): """Test will message.""" mqtt_client_mock.will_set.assert_called_with( - "homeassistant/status", "offline", 0, False + topic="homeassistant/status", payload="offline", qos=0, retain=False ) From 4015991622cf90ca6866ca046d34d9c66f033cdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jul 2020 11:22:55 -1000 Subject: [PATCH 073/362] Update tests that track time to account for microsecond precision (#38044) --- tests/components/automation/test_time.py | 80 ++++++++++++++--------- tests/helpers/test_event.py | 81 ++++++++++++++---------- 2 files changed, 99 insertions(+), 62 deletions(-) diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index 81f1657e0a2..c93cdbc36e9 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -30,22 +30,31 @@ def setup_comp(hass): async def test_if_fires_using_at(hass, calls): """Test for firing at.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, - }, - } - }, + now = dt_util.utcnow() + + time_that_will_not_match_right_away = now.replace( + year=now.year + 1, hour=4, minute=59, second=0 ) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" + }, + }, + } + }, + ) + now = dt_util.utcnow() async_fire_time_changed( @@ -62,23 +71,34 @@ async def test_if_not_fires_using_wrong_at(hass, calls): This should break the before rule. """ - with assert_setup_component(0, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) + now = dt_util.utcnow() - async_fire_time_changed(hass, dt_util.utcnow().replace(hour=1, minute=0, second=5)) + time_that_will_not_match_right_away = now.replace( + year=now.year + 1, hour=1, minute=0, second=0 + ) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + with assert_setup_component(0, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=1, minute=0, second=5) + ) await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 784bb673f77..669b2ab25c8 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -755,17 +755,17 @@ async def test_async_track_time_change(hass): hass, lambda x: specific_runs.append(1), second=[0, 30] ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 15)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 15, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -773,7 +773,7 @@ async def test_async_track_time_change(hass): unsub() unsub_utc() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -789,21 +789,21 @@ async def test_periodic_task_minute(hass): hass, lambda x: specific_runs.append(1), minute="/5", second=0 ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 3, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 3, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -818,23 +818,23 @@ async def test_periodic_task_hour(hass): hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 1, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 1, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 3 @@ -856,7 +856,7 @@ async def test_periodic_task_wrong_input(hass): hass, lambda x: specific_runs.append(1), hour="/two" ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 2, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 2, 0, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 0 @@ -871,31 +871,33 @@ async def test_periodic_task_clock_rollback(hass): hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0), fire_all=True + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999), fire_all=True ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 0, 0, 0), fire_all=True) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 0, 0, 0, 999999), fire_all=True + ) await hass.async_block_till_done() assert len(specific_runs) == 3 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 4 unsub() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 4 @@ -910,15 +912,15 @@ async def test_periodic_task_duplicate_time(hass): hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0)) + async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -944,25 +946,25 @@ async def test_periodic_task_entering_dst(hass): ) async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 1, 3, 25, 1, 50, 0)) + hass, timezone.localize(datetime(now.year + 1, 3, 25, 1, 50, 0, 999999)) ) await hass.async_block_till_done() assert len(specific_runs) == 0 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 1, 3, 25, 3, 50, 0)) + hass, timezone.localize(datetime(now.year + 1, 3, 25, 3, 50, 0, 999999)) ) await hass.async_block_till_done() assert len(specific_runs) == 0 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 1, 3, 26, 1, 50, 0)) + hass, timezone.localize(datetime(now.year + 1, 3, 26, 1, 50, 0, 999999)) ) await hass.async_block_till_done() assert len(specific_runs) == 0 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 1, 3, 26, 2, 50, 0)) + hass, timezone.localize(datetime(now.year + 1, 3, 26, 2, 50, 0, 999999)) ) await hass.async_block_till_done() assert len(specific_runs) == 1 @@ -990,31 +992,46 @@ async def test_periodic_task_leaving_dst(hass): ) async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 1, 10, 28, 2, 5, 0), is_dst=False) + hass, + timezone.localize( + datetime(now.year + 1, 10, 28, 2, 5, 0, 999999), is_dst=False + ), ) await hass.async_block_till_done() assert len(specific_runs) == 0 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 1, 10, 28, 2, 55, 0), is_dst=False) + hass, + timezone.localize( + datetime(now.year + 1, 10, 28, 2, 55, 0, 999999), is_dst=False + ), ) await hass.async_block_till_done() assert len(specific_runs) == 1 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 45, 0), is_dst=True) + hass, + timezone.localize( + datetime(now.year + 2, 10, 28, 2, 45, 0, 999999), is_dst=True + ), ) await hass.async_block_till_done() assert len(specific_runs) == 2 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 55, 0), is_dst=True) + hass, + timezone.localize( + datetime(now.year + 2, 10, 28, 2, 55, 0, 999999), is_dst=True + ), ) await hass.async_block_till_done() assert len(specific_runs) == 2 async_fire_time_changed( - hass, timezone.localize(datetime(now.year + 2, 10, 28, 2, 55, 0), is_dst=True) + hass, + timezone.localize( + datetime(now.year + 2, 10, 28, 2, 55, 0, 999999), is_dst=True + ), ) await hass.async_block_till_done() assert len(specific_runs) == 2 From ec17ed9364839bf8b36ff9edd9d5f05b21379af2 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 21 Jul 2020 17:42:23 -0400 Subject: [PATCH 074/362] ZHA dependencies bump bellows to 0.18.0 (#38043) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 24d9a0a3962..974cac32c33 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.17.0", + "bellows==0.18.0", "pyserial==3.4", "zha-quirks==0.0.42", "zigpy-cc==0.4.4", diff --git a/requirements_all.txt b/requirements_all.txt index d3876d0757e..d4ac79183d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -322,7 +322,7 @@ beautifulsoup4==4.9.0 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows==0.17.0 +bellows==0.18.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe3741e3f76..c07f4b662b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,7 +169,7 @@ azure-eventhub==5.1.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.17.0 +bellows==0.18.0 # homeassistant.components.blebox blebox_uniapi==1.3.2 From ad5d7ee615f553b43b042cc8c45cc6a86f36bd15 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Tue, 21 Jul 2020 23:43:05 +0200 Subject: [PATCH 075/362] Implement unload entry for rfxtrx integration (#38037) * Implement unload entry * Change async_remove to remove * Pop data from hass.data * Change sequence order in unload * Dont unload internal when unload platforms fail --- homeassistant/components/rfxtrx/__init__.py | 45 +++++++++++++++++++-- tests/components/rfxtrx/__init__.py | 2 +- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 81b5cf93392..9cef384cc01 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,4 +1,5 @@ """Support for RFXtrx devices.""" +import asyncio import binascii from collections import OrderedDict import logging @@ -82,6 +83,7 @@ DATA_TYPES = OrderedDict( _LOGGER = logging.getLogger(__name__) DATA_RFXOBJECT = "rfxobject" +DATA_LISTENER = "ha_stop" def _bytearray_string(data): @@ -132,6 +134,8 @@ CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Any(DEVICE_SCHEMA, PORT_SCHEMA)}, extra=vol.ALLOW_EXTRA ) +DOMAINS = ["switch", "sensor", "light", "binary_sensor", "cover"] + async def async_setup(hass, config): """Set up the RFXtrx component.""" @@ -157,9 +161,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" + hass.data.setdefault(DOMAIN, {}) + await hass.async_add_executor_job(setup_internal, hass, entry.data) - for domain in ["switch", "sensor", "light", "binary_sensor", "cover"]: + for domain in DOMAINS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, domain) ) @@ -167,6 +173,36 @@ async def async_setup_entry(hass, entry: config_entries.ConfigEntry): return True +async def async_unload_entry(hass, entry: config_entries.ConfigEntry): + """Unload RFXtrx component.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in DOMAINS + ] + ) + ) + + if unload_ok: + await hass.async_add_executor_job(unload_internal, hass, entry.data) + + hass.data.pop(DOMAIN) + + return unload_ok + + +def unload_internal(hass, config): + """Unload the RFXtrx component.""" + hass.services.remove(DOMAIN, SERVICE_SEND) + + listener = hass.data[DOMAIN][DATA_LISTENER] + listener() + + rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT] + rfx_object.close_connection() + + def setup_internal(hass, config): """Set up the RFXtrx component.""" # Setup some per device config @@ -234,9 +270,10 @@ def setup_internal(hass, config): """Close connection with RFXtrx.""" rfx_object.close_connection() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) + listener = hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) - hass.data[DATA_RFXOBJECT] = rfx_object + hass.data[DOMAIN][DATA_LISTENER] = listener + hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object def send(call): event = call.data[ATTR_EVENT] @@ -432,7 +469,7 @@ class RfxtrxCommandEntity(RfxtrxEntity): self._state = None def _send_command(self, command, brightness=0): - rfx_object = self.hass.data[DATA_RFXOBJECT] + rfx_object = self.hass.data[DOMAIN][DATA_RFXOBJECT] if command == "turn_on": for _ in range(self.signal_repetitions): diff --git a/tests/components/rfxtrx/__init__.py b/tests/components/rfxtrx/__init__.py index 53d53664591..db7a592877e 100644 --- a/tests/components/rfxtrx/__init__.py +++ b/tests/components/rfxtrx/__init__.py @@ -6,7 +6,7 @@ async def _signal_event(hass, packet_id): event = rfxtrx.get_rfx_object(packet_id) await hass.async_add_executor_job( - hass.data[rfxtrx.DATA_RFXOBJECT].event_callback, event, + hass.data[rfxtrx.DOMAIN][rfxtrx.DATA_RFXOBJECT].event_callback, event, ) await hass.async_block_till_done() From 945acb4e29cf4d190385a9dbe8c775e6602e3bab Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 22 Jul 2020 00:01:31 +0200 Subject: [PATCH 076/362] Make sure command entities restore from state (#38038) --- homeassistant/components/rfxtrx/__init__.py | 6 ----- .../components/rfxtrx/binary_sensor.py | 18 ++++++++++++- homeassistant/components/rfxtrx/cover.py | 11 +++++++- homeassistant/components/rfxtrx/light.py | 12 ++++++++- homeassistant/components/rfxtrx/sensor.py | 12 +++++++++ homeassistant/components/rfxtrx/switch.py | 11 +++++++- tests/components/rfxtrx/test_binary_sensor.py | 27 ++++++++++++++++++- tests/components/rfxtrx/test_cover.py | 23 ++++++++++++++++ tests/components/rfxtrx/test_light.py | 25 +++++++++++++++++ tests/components/rfxtrx/test_sensor.py | 27 +++++++++++++++++++ tests/components/rfxtrx/test_switch.py | 21 +++++++++++++++ 11 files changed, 182 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 9cef384cc01..0fdab9f1bf5 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -398,12 +398,6 @@ class RfxtrxEntity(RestoreEntity): """Restore RFXtrx device state (ON/OFF).""" if self._event: self._apply_event(self._event) - else: - old_state = await self.async_get_last_state() - if old_state is not None: - event = old_state.attributes.get(ATTR_EVENT) - if event: - self._apply_event(get_rfx_object(event)) self.async_on_remove( self.hass.helpers.dispatcher.async_dispatcher_connect( diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 82e8765fb49..0c2cd728afc 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -24,7 +24,12 @@ from . import ( get_pt2262_cmd, get_rfx_object, ) -from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DEVICE_PACKET_TYPE_LIGHTING4 +from .const import ( + ATTR_EVENT, + COMMAND_OFF_LIST, + COMMAND_ON_LIST, + DEVICE_PACKET_TYPE_LIGHTING4, +) _LOGGER = logging.getLogger(__name__) @@ -124,6 +129,17 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self._cmd_on = cmd_on self._cmd_off = cmd_off + async def async_added_to_hass(self): + """Restore device state.""" + await super().async_added_to_hass() + + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + event = old_state.attributes.get(ATTR_EVENT) + if event: + self._apply_event(get_rfx_object(event)) + @property def force_update(self) -> bool: """We should force updates. Repeated states have meaning.""" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index db3f5d38131..047f7c67a21 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -2,7 +2,7 @@ import logging from homeassistant.components.cover import CoverEntity -from homeassistant.const import CONF_DEVICES +from homeassistant.const import CONF_DEVICES, STATE_OPEN from homeassistant.core import callback from . import ( @@ -81,6 +81,15 @@ async def async_setup_entry( class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): """Representation of a RFXtrx cover.""" + async def async_added_to_hass(self): + """Restore device state.""" + await super().async_added_to_hass() + + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + self._state = old_state.state == STATE_OPEN + @property def is_closed(self): """Return if the cover is closed.""" diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 81633f847c4..cd57bb99c40 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -8,7 +8,7 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, LightEntity, ) -from homeassistant.const import CONF_DEVICES +from homeassistant.const import CONF_DEVICES, STATE_ON from homeassistant.core import callback from . import ( @@ -97,6 +97,16 @@ class RfxtrxLight(RfxtrxCommandEntity, LightEntity): _brightness = 0 + async def async_added_to_hass(self): + """Restore RFXtrx device state (ON/OFF).""" + await super().async_added_to_hass() + + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + self._state = old_state.state == STATE_ON + self._brightness = old_state.attributes.get(ATTR_BRIGHTNESS) + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 337af41940f..34e6dc1b310 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -20,6 +20,7 @@ from . import ( get_device_id, get_rfx_object, ) +from .const import ATTR_EVENT _LOGGER = logging.getLogger(__name__) @@ -125,6 +126,17 @@ class RfxtrxSensor(RfxtrxEntity): self._device_class = DEVICE_CLASSES.get(data_type) self._convert_fun = CONVERT_FUNCTIONS.get(data_type, lambda x: x) + async def async_added_to_hass(self): + """Restore device state.""" + await super().async_added_to_hass() + + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + event = old_state.attributes.get(ATTR_EVENT) + if event: + self._apply_event(get_rfx_object(event)) + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 4b5c6919910..cf8c26edecb 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -4,7 +4,7 @@ import logging import RFXtrx as rfxtrxmod from homeassistant.components.switch import SwitchEntity -from homeassistant.const import CONF_DEVICES +from homeassistant.const import CONF_DEVICES, STATE_ON from homeassistant.core import callback from . import ( @@ -91,6 +91,15 @@ async def async_setup_entry( class RfxtrxSwitch(RfxtrxCommandEntity, SwitchEntity): """Representation of a RFXtrx switch.""" + async def async_added_to_hass(self): + """Restore device state.""" + await super().async_added_to_hass() + + if self._event is None: + old_state = await self.async_get_last_state() + if old_state is not None: + self._state = old_state.state == STATE_ON + def _apply_event(self, event): """Apply command from rfxtrx.""" super()._apply_event(event) diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index d694dcb34cf..ad04a0763f2 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -1,12 +1,16 @@ """The tests for the Rfxtrx sensor platform.""" from datetime import timedelta +import pytest + +from homeassistant.components.rfxtrx.const import ATTR_EVENT +from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from . import _signal_event -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache async def test_one(hass, rfxtrx): @@ -59,6 +63,27 @@ async def test_one_pt2262(hass, rfxtrx): assert state.state == "off" +@pytest.mark.parametrize( + "state,event", + [["on", "0b1100cd0213c7f230010f71"], ["off", "0b1100cd0213c7f230000f71"]], +) +async def test_state_restore(hass, rfxtrx, state, event): + """State restoration.""" + + entity_id = "binary_sensor.ac_213c7f2_48" + + mock_restore_cache(hass, [State(entity_id, state, attributes={ATTR_EVENT: event})]) + + assert await async_setup_component( + hass, + "rfxtrx", + {"rfxtrx": {"device": "abcd", "devices": {"0b1100cd0213c7f230010f71": {}}}}, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == state + + async def test_several(hass, rfxtrx): """Test with 3.""" assert await async_setup_component( diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index 9a9b37f4e36..73c3cb9cc27 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -1,10 +1,15 @@ """The tests for the Rfxtrx cover platform.""" from unittest.mock import call +import pytest + +from homeassistant.core import State from homeassistant.setup import async_setup_component from . import _signal_event +from tests.common import mock_restore_cache + async def test_one_cover(hass, rfxtrx): """Test with 1 cover.""" @@ -46,6 +51,24 @@ async def test_one_cover(hass, rfxtrx): ] +@pytest.mark.parametrize("state", ["open", "closed"]) +async def test_state_restore(hass, rfxtrx, state): + """State restoration.""" + + entity_id = "cover.lightwaverf_siemens_0213c7_242" + + mock_restore_cache(hass, [State(entity_id, state)]) + + assert await async_setup_component( + hass, + "rfxtrx", + {"rfxtrx": {"device": "abcd", "devices": {"0b1400cd0213c7f20d010f51": {}}}}, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == state + + async def test_several_covers(hass, rfxtrx): """Test with 3 covers.""" assert await async_setup_component( diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py index 53ddf8bc48c..b96dec95e6e 100644 --- a/tests/components/rfxtrx/test_light.py +++ b/tests/components/rfxtrx/test_light.py @@ -3,10 +3,14 @@ from unittest.mock import call import pytest +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.core import State from homeassistant.setup import async_setup_component from . import _signal_event +from tests.common import mock_restore_cache + async def test_one_light(hass, rfxtrx): """Test with 1 light.""" @@ -83,6 +87,27 @@ async def test_one_light(hass, rfxtrx): ] +@pytest.mark.parametrize("state,brightness", [["on", 100], ["on", 50], ["off", None]]) +async def test_state_restore(hass, rfxtrx, state, brightness): + """State restoration.""" + + entity_id = "light.ac_213c7f2_16" + + mock_restore_cache( + hass, [State(entity_id, state, attributes={ATTR_BRIGHTNESS: brightness})] + ) + + assert await async_setup_component( + hass, + "rfxtrx", + {"rfxtrx": {"device": "abcd", "devices": {"0b1100cd0213c7f210020f51": {}}}}, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == state + assert hass.states.get(entity_id).attributes.get(ATTR_BRIGHTNESS) == brightness + + async def test_several_lights(hass, rfxtrx): """Test with 3 lights.""" assert await async_setup_component( diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 91e010f9e54..3a797f4168d 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -1,9 +1,15 @@ """The tests for the Rfxtrx sensor platform.""" +import pytest + +from homeassistant.components.rfxtrx.const import ATTR_EVENT from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE +from homeassistant.core import State from homeassistant.setup import async_setup_component from . import _signal_event +from tests.common import mock_restore_cache + async def test_default_config(hass, rfxtrx): """Test with 0 sensor.""" @@ -34,6 +40,27 @@ async def test_one_sensor(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == TEMP_CELSIUS +@pytest.mark.parametrize( + "state,event", + [["18.4", "0a520801070100b81b0279"], ["17.9", "0a52085e070100b31b0279"]], +) +async def test_state_restore(hass, rfxtrx, state, event): + """State restoration.""" + + entity_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_07_01_temperature" + + mock_restore_cache(hass, [State(entity_id, state, attributes={ATTR_EVENT: event})]) + + assert await async_setup_component( + hass, + "rfxtrx", + {"rfxtrx": {"device": "abcd", "devices": {"0a520801070100b81b0279": {}}}}, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == state + + async def test_one_sensor_no_datatype(hass, rfxtrx): """Test with 1 sensor.""" assert await async_setup_component( diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 24d1cbbcdf0..22f7a73c77c 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -3,10 +3,13 @@ from unittest.mock import call import pytest +from homeassistant.core import State from homeassistant.setup import async_setup_component from . import _signal_event +from tests.common import mock_restore_cache + async def test_one_switch(hass, rfxtrx): """Test with 1 switch.""" @@ -42,6 +45,24 @@ async def test_one_switch(hass, rfxtrx): ] +@pytest.mark.parametrize("state", ["on", "off"]) +async def test_state_restore(hass, rfxtrx, state): + """State restoration.""" + + entity_id = "switch.ac_213c7f2_16" + + mock_restore_cache(hass, [State(entity_id, state)]) + + assert await async_setup_component( + hass, + "rfxtrx", + {"rfxtrx": {"device": "abcd", "devices": {"0b1100cd0213c7f210010f51": {}}}}, + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == state + + async def test_several_switches(hass, rfxtrx): """Test with 3 switches.""" assert await async_setup_component( From 7a3c6d65252f68ce73ca1edb101c83720fa99ea7 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 22 Jul 2020 00:24:26 +0200 Subject: [PATCH 077/362] Avoid using implementation internal to trigger events (#38041) This uses the moxking in fixture to trigger events. --- tests/components/rfxtrx/__init__.py | 13 ------------ tests/components/rfxtrx/conftest.py | 20 +++++++++++++++++-- tests/components/rfxtrx/test_binary_sensor.py | 12 +++++------ tests/components/rfxtrx/test_cover.py | 6 ++---- tests/components/rfxtrx/test_init.py | 16 +++++++-------- tests/components/rfxtrx/test_light.py | 6 ++---- tests/components/rfxtrx/test_sensor.py | 12 +++++------ tests/components/rfxtrx/test_switch.py | 6 ++---- 8 files changed, 41 insertions(+), 50 deletions(-) diff --git a/tests/components/rfxtrx/__init__.py b/tests/components/rfxtrx/__init__.py index db7a592877e..81b2db8f4df 100644 --- a/tests/components/rfxtrx/__init__.py +++ b/tests/components/rfxtrx/__init__.py @@ -1,14 +1 @@ """Tests for the rfxtrx component.""" -from homeassistant.components import rfxtrx - - -async def _signal_event(hass, packet_id): - event = rfxtrx.get_rfx_object(packet_id) - - await hass.async_add_executor_job( - hass.data[rfxtrx.DOMAIN][rfxtrx.DATA_RFXOBJECT].event_callback, event, - ) - - await hass.async_block_till_done() - await hass.async_block_till_done() - return event diff --git a/tests/components/rfxtrx/conftest.py b/tests/components/rfxtrx/conftest.py index eba322de5a3..9ce7ec07f4b 100644 --- a/tests/components/rfxtrx/conftest.py +++ b/tests/components/rfxtrx/conftest.py @@ -3,10 +3,26 @@ from unittest import mock import pytest +from homeassistant.components import rfxtrx + @pytest.fixture(autouse=True, name="rfxtrx") -async def rfxtrx(hass): +async def rfxtrx_fixture(hass): """Fixture that cleans up threads from integration.""" with mock.patch("RFXtrx.Connect") as connect, mock.patch("RFXtrx.DummyTransport2"): - yield connect.return_value + rfx = connect.return_value + + async def _signal_event(packet_id): + event = rfxtrx.get_rfx_object(packet_id) + await hass.async_add_executor_job( + rfx.event_callback, event, + ) + + await hass.async_block_till_done() + await hass.async_block_till_done() + return event + + rfx.signal = _signal_event + + yield rfx diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index ad04a0763f2..50f9ccf0eca 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -8,8 +8,6 @@ from homeassistant.core import State from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from . import _signal_event - from tests.common import async_fire_time_changed, mock_restore_cache @@ -54,11 +52,11 @@ async def test_one_pt2262(hass, rfxtrx): assert state.state == "off" # probably aught to be unknown assert state.attributes.get("friendly_name") == "PT2262 22670e" - await _signal_event(hass, "0913000022670e013970") + await rfxtrx.signal("0913000022670e013970") state = hass.states.get("binary_sensor.pt2262_22670e") assert state.state == "on" - await _signal_event(hass, "09130000226707013d70") + await rfxtrx.signal("09130000226707013d70") state = hass.states.get("binary_sensor.pt2262_22670e") assert state.state == "off" @@ -138,12 +136,12 @@ async def test_discover(hass, rfxtrx): await hass.async_block_till_done() await hass.async_start() - await _signal_event(hass, "0b1100100118cdea02010f70") + await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "on" - await _signal_event(hass, "0b1100100118cdeb02010f70") + await rfxtrx.signal("0b1100100118cdeb02010f70") state = hass.states.get("binary_sensor.ac_118cdeb_2") assert state assert state.state == "on" @@ -168,7 +166,7 @@ async def test_off_delay(hass, rfxtrx): assert state assert state.state == "off" - await _signal_event(hass, "0b1100100118cdea02010f70") + await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "on" diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index 73c3cb9cc27..ffbef93daec 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -6,8 +6,6 @@ import pytest from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -111,12 +109,12 @@ async def test_discover_covers(hass, rfxtrx): await hass.async_block_till_done() await hass.async_start() - await _signal_event(hass, "0a140002f38cae010f0070") + await rfxtrx.signal("0a140002f38cae010f0070") state = hass.states.get("cover.lightwaverf_siemens_f38cae_1") assert state assert state.state == "open" - await _signal_event(hass, "0a1400adf394ab020e0060") + await rfxtrx.signal("0a1400adf394ab020e0060") state = hass.states.get("cover.lightwaverf_siemens_f394ab_2") assert state assert state.state == "open" diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index 2a091525328..abe7c3c0441 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -1,11 +1,9 @@ """The tests for the Rfxtrx component.""" -from homeassistant.components import rfxtrx +from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT from homeassistant.core import callback from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.async_mock import call @@ -55,7 +53,7 @@ async def test_invalid_config(hass): ) -async def test_fire_event(hass): +async def test_fire_event(hass, rfxtrx): """Test fire event.""" assert await async_setup_component( hass, @@ -66,8 +64,8 @@ async def test_fire_event(hass): + "-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0", "automatic_add": True, "devices": { - "0b1100cd0213c7f210010f51": {rfxtrx.CONF_FIRE_EVENT: True}, - "0716000100900970": {rfxtrx.CONF_FIRE_EVENT: True}, + "0b1100cd0213c7f210010f51": {"fire_event": True}, + "0716000100900970": {"fire_event": True}, }, } }, @@ -83,10 +81,10 @@ async def test_fire_event(hass): assert event.event_type == "rfxtrx_event" calls.append(event.data) - hass.bus.async_listen(rfxtrx.const.EVENT_RFXTRX_EVENT, record_event) + hass.bus.async_listen(EVENT_RFXTRX_EVENT, record_event) - await _signal_event(hass, "0b1100cd0213c7f210010f51") - await _signal_event(hass, "0716000100900970") + await rfxtrx.signal("0b1100cd0213c7f210010f51") + await rfxtrx.signal("0716000100900970") assert calls == [ { diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py index b96dec95e6e..ead6d638841 100644 --- a/tests/components/rfxtrx/test_light.py +++ b/tests/components/rfxtrx/test_light.py @@ -7,8 +7,6 @@ from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -175,13 +173,13 @@ async def test_discover_light(hass, rfxtrx): await hass.async_block_till_done() await hass.async_start() - await _signal_event(hass, "0b11009e00e6116202020070") + await rfxtrx.signal("0b11009e00e6116202020070") state = hass.states.get("light.ac_0e61162_2") assert state assert state.state == "on" assert state.attributes.get("friendly_name") == "AC 0e61162:2" - await _signal_event(hass, "0b1100120118cdea02020070") + await rfxtrx.signal("0b1100120118cdea02020070") state = hass.states.get("light.ac_118cdea_2") assert state assert state.state == "on" diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 3a797f4168d..a8cabcd401e 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -6,8 +6,6 @@ from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -159,7 +157,7 @@ async def test_discover_sensor(hass, rfxtrx): await hass.async_start() # 1 - await _signal_event(hass, "0a520801070100b81b0279") + await rfxtrx.signal("0a520801070100b81b0279") base_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_07_01" state = hass.states.get(f"{base_id}_humidity") @@ -188,7 +186,7 @@ async def test_discover_sensor(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE # 2 - await _signal_event(hass, "0a52080405020095240279") + await rfxtrx.signal("0a52080405020095240279") base_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_05_02" state = hass.states.get(f"{base_id}_humidity") @@ -217,7 +215,7 @@ async def test_discover_sensor(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE # 1 Update - await _signal_event(hass, "0a52085e070100b31b0279") + await rfxtrx.signal("0a52085e070100b31b0279") base_id = "sensor.wt260_wt260h_wt440h_wt450_wt450h_07_01" state = hass.states.get(f"{base_id}_humidity") @@ -278,8 +276,8 @@ async def test_update_of_sensors(hass, rfxtrx): assert state assert state.state == "unknown" - await _signal_event(hass, "0a520802060101ff0f0269") - await _signal_event(hass, "0a52080705020085220269") + await rfxtrx.signal("0a520802060101ff0f0269") + await rfxtrx.signal("0a52080705020085220269") state = hass.states.get("sensor.wt260_wt260h_wt440h_wt450_wt450h_05_02_temperature") assert state diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 22f7a73c77c..e4f7763c299 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -6,8 +6,6 @@ import pytest from homeassistant.core import State from homeassistant.setup import async_setup_component -from . import _signal_event - from tests.common import mock_restore_cache @@ -130,12 +128,12 @@ async def test_discover_switch(hass, rfxtrx): await hass.async_block_till_done() await hass.async_start() - await _signal_event(hass, "0b1100100118cdea02010f70") + await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("switch.ac_118cdea_2") assert state assert state.state == "on" - await _signal_event(hass, "0b1100100118cdeb02010f70") + await rfxtrx.signal("0b1100100118cdeb02010f70") state = hass.states.get("switch.ac_118cdeb_2") assert state assert state.state == "on" From e766a119d22812438ae462736a47d31db8af4e8c Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Wed, 22 Jul 2020 00:02:30 +0000 Subject: [PATCH 078/362] [ci skip] Translation update --- .../components/brother/translations/tr.json | 9 ++ .../components/control4/translations/it.json | 31 +++++++ .../components/control4/translations/pl.json | 10 +++ .../control4/translations/zh-Hant.json | 31 +++++++ .../components/deconz/translations/tr.json | 9 ++ .../components/demo/translations/it.json | 1 + .../components/dexcom/translations/pl.json | 5 +- .../components/enocean/translations/it.json | 27 ++++++ .../components/enocean/translations/pl.json | 16 +++- .../components/esphome/translations/tr.json | 9 ++ .../components/firmata/translations/it.json | 11 +++ .../huawei_lte/translations/tr.json | 11 +++ .../components/hue/translations/it.json | 1 + .../humidifier/translations/it.json | 10 +++ .../humidifier/translations/nl.json | 9 ++ .../components/ipp/translations/tr.json | 9 ++ .../components/netatmo/translations/it.json | 25 ++++++ .../components/notion/translations/tr.json | 7 ++ .../onboarding/translations/tr.json | 7 ++ .../components/onvif/translations/tr.json | 13 +++ .../components/pi_hole/translations/it.json | 3 +- .../components/pi_hole/translations/nl.json | 7 ++ .../components/rfxtrx/translations/it.json | 4 + .../components/sms/translations/pl.json | 8 ++ .../components/syncthru/translations/it.json | 27 ++++++ .../components/wemo/translations/tr.json | 7 ++ .../components/wolflink/translations/it.json | 27 ++++++ .../components/wolflink/translations/pl.json | 9 +- .../components/wolflink/translations/ru.json | 27 ++++++ .../wolflink/translations/sensor.it.json | 87 +++++++++++++++++++ .../wolflink/translations/sensor.pl.json | 87 +++++++++++++++++++ .../wolflink/translations/sensor.ru.json | 67 ++++++++++++++ .../wolflink/translations/sensor.tr.json | 10 +++ .../wolflink/translations/sensor.zh-Hant.json | 87 +++++++++++++++++++ .../wolflink/translations/zh-Hant.json | 27 ++++++ .../components/zerproc/translations/tr.json | 7 ++ 36 files changed, 737 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/brother/translations/tr.json create mode 100644 homeassistant/components/control4/translations/it.json create mode 100644 homeassistant/components/control4/translations/zh-Hant.json create mode 100644 homeassistant/components/deconz/translations/tr.json create mode 100644 homeassistant/components/enocean/translations/it.json create mode 100644 homeassistant/components/esphome/translations/tr.json create mode 100644 homeassistant/components/firmata/translations/it.json create mode 100644 homeassistant/components/huawei_lte/translations/tr.json create mode 100644 homeassistant/components/humidifier/translations/nl.json create mode 100644 homeassistant/components/ipp/translations/tr.json create mode 100644 homeassistant/components/notion/translations/tr.json create mode 100644 homeassistant/components/onboarding/translations/tr.json create mode 100644 homeassistant/components/onvif/translations/tr.json create mode 100644 homeassistant/components/rfxtrx/translations/it.json create mode 100644 homeassistant/components/syncthru/translations/it.json create mode 100644 homeassistant/components/wemo/translations/tr.json create mode 100644 homeassistant/components/wolflink/translations/it.json create mode 100644 homeassistant/components/wolflink/translations/ru.json create mode 100644 homeassistant/components/wolflink/translations/sensor.it.json create mode 100644 homeassistant/components/wolflink/translations/sensor.pl.json create mode 100644 homeassistant/components/wolflink/translations/sensor.tr.json create mode 100644 homeassistant/components/wolflink/translations/sensor.zh-Hant.json create mode 100644 homeassistant/components/wolflink/translations/zh-Hant.json create mode 100644 homeassistant/components/zerproc/translations/tr.json diff --git a/homeassistant/components/brother/translations/tr.json b/homeassistant/components/brother/translations/tr.json new file mode 100644 index 00000000000..160a5ecc7b7 --- /dev/null +++ b/homeassistant/components/brother/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "zeroconf_confirm": { + "title": "Ke\u015ffedilen Brother Yaz\u0131c\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/it.json b/homeassistant/components/control4/translations/it.json new file mode 100644 index 00000000000..01dfba1bdc5 --- /dev/null +++ b/homeassistant/components/control4/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Indirizzo IP", + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci i dettagli del tuo account Control4 e l'indirizzo IP del controller locale." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Secondi tra gli aggiornamenti" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/pl.json b/homeassistant/components/control4/translations/pl.json index 615bf304fb4..3064a0044b1 100644 --- a/homeassistant/components/control4/translations/pl.json +++ b/homeassistant/components/control4/translations/pl.json @@ -14,6 +14,16 @@ "host": "Adres IP", "password": "[%key_id:common::config_flow::data::password%]", "username": "[%key_id:common::config_flow::data::username%]" + }, + "description": "Prosz\u0119 wprowadzi\u0107 dane swojego konta Control4 oraz adres IP lokalnego kontrolera." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji [sekundy]" } } } diff --git a/homeassistant/components/control4/translations/zh-Hant.json b/homeassistant/components/control4/translations/zh-Hant.json new file mode 100644 index 00000000000..f52e877a9d4 --- /dev/null +++ b/homeassistant/components/control4/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "IP \u4f4d\u5740", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165 Control4 \u5e33\u865f\u8cc7\u8a0a\u8207\u672c\u5730\u7aef\u63a7\u5236\u5668 IP \u4f4d\u5740\u3002" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u66f4\u65b0\u9593\u9694\u79d2\u6578" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/tr.json b/homeassistant/components/deconz/translations/tr.json new file mode 100644 index 00000000000..e73703043f3 --- /dev/null +++ b/homeassistant/components/deconz/translations/tr.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "deconz_devices": { + "title": "deCONZ se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/it.json b/homeassistant/components/demo/translations/it.json index 16477633de2..50939df5631 100644 --- a/homeassistant/components/demo/translations/it.json +++ b/homeassistant/components/demo/translations/it.json @@ -10,6 +10,7 @@ "options_1": { "data": { "bool": "Valore booleano facoltativo", + "constant": "Costante", "int": "Input numerico" } }, diff --git a/homeassistant/components/dexcom/translations/pl.json b/homeassistant/components/dexcom/translations/pl.json index a35fc314e57..24ae7a17370 100644 --- a/homeassistant/components/dexcom/translations/pl.json +++ b/homeassistant/components/dexcom/translations/pl.json @@ -12,8 +12,11 @@ "user": { "data": { "password": "[%key_id:common::config_flow::data::password%]", + "server": "Serwer", "username": "[%key_id:common::config_flow::data::username%]" - } + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce Dexcom", + "title": "Skonfiguruj integracj\u0119 Dexcom" } } }, diff --git a/homeassistant/components/enocean/translations/it.json b/homeassistant/components/enocean/translations/it.json new file mode 100644 index 00000000000..857269a6ae1 --- /dev/null +++ b/homeassistant/components/enocean/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Percorso del dongle non valido", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "invalid_dongle_path": "Nessun dongle valido trovato per questo percorso" + }, + "flow_title": "Configurazione di ENOcean", + "step": { + "detect": { + "data": { + "path": "Percorso dongle USB" + }, + "title": "Seleziona il percorso verso il tuo dongle ENOcean" + }, + "manual": { + "data": { + "path": "Percorso dongle USB" + }, + "title": "Inserisci il percorso per il tuo dongle ENOcean" + } + } + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/pl.json b/homeassistant/components/enocean/translations/pl.json index 5b4deff04bb..5f770418fef 100644 --- a/homeassistant/components/enocean/translations/pl.json +++ b/homeassistant/components/enocean/translations/pl.json @@ -1,15 +1,27 @@ { "config": { "abort": { + "invalid_dongle_path": "Niepoprawna \u015bcie\u017cka urz\u0105dzenia", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, + "error": { + "invalid_dongle_path": "Nie znaleziono urz\u0105dzenia pod wskazan\u0105 \u015bcie\u017ck\u0105" + }, "flow_title": "Konfiguracja ENOcean", "step": { "detect": { "data": { "path": "\u015acie\u017cka urz\u0105dzenia USB" - } + }, + "title": "Wybierz \u015bcie\u017ck\u0119 urz\u0105dzenia ENOcean" + }, + "manual": { + "data": { + "path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "title": "Podaj \u015bcie\u017ck\u0119 urz\u0105dzenia ENOcean" } } - } + }, + "title": "EnOcean" } \ No newline at end of file diff --git a/homeassistant/components/esphome/translations/tr.json b/homeassistant/components/esphome/translations/tr.json new file mode 100644 index 00000000000..15028c4fe65 --- /dev/null +++ b/homeassistant/components/esphome/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "title": "Ke\u015ffedilen ESPHome d\u00fc\u011f\u00fcm\u00fc" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/firmata/translations/it.json b/homeassistant/components/firmata/translations/it.json new file mode 100644 index 00000000000..79c4a093140 --- /dev/null +++ b/homeassistant/components/firmata/translations/it.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "cannot_connect": "Impossibile connettersi alla scheda Firmata durante la configurazione" + }, + "step": { + "one": "uno", + "other": "altri" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/translations/tr.json b/homeassistant/components/huawei_lte/translations/tr.json new file mode 100644 index 00000000000..a76e31fa483 --- /dev/null +++ b/homeassistant/components/huawei_lte/translations/tr.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "track_new_devices": "Yeni cihazlar\u0131 izle" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/it.json b/homeassistant/components/hue/translations/it.json index f281548e017..b2320be646b 100644 --- a/homeassistant/components/hue/translations/it.json +++ b/homeassistant/components/hue/translations/it.json @@ -59,6 +59,7 @@ "init": { "data": { "allow_how_groups": "Consenti gruppi Hue", + "allow_hue_groups": "Consenti gruppi Hue", "allow_unreachable": "Consentire alle lampadine irraggiungibili di segnalare correttamente il loro stato" } } diff --git a/homeassistant/components/humidifier/translations/it.json b/homeassistant/components/humidifier/translations/it.json index 19b9102fbf3..2d2caf1a9d7 100644 --- a/homeassistant/components/humidifier/translations/it.json +++ b/homeassistant/components/humidifier/translations/it.json @@ -6,6 +6,16 @@ "toggle": "Commuta {entity_name}", "turn_off": "Disattivare {entity_name}", "turn_on": "Attivare {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} \u00e8 impostato su una modalit\u00e0 specifica", + "is_off": "{entity_name} \u00e8 spento", + "is_on": "{entity_name} \u00e8 acceso" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} umidit\u00e0 target modificata", + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" } }, "state": { diff --git a/homeassistant/components/humidifier/translations/nl.json b/homeassistant/components/humidifier/translations/nl.json new file mode 100644 index 00000000000..d74d37d20ad --- /dev/null +++ b/homeassistant/components/humidifier/translations/nl.json @@ -0,0 +1,9 @@ +{ + "device_automation": { + "trigger_type": { + "target_humidity_changed": "{entity_name} doel luchtvochtigheid gewijzigd", + "turned_off": "{entity_name} is uitgeschakeld", + "turned_on": "{entity_name} is ingeschakeld" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ipp/translations/tr.json b/homeassistant/components/ipp/translations/tr.json new file mode 100644 index 00000000000..dbb14fe825e --- /dev/null +++ b/homeassistant/components/ipp/translations/tr.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "zeroconf_confirm": { + "title": "Ke\u015ffedilen yaz\u0131c\u0131" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/it.json b/homeassistant/components/netatmo/translations/it.json index 7095a058feb..1efe744687d 100644 --- a/homeassistant/components/netatmo/translations/it.json +++ b/homeassistant/components/netatmo/translations/it.json @@ -13,5 +13,30 @@ "title": "Scegli il metodo di autenticazione" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "Nome dell'area", + "lat_ne": "Latitudine angolo Nord-Est", + "lat_sw": "Latitudine angolo Sud-Ovest", + "lon_ne": "Longitudine angolo Nord-Est", + "lon_sw": "Longitudine angolo Sud-Ovest", + "mode": "Calcolo", + "show_on_map": "Mostra sulla mappa" + }, + "description": "Configurare un sensore meteorologico pubblico per un'area.", + "title": "Sensore meteorologico pubblico Netatmo" + }, + "public_weather_areas": { + "data": { + "new_area": "Nome area", + "weather_areas": "Aree meteorologiche" + }, + "description": "Configura i sensori meteorologici pubblici.", + "title": "Sensore meteorologico pubblico Netatmo" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/notion/translations/tr.json b/homeassistant/components/notion/translations/tr.json new file mode 100644 index 00000000000..8966b79df1b --- /dev/null +++ b/homeassistant/components/notion/translations/tr.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "no_devices": "Hesapta cihaz bulunamad\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onboarding/translations/tr.json b/homeassistant/components/onboarding/translations/tr.json new file mode 100644 index 00000000000..2fdec8c05ad --- /dev/null +++ b/homeassistant/components/onboarding/translations/tr.json @@ -0,0 +1,7 @@ +{ + "area": { + "bedroom": "Yatak odas\u0131", + "kitchen": "Mutfak", + "living_room": "Oturma odas\u0131" + } +} \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/tr.json b/homeassistant/components/onvif/translations/tr.json new file mode 100644 index 00000000000..fc82ed5bb8a --- /dev/null +++ b/homeassistant/components/onvif/translations/tr.json @@ -0,0 +1,13 @@ +{ + "options": { + "step": { + "onvif_devices": { + "data": { + "extra_arguments": "Ekstra FFMPEG arg\u00fcmanlar", + "rtsp_transport": "RTSP ta\u015f\u0131ma mekanizmas\u0131" + }, + "title": "ONVIF Cihaz Se\u00e7enekleri" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/it.json b/homeassistant/components/pi_hole/translations/it.json index d8ee9d3c6b7..5b7329f31d6 100644 --- a/homeassistant/components/pi_hole/translations/it.json +++ b/homeassistant/components/pi_hole/translations/it.json @@ -10,8 +10,9 @@ "step": { "user": { "data": { - "api_key": "Chiave API (opzionale)", + "api_key": "Chiave API", "host": "Host", + "location": "Posizione", "name": "Nome", "port": "Porta", "ssl": "Utilizzare SSL", diff --git a/homeassistant/components/pi_hole/translations/nl.json b/homeassistant/components/pi_hole/translations/nl.json index 16ef25a15fa..7c399fc9ae6 100644 --- a/homeassistant/components/pi_hole/translations/nl.json +++ b/homeassistant/components/pi_hole/translations/nl.json @@ -6,6 +6,13 @@ }, "error": { "cannot_connect": "Kon niet verbinden" + }, + "step": { + "user": { + "data": { + "location": "Locatie" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/it.json b/homeassistant/components/rfxtrx/translations/it.json new file mode 100644 index 00000000000..a0ccd718ca4 --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/it.json @@ -0,0 +1,4 @@ +{ + "one": "uno", + "other": "altri" +} \ No newline at end of file diff --git a/homeassistant/components/sms/translations/pl.json b/homeassistant/components/sms/translations/pl.json index e315dd7c5bf..eec34cc0197 100644 --- a/homeassistant/components/sms/translations/pl.json +++ b/homeassistant/components/sms/translations/pl.json @@ -7,6 +7,14 @@ "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", "unknown": "Nieoczekiwany b\u0142\u0105d." + }, + "step": { + "user": { + "data": { + "device": "Urz\u0105dzenie" + }, + "title": "Po\u0142\u0105cz z modemem" + } } } } \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/it.json b/homeassistant/components/syncthru/translations/it.json new file mode 100644 index 00000000000..d8d0b70a8ed --- /dev/null +++ b/homeassistant/components/syncthru/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_url": "URL non valido", + "syncthru_not_supported": "Il dispositivo non supporta SyncThru", + "unknown_state": "Stato della stampante sconosciuto, verificare l'URL e la connettivit\u00e0 di rete" + }, + "flow_title": "Stampante Samsung SyncThru: {name}", + "step": { + "confirm": { + "data": { + "name": "Nome", + "url": "URL interfaccia Web" + } + }, + "user": { + "data": { + "name": "Nome", + "url": "URL interfaccia Web" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wemo/translations/tr.json b/homeassistant/components/wemo/translations/tr.json new file mode 100644 index 00000000000..411a536ceed --- /dev/null +++ b/homeassistant/components/wemo/translations/tr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda Wemo cihaz\u0131 bulunamad\u0131." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/it.json b/homeassistant/components/wolflink/translations/it.json new file mode 100644 index 00000000000..6e88a6b3e29 --- /dev/null +++ b/homeassistant/components/wolflink/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "device": { + "data": { + "device_name": "Dispositivo" + }, + "title": "Selezionare il dispositivo WOLF" + }, + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Connessione WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/pl.json b/homeassistant/components/wolflink/translations/pl.json index c59e4297382..483c73aac3d 100644 --- a/homeassistant/components/wolflink/translations/pl.json +++ b/homeassistant/components/wolflink/translations/pl.json @@ -9,11 +9,18 @@ "unknown": "[%key::common::config_flow::error::unknown%]" }, "step": { + "device": { + "data": { + "device_name": "Urz\u0105dzenie" + }, + "title": "Wybierz urz\u0105dzenie WOLF" + }, "user": { "data": { "password": "[%key_id:common::config_flow::data::password%]", "username": "[%key_id:common::config_flow::data::username%]" - } + }, + "title": "Po\u0142\u0105czenie WOLF SmartSet" } } } diff --git a/homeassistant/components/wolflink/translations/ru.json b/homeassistant/components/wolflink/translations/ru.json new file mode 100644 index 00000000000..a15fb94c8ff --- /dev/null +++ b/homeassistant/components/wolflink/translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "device": { + "data": { + "device_name": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e WOLF" + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "title": "WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.it.json b/homeassistant/components/wolflink/translations/sensor.it.json new file mode 100644 index 00000000000..e5eb50e6586 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.it.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x ACS", + "abgasklappe": "Serranda fumi", + "absenkbetrieb": "Modalit\u00e0 contrattempo", + "absenkstop": "Stop al contrattempo", + "aktiviert": "Attivato", + "antilegionellenfunktion": "Funzione anti-legionella", + "at_abschaltung": "Spegnimento OT", + "at_frostschutz": "Protezione antigelo OT", + "aus": "Disabilitato", + "auto": "Automatico", + "auto_off_cool": "Autospegnimento Raffreddamento", + "auto_on_cool": "Autoaccensione Raffreddamento", + "automatik_aus": "Spegnimento automatico", + "automatik_ein": "Accensione automatica", + "bereit_keine_ladung": "Pronto, non in caricamento", + "betrieb_ohne_brenner": "Funzionamento senza bruciatore", + "cooling": "Raffreddamento", + "deaktiviert": "Inattivo", + "dhw_prior": "Priorit\u00e0 ACS", + "eco": "Eco", + "ein": "Abilitato", + "estrichtrocknung": "Asciugatura pavimento", + "externe_deaktivierung": "Disattivazione esterna", + "fernschalter_ein": "Telecomando abilitato", + "frost_heizkreis": "Circuito di riscaldamento antigelo", + "frost_warmwasser": "Gelo ACS", + "frostschutz": "Protezione antigelo", + "gasdruck": "Pressione del gas", + "glt_betrieb": "Modalit\u00e0 BMS", + "gradienten_uberwachung": "Monitoraggio del gradiente", + "heizbetrieb": "Modalit\u00e0 di riscaldamento", + "heizgerat_mit_speicher": "Scaldabagno", + "heizung": "Riscaldamento", + "initialisierung": "Inizializzazione", + "kalibration": "Calibrazione", + "kalibration_heizbetrieb": "Calibrazione della modalit\u00e0 di riscaldamento", + "kalibration_kombibetrieb": "Calibrazione della modalit\u00e0 combinata", + "kalibration_warmwasserbetrieb": "Calibrazione ACS", + "kaskadenbetrieb": "Funzionamento a cascata", + "kombibetrieb": "Modalit\u00e0 combinata", + "kombigerat": "Caldaia combinata", + "kombigerat_mit_solareinbindung": "Caldaia combinata con integrazione solare", + "mindest_kombizeit": "Tempo minimo combinato", + "nachlauf_heizkreispumpe": "Pompa del circuito di riscaldamento accesa", + "nachspulen": "Postlavaggio", + "nur_heizgerat": "Solo caldaia", + "parallelbetrieb": "Modalit\u00e0 parallela", + "partymodus": "Modalit\u00e0 festa", + "perm_cooling": "Raffreddamento Permanente", + "permanent": "Permanente", + "permanentbetrieb": "Modalit\u00e0 permanente", + "reduzierter_betrieb": "Modalit\u00e0 limitata", + "rt_abschaltung": "Spegnimento RT", + "rt_frostschutz": "Protezione antigelo RT", + "ruhekontakt": "Contatto di riposo", + "schornsteinfeger": "Test delle emissioni", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Avvio graduale", + "solarbetrieb": "Modalit\u00e0 Solare", + "sparbetrieb": "Modalit\u00e0 Economica", + "sparen": "Economia", + "spreizung_hoch": "dT troppo grande", + "spreizung_kf": "Diffusione KF", + "stabilisierung": "Stabilizzazione", + "standby": "In attesa", + "start": "Inizio", + "storung": "Guasto", + "taktsperre": "Blocco del ciclo", + "telefonfernschalter": "Interruttore a telecomando telefonico", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Modalit\u00e0 Vacanza", + "ventilprufung": "Test della valvola", + "vorspulen": "Prelavaggio", + "warmwasser": "ACS", + "warmwasser_schnellstart": "Avvio rapido ACS", + "warmwasserbetrieb": "Modalit\u00e0 ACS", + "warmwassernachlauf": "ACS in funzione", + "warmwasservorrang": "Priorit\u00e0 ACS", + "zunden": "Accensione" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.pl.json b/homeassistant/components/wolflink/translations/sensor.pl.json new file mode 100644 index 00000000000..d06189d4ae4 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.pl.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x CWU", + "abgasklappe": "Przepustnica spalin", + "absenkbetrieb": "Wych\u0142adzanie", + "absenkstop": "Zatrzymanie wych\u0142adzania", + "aktiviert": "W\u0142\u0105czone", + "antilegionellenfunktion": "Funkcja antylegionella", + "at_abschaltung": "Granica wy\u0142\u0105czenia temperatura zewn\u0119trzna", + "at_frostschutz": "AT Ochrona antyzamro\u017ceniowa", + "aus": "Wy\u0142\u0105czone", + "auto": "Automatyczny", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatyczne wy\u0142\u0105czanie", + "automatik_ein": "Automatyczne w\u0142\u0105czenie", + "bereit_keine_ladung": "Gotowe, nie \u0142aduj\u0119", + "betrieb_ohne_brenner": "Praca bez palnika", + "cooling": "Ch\u0142odzenie", + "deaktiviert": "Nieaktywne", + "dhw_prior": "Priorytet CWU", + "eco": "Eco", + "ein": "W\u0142\u0105czony", + "estrichtrocknung": "Suszenie jastrychu", + "externe_deaktivierung": "Zewn\u0119trzna dezaktywacja", + "fernschalter_ein": "Zdalne sterowanie w\u0142\u0105czone", + "frost_heizkreis": "Mr\u00f3z w obwodzie grzewczym", + "frost_warmwasser": "Mr\u00f3z na CWU", + "frostschutz": "Zabezpieczenie przed zamarzaniem", + "gasdruck": "Ci\u015bnienie gazu", + "glt_betrieb": "Tryb BMS", + "gradienten_uberwachung": "Monitorowanie gradientu", + "heizbetrieb": "Tryb ogrzewania", + "heizgerat_mit_speicher": "Urz\u0105dzenie grzewcze z zasobnikiem", + "heizung": "Ogrzewanie", + "initialisierung": "Inicjalizacja", + "kalibration": "Kalibracja", + "kalibration_heizbetrieb": "Kalibracja trybu ogrzewania", + "kalibration_kombibetrieb": "Kalibracja trybu kombi", + "kalibration_warmwasserbetrieb": "Kalibracja CWU", + "kaskadenbetrieb": "Tryb kaskadowy", + "kombibetrieb": "Tryb kombi", + "kombigerat": "Urz\u0105dzenie kombi", + "kombigerat_mit_solareinbindung": "Urz\u0105dzenie kombi zintegrowane z solarem", + "mindest_kombizeit": "Minimalny czas kombi", + "nachlauf_heizkreispumpe": "Wybieg pompy obiegu grzewczego", + "nachspulen": "Przedmuchiwanie", + "nur_heizgerat": "Tylko urz\u0105dzenie grzewcze", + "parallelbetrieb": "Tryb r\u00f3wnoleg\u0142y", + "partymodus": "Tryb imprezowy", + "perm_cooling": "PermCooling", + "permanent": "Sta\u0142y", + "permanentbetrieb": "Tryb pracy ci\u0105g\u0142ej", + "reduzierter_betrieb": "Tryb ograniczony", + "rt_abschaltung": "Wy\u0142\u0105czenie RT", + "rt_frostschutz": "RT Ochrona antyzamro\u017ceniowa", + "ruhekontakt": "Zestyk spoczynkowy", + "schornsteinfeger": "Kominiarz", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "\u0141agodny rozruch", + "solarbetrieb": "Tryb solarny", + "sparbetrieb": "Tryb oszcz\u0119dzania", + "sparen": "Oszcz\u0119dzanie", + "spreizung_hoch": "Zbyt du\u017cy zakres", + "spreizung_kf": "Zakres KF", + "stabilisierung": "Stabilizacja", + "standby": "Tryb czuwania", + "start": "Start", + "storung": "Usterka", + "taktsperre": "Blokada taktu", + "telefonfernschalter": "Zdalne sterowanie za pomoc\u0105 telefonu", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Tryb urlopowy", + "ventilprufung": "Kontrola zawor\u00f3w", + "vorspulen": "P\u0142ukanie wst\u0119pne", + "warmwasser": "CWU", + "warmwasser_schnellstart": "Szybki start CWU", + "warmwasserbetrieb": "Tryb CWU", + "warmwassernachlauf": "Wybieg CWU", + "warmwasservorrang": "Priorytet CWU", + "zunden": "Zap\u0142on" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ru.json b/homeassistant/components/wolflink/translations/sensor.ru.json index 218b962a096..cace84a9da3 100644 --- a/homeassistant/components/wolflink/translations/sensor.ru.json +++ b/homeassistant/components/wolflink/translations/sensor.ru.json @@ -1,12 +1,79 @@ { "state": { "wolflink__state": { + "1_x_warmwasser": "1 \u0445 \u0413\u0412\u0421", + "abgasklappe": "\u0417\u0430\u0441\u043b\u043e\u043d\u043a\u0430 \u0434\u044b\u043c\u043e\u0432\u044b\u0445 \u0433\u0430\u0437\u043e\u0432", + "absenkbetrieb": "\u0420\u0435\u0436\u0438\u043c \u0430\u0432\u0430\u0440\u0438\u0438", + "absenkstop": "\u0410\u0432\u0430\u0440\u0438\u0439\u043d\u0430\u044f \u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430", + "aktiviert": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u043e", + "antilegionellenfunktion": "\u0424\u0443\u043d\u043a\u0446\u0438\u044f \u0430\u043d\u0442\u0438-\u043b\u0435\u0433\u0438\u043e\u043d\u0435\u043b\u043b\u044b", + "at_abschaltung": "\u041e\u0422 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "at_frostschutz": "\u041e\u0422 \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "aus": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "auto": "\u0410\u0432\u0442\u043e", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "automatik_ein": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "bereit_keine_ladung": "\u0413\u043e\u0442\u043e\u0432, \u043d\u0435 \u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442\u0441\u044f", + "betrieb_ohne_brenner": "\u0420\u0430\u0431\u043e\u0442\u0430 \u0431\u0435\u0437 \u0433\u043e\u0440\u0435\u043b\u043a\u0438", + "cooling": "\u041e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "deaktiviert": "\u041d\u0435\u0430\u043a\u0442\u0438\u0432\u043d\u043e", + "dhw_prior": "DHWPrior", + "eco": "\u042d\u043a\u043e", + "ein": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "estrichtrocknung": "\u0421\u0443\u0448\u043a\u0430", + "externe_deaktivierung": "\u0412\u043d\u0435\u0448\u043d\u044f\u044f \u0434\u0435\u0430\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u044f", + "fernschalter_ein": "\u0414\u0438\u0441\u0442\u0430\u043d\u0446\u0438\u043e\u043d\u043d\u043e\u0435 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "frost_heizkreis": "\u0417\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u0435 \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "frost_warmwasser": "\u0417\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u0435 \u0413\u0412\u0421", + "frostschutz": "\u0417\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "gasdruck": "\u0414\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0433\u0430\u0437\u0430", + "glt_betrieb": "\u0420\u0435\u0436\u0438\u043c BMS", + "gradienten_uberwachung": "\u0413\u0440\u0430\u0434\u0438\u0435\u043d\u0442\u043d\u044b\u0439 \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433", + "heizbetrieb": "\u0420\u0435\u0436\u0438\u043c \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "heizgerat_mit_speicher": "\u041a\u043e\u0442\u0435\u043b \u0441 \u0446\u0438\u043b\u0438\u043d\u0434\u0440\u043e\u043c", + "heizung": "\u041e\u0431\u043e\u0433\u0440\u0435\u0432", + "initialisierung": "\u0418\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f", + "kalibration": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430", + "kalibration_heizbetrieb": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430 \u0440\u0435\u0436\u0438\u043c\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "kalibration_kombibetrieb": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430 \u0432 \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u043c \u0440\u0435\u0436\u0438\u043c\u0435", + "kalibration_warmwasserbetrieb": "\u041a\u0430\u043b\u0438\u0431\u0440\u043e\u0432\u043a\u0430 \u0413\u0412\u0421", + "kaskadenbetrieb": "\u041a\u0430\u0441\u043a\u0430\u0434\u043d\u0430\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f", + "kombibetrieb": "\u041a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "kombigerat": "\u0414\u0432\u0443\u0445\u043a\u043e\u043d\u0442\u0443\u0440\u043d\u044b\u0439 \u043a\u043e\u0442\u0435\u043b", + "kombigerat_mit_solareinbindung": "\u0414\u0432\u0443\u0445\u043a\u043e\u043d\u0442\u0443\u0440\u043d\u044b\u0439 \u043a\u043e\u0442\u0435\u043b \u0441 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0435\u0439 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u044b", + "mindest_kombizeit": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f", + "nachlauf_heizkreispumpe": "\u0420\u0430\u0431\u043e\u0442\u0430 \u043d\u0430\u0441\u043e\u0441\u0430 \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u043e\u0442\u043e\u043f\u043b\u0435\u043d\u0438\u044f", + "nachspulen": "\u041f\u043e\u0441\u0442-\u043f\u0440\u043e\u043c\u044b\u0432\u043a\u0430", + "nur_heizgerat": "\u0422\u043e\u043b\u044c\u043a\u043e \u0431\u043e\u0439\u043b\u0435\u0440", + "parallelbetrieb": "\u041f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "partymodus": "\u0420\u0435\u0436\u0438\u043c \u0432\u0435\u0447\u0435\u0440\u0438\u043d\u043a\u0438", + "perm_cooling": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e\u0435 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0435", + "permanent": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e", + "permanentbetrieb": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "reduzierter_betrieb": "\u041e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "rt_abschaltung": "RT \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "rt_frostschutz": "RT \u0437\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 \u0437\u0430\u043c\u0435\u0440\u0437\u0430\u043d\u0438\u044f", + "ruhekontakt": "\u041e\u0441\u0442\u0430\u043b\u044c\u043d\u043e\u0439 \u043a\u043e\u043d\u0442\u0430\u043a\u0442", + "schornsteinfeger": "\u0422\u0435\u0441\u0442 \u043d\u0430 \u0432\u044b\u0431\u0440\u043e\u0441\u044b", + "smart_grid": "\u0423\u043c\u043d\u0430\u044f \u0441\u0435\u0442\u044c \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u0441\u043d\u0430\u0431\u0436\u0435\u043d\u0438\u044f", + "smart_home": "\u0423\u043c\u043d\u044b\u0439 \u0434\u043e\u043c", + "softstart": "\u041c\u044f\u0433\u043a\u0438\u0439 \u0437\u0430\u043f\u0443\u0441\u043a", + "solarbetrieb": "\u0421\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c", + "sparbetrieb": "\u0420\u0435\u0436\u0438\u043c \u044d\u043a\u043e\u043d\u043e\u043c\u0438\u0438", + "sparen": "\u042d\u043a\u043e\u043d\u043e\u043c\u0438\u044f", + "spreizung_hoch": "dT \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0448\u0438\u0440\u043e\u043a\u0438\u0439", + "spreizung_kf": "\u0421\u043f\u0440\u0435\u0434 KF", "stabilisierung": "\u0421\u0442\u0430\u0431\u0438\u043b\u0438\u0437\u0430\u0446\u0438\u044f", "standby": "\u041e\u0436\u0438\u0434\u0430\u043d\u0438\u0435", "start": "\u0417\u0430\u043f\u0443\u0441\u043a", "storung": "\u041e\u0448\u0438\u0431\u043a\u0430", "taktsperre": "\u0410\u043d\u0442\u0438-\u0446\u0438\u043a\u043b", + "telefonfernschalter": "\u0414\u0438\u0441\u0442\u0430\u043d\u0446\u0438\u043e\u043d\u043d\u043e\u0435 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0441 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430", "test": "\u0422\u0435\u0441\u0442", + "tpw": "TPW", + "urlaubsmodus": "\u0420\u0435\u0436\u0438\u043c \"\u0432\u044b\u0445\u043e\u0434\u043d\u044b\u0435\"", "ventilprufung": "\u0422\u0435\u0441\u0442 \u043a\u043b\u0430\u043f\u0430\u043d\u0430", "vorspulen": "\u041f\u0440\u043e\u043c\u044b\u0432\u043a\u0430 \u0432\u0445\u043e\u0434\u0430", "warmwasser": "\u0413\u0412\u0421", diff --git a/homeassistant/components/wolflink/translations/sensor.tr.json b/homeassistant/components/wolflink/translations/sensor.tr.json new file mode 100644 index 00000000000..91160e3569a --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.tr.json @@ -0,0 +1,10 @@ +{ + "state": { + "wolflink__state": { + "standby": "Bekleme modu", + "start": "Ba\u015flat", + "storung": "Hata", + "test": "Test" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.zh-Hant.json b/homeassistant/components/wolflink/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..c2be9263bcf --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.zh-Hant.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "\u71c3\u6c23\u8abf\u7bc0\u95a5", + "absenkbetrieb": "\u5012\u9000\u6a21\u5f0f", + "absenkstop": "\u5012\u9000\u505c\u6b62", + "aktiviert": "\u5df2\u555f\u52d5", + "antilegionellenfunktion": "\u6297\u83cc\u529f\u80fd", + "at_abschaltung": "OT \u95dc\u6a5f", + "at_frostschutz": "OT \u9632\u51cd", + "aus": "\u5df2\u95dc\u9589", + "auto": "\u81ea\u52d5", + "auto_off_cool": "\u81ea\u52d5\u95dc\u9589\u51b7\u537b", + "auto_on_cool": "\u81ea\u52d5\u958b\u555f\u51b7\u537b", + "automatik_aus": "\u81ea\u52d5\u95dc\u9589", + "automatik_ein": "\u81ea\u52d5\u958b\u555f", + "bereit_keine_ladung": "\u5c31\u7dd2\uff0c\u672a\u8f09\u5165", + "betrieb_ohne_brenner": "\u7121\u71c3\u71d2\u904b\u4f5c", + "cooling": "\u51b7\u6c23", + "deaktiviert": "\u672a\u555f\u52d5", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "\u5df2\u555f\u7528", + "estrichtrocknung": "\u71d9\u677f\u4e7e\u71e5", + "externe_deaktivierung": "\u5916\u90e8\u505c\u7528", + "fernschalter_ein": "\u9060\u7aef\u63a7\u5236\u5df2\u958b\u555f", + "frost_heizkreis": "Heating circuit frost", + "frost_warmwasser": "DHW \u971c", + "frostschutz": "\u9632\u51cd", + "gasdruck": "\u6c23\u58d3", + "glt_betrieb": "BMS \u6a21\u5f0f", + "gradienten_uberwachung": "\u50be\u659c\u76e3\u63a7", + "heizbetrieb": "\u6696\u6c23\u6a21\u5f0f", + "heizgerat_mit_speicher": "\u6c7d\u7f38\u934b\u7210", + "heizung": "\u6696\u6c23", + "initialisierung": "\u521d\u59cb\u5316", + "kalibration": "\u6821\u6b63", + "kalibration_heizbetrieb": "\u6696\u6c23\u6a21\u5f0f\u6821\u6b63", + "kalibration_kombibetrieb": "\u6df7\u5408\u6a21\u5f0f\u6821\u6b63", + "kalibration_warmwasserbetrieb": "DHW \u6821\u6b63", + "kaskadenbetrieb": "\u4e32\u9023\u64cd\u4f5c", + "kombibetrieb": "\u6df7\u5408\u6a21\u5f0f", + "kombigerat": "\u6df7\u5408\u934b\u7210", + "kombigerat_mit_solareinbindung": "\u6574\u5408\u592a\u967d\u80fd\u6df7\u5408\u934b\u7210", + "mindest_kombizeit": "\u6700\u4f4e\u6df7\u5408\u6642\u9593", + "nachlauf_heizkreispumpe": "\u8ff4\u8def\u5e6b\u6d66\u904b\u884c\u6696\u6c23", + "nachspulen": "\u6c96\u6d17\u5f8c", + "nur_heizgerat": "\u50c5\u934b\u7210", + "parallelbetrieb": "\u4e26\u884c\u6a21\u5f0f", + "partymodus": "\u6d3e\u5c0d\u6a21\u5f0f", + "perm_cooling": "PermCooling", + "permanent": "\u6c38\u4e45", + "permanentbetrieb": "\u6c38\u4e45\u6a21\u5f0f", + "reduzierter_betrieb": "\u9650\u5236\u6a21\u5f0f", + "rt_abschaltung": "RT \u95dc\u6a5f", + "rt_frostschutz": "RT \u9632\u51cd", + "ruhekontakt": "Rest contact", + "schornsteinfeger": "\u6392\u653e\u6e2c\u8a66", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "\u8edf\u958b\u6a5f", + "solarbetrieb": "\u592a\u967d\u80fd\u6a21\u5f0f", + "sparbetrieb": "\u7bc0\u80fd\u6a21\u5f0f", + "sparen": "\u7bc0\u80fd", + "spreizung_hoch": "DT \u904e\u5bec", + "spreizung_kf": "Spread KF", + "stabilisierung": "\u7a69\u5b9a", + "standby": "\u5f85\u547d", + "start": "\u958b\u59cb", + "storung": "\u6545\u969c", + "taktsperre": "\u53cd\u5faa\u74b0", + "telefonfernschalter": "\u96fb\u8a71\u9060\u7aef\u958b\u95dc", + "test": "\u6e2c\u8a66", + "tpw": "TPW", + "urlaubsmodus": "\u5047\u65e5\u6a21\u5f0f", + "ventilprufung": "\u95a5\u9580\u6e2c\u8a66", + "vorspulen": "Entry rinsing", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW \u5feb\u901f\u555f\u52d5", + "warmwasserbetrieb": "DHW \u6a21\u5f0f", + "warmwassernachlauf": "DHW \u904b\u884c", + "warmwasservorrang": "DHW \u512a\u5148", + "zunden": "\u9ede\u706b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/zh-Hant.json b/homeassistant/components/wolflink/translations/zh-Hant.json new file mode 100644 index 00000000000..13eb90b55db --- /dev/null +++ b/homeassistant/components/wolflink/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "device": { + "data": { + "device_name": "\u8a2d\u5099" + }, + "title": "\u9078\u64c7 WOLF \u8a2d\u5099" + }, + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "title": "WOLF SmartSet connection" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zerproc/translations/tr.json b/homeassistant/components/zerproc/translations/tr.json new file mode 100644 index 00000000000..49fa9545e94 --- /dev/null +++ b/homeassistant/components/zerproc/translations/tr.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "no_devices_found": "A\u011fda cihaz bulunamad\u0131" + } + } +} \ No newline at end of file From 5cf7b1b1bc9554f5e7cf8547350943cffac196ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jul 2020 14:18:43 -1000 Subject: [PATCH 079/362] Ensure we do not start discovered flows until after the started event has fired (#38047) * Ensure we do not start discovered flows until after the start event has fired This change makes zeroconf and ssdp match discovery behavior of not creating config flows until the start event has been fired. This prevents config flow creation/dependency installs for discovered config flows from competing for cpu time during startup. * Start discovery/service browser/ssdp when EVENT_HOMEASSISTANT_STARTED is fired instead of EVENT_HOMEASSISTANT_START --- .../components/discovery/__init__.py | 4 ++-- homeassistant/components/ssdp/__init__.py | 5 ++-- homeassistant/components/zeroconf/__init__.py | 9 ++++++- tests/components/discovery/test_init.py | 11 +++++---- tests/components/zeroconf/test_init.py | 24 ++++++++++++++++++- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 37320c80008..65392fa767a 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_discover, async_load_platform @@ -209,7 +209,7 @@ async def async_setup(hass, config): """Schedule the first discovery when Home Assistant starts up.""" async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, schedule_first) return True diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 94e256f0523..11e58020c4f 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -7,6 +7,7 @@ import aiohttp from defusedxml import ElementTree from netdisco import ssdp, util +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.generated.ssdp import SSDP from homeassistant.helpers.event import async_track_time_interval @@ -33,12 +34,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass, config): """Set up the SSDP integration.""" - async def initialize(): + async def initialize(_): scanner = Scanner(hass) await scanner.async_scan(None) async_track_time_interval(hass, scanner.async_scan, SCAN_INTERVAL) - hass.loop.create_task(initialize()) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, initialize) return True diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 7534ad39541..06832386621 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -21,6 +21,7 @@ from homeassistant import util from homeassistant.const import ( ATTR_NAME, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, __version__, ) @@ -247,7 +248,13 @@ def setup(hass, config): if HOMEKIT_TYPE not in ZEROCONF: types.append(HOMEKIT_TYPE) - HaServiceBrowser(zeroconf, types, handlers=[service_update]) + def zeroconf_hass_started(_event): + """Start the service browser.""" + + _LOGGER.debug("Starting Zeroconf browser") + HaServiceBrowser(zeroconf, types, handlers=[service_update]) + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STARTED, zeroconf_hass_started) return True diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index da04793e2a9..26f79e3e62f 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -6,6 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.util.dt import utcnow from tests.async_mock import patch @@ -36,10 +37,12 @@ def netdisco_mock(): async def mock_discovery(hass, discoveries, config=BASE_CONFIG): """Mock discoveries.""" - result = await async_setup_component(hass, "discovery", config) - assert result - - await hass.async_start() + with patch("homeassistant.components.zeroconf.async_get_instance"): + assert await async_setup_component(hass, "discovery", config) + await hass.async_block_till_done() + await hass.async_start() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() with patch.object(discovery, "_discover", discoveries), patch( "homeassistant.components.discovery.async_discover", return_value=mock_coro() diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 412e1f5f3f5..0c32e60ecca 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -4,7 +4,7 @@ from zeroconf import InterfaceChoice, IPVersion, ServiceInfo, ServiceStateChange from homeassistant.components import zeroconf from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.generated import zeroconf as zc_gen from homeassistant.setup import async_setup_component @@ -76,6 +76,8 @@ async def test_setup(hass, mock_zeroconf): ) as mock_service_browser: mock_zeroconf.get_service_info.side_effect = get_service_info_mock assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 expected_flow_calls = 0 @@ -97,6 +99,8 @@ async def test_setup_with_default_interface(hass, mock_zeroconf): assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_DEFAULT_INTERFACE: True}} ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.Default) @@ -123,6 +127,8 @@ async def test_setup_without_ipv6(hass, mock_zeroconf): assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_IPV6: False}} ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with(ip_version=IPVersion.V4Only) @@ -136,6 +142,8 @@ async def test_setup_with_ipv6(hass, mock_zeroconf): assert await async_setup_component( hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_IPV6: True}} ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with() @@ -147,6 +155,8 @@ async def test_setup_with_ipv6_default(hass, mock_zeroconf): ): mock_zeroconf.get_service_info.side_effect = get_service_info_mock assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert mock_zeroconf.called_with() @@ -164,6 +174,8 @@ async def test_homekit_match_partial_space(hass, mock_zeroconf): "LIFX bulb", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 @@ -183,6 +195,8 @@ async def test_homekit_match_partial_dash(hass, mock_zeroconf): "Rachio-fa46ba", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 @@ -202,6 +216,8 @@ async def test_homekit_match_full(hass, mock_zeroconf): "BSB002", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() homekit_mock = get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED) info = homekit_mock("_hap._tcp.local.", "BSB002._hap._tcp.local.") @@ -226,6 +242,8 @@ async def test_homekit_already_paired(hass, mock_zeroconf): "tado", HOMEKIT_STATUS_PAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 2 @@ -246,6 +264,8 @@ async def test_homekit_invalid_paring_status(hass, mock_zeroconf): "tado", b"invalid" ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 @@ -265,6 +285,8 @@ async def test_homekit_not_paired(hass, mock_zeroconf): "this_will_not_match_any_integration", HOMEKIT_STATUS_UNPAIRED ) assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 From 6e87c2ad3e4ce6a6db8d3ce3997b5630a28cd5a0 Mon Sep 17 00:00:00 2001 From: Donnie Date: Tue, 21 Jul 2020 17:19:07 -0700 Subject: [PATCH 080/362] Support default transition in light profiles (#36747) Co-authored-by: Paulus Schoutsen --- homeassistant/components/light/__init__.py | 27 ++++++- tests/components/light/test_init.py | 84 ++++++++++++++++++---- 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 0a3c087950e..14a870696ce 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -123,7 +123,12 @@ LIGHT_TURN_ON_SCHEMA = { PROFILE_SCHEMA = vol.Schema( - vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) + vol.Any( + vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)), + vol.ExactSequence( + (str, cv.small_float, cv.small_float, cv.byte, cv.positive_int) + ), + ) ) _LOGGER = logging.getLogger(__name__) @@ -141,6 +146,8 @@ def preprocess_turn_on_alternatives(params): if profile is not None: params.setdefault(ATTR_XY_COLOR, profile[:2]) params.setdefault(ATTR_BRIGHTNESS, profile[2]) + if len(profile) > 3: + params.setdefault(ATTR_TRANSITION, profile[3]) color_name = params.pop(ATTR_COLOR_NAME, None) if color_name is not None: @@ -313,8 +320,22 @@ class Profiles: try: for rec in reader: - profile, color_x, color_y, brightness = PROFILE_SCHEMA(rec) - profiles[profile] = (color_x, color_y, brightness) + ( + profile, + color_x, + color_y, + brightness, + *transition, + ) = PROFILE_SCHEMA(rec) + + transition = transition[0] if transition else 0 + + profiles[profile] = ( + color_x, + color_y, + brightness, + transition, + ) except vol.MultipleInvalid as ex: _LOGGER.error( "Error parsing light profile from %s: %s", profile_path, ex diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index b46be5c926c..1660ec422f3 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -243,12 +243,14 @@ class TestLight(unittest.TestCase): assert {} == data # One of the light profiles - prof_name, prof_h, prof_s, prof_bri = "relax", 35.932, 69.412, 144 + prof_name, prof_h, prof_s, prof_bri, prof_t = "relax", 35.932, 69.412, 144, 0 # Test light profiles common.turn_on(self.hass, ent1.entity_id, profile=prof_name) # Specify a profile and a brightness attribute to overwrite it - common.turn_on(self.hass, ent2.entity_id, profile=prof_name, brightness=100) + common.turn_on( + self.hass, ent2.entity_id, profile=prof_name, brightness=100, transition=1 + ) self.hass.block_till_done() @@ -256,12 +258,14 @@ class TestLight(unittest.TestCase): assert { light.ATTR_BRIGHTNESS: prof_bri, light.ATTR_HS_COLOR: (prof_h, prof_s), + light.ATTR_TRANSITION: prof_t, } == data _, data = ent2.last_call("turn_on") assert { light.ATTR_BRIGHTNESS: 100, light.ATTR_HS_COLOR: (prof_h, prof_s), + light.ATTR_TRANSITION: 1, } == data # Test toggle with parameters @@ -271,6 +275,7 @@ class TestLight(unittest.TestCase): assert { light.ATTR_BRIGHTNESS: 255, light.ATTR_HS_COLOR: (prof_h, prof_s), + light.ATTR_TRANSITION: prof_t, } == data # Test bad data @@ -314,8 +319,8 @@ class TestLight(unittest.TestCase): # Setup a wrong light file with open(user_light_file, "w") as user_file: - user_file.write("id,x,y,brightness\n") - user_file.write("I,WILL,NOT,WORK\n") + user_file.write("id,x,y,brightness,transition\n") + user_file.write("I,WILL,NOT,WORK,EVER\n") assert not setup_component( self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} @@ -347,7 +352,11 @@ class TestLight(unittest.TestCase): _, data = ent1.last_call("turn_on") assert light.is_on(self.hass, ent1.entity_id) - assert {light.ATTR_HS_COLOR: (71.059, 100), light.ATTR_BRIGHTNESS: 100} == data + assert { + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 100, + light.ATTR_TRANSITION: 0, + } == data common.turn_on(self.hass, ent1.entity_id, profile="test_off") @@ -356,7 +365,48 @@ class TestLight(unittest.TestCase): _, data = ent1.last_call("turn_off") assert not light.is_on(self.hass, ent1.entity_id) - assert {} == data + assert {light.ATTR_TRANSITION: 0} == data + + def test_light_profiles_with_transition(self): + """Test light profiles with transition.""" + platform = getattr(self.hass.components, "test.light") + platform.init() + + user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE) + + with open(user_light_file, "w") as user_file: + user_file.write("id,x,y,brightness,transition\n") + user_file.write("test,.4,.6,100,2\n") + user_file.write("test_off,0,0,0,0\n") + + assert setup_component( + self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} + ) + self.hass.block_till_done() + + ent1, _, _ = platform.ENTITIES + + common.turn_on(self.hass, ent1.entity_id, profile="test") + + self.hass.block_till_done() + + _, data = ent1.last_call("turn_on") + + assert light.is_on(self.hass, ent1.entity_id) + assert { + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 100, + light.ATTR_TRANSITION: 2, + } == data + + common.turn_on(self.hass, ent1.entity_id, profile="test_off") + + self.hass.block_till_done() + + _, data = ent1.last_call("turn_off") + + assert not light.is_on(self.hass, ent1.entity_id) + assert {light.ATTR_TRANSITION: 0} == data def test_default_profiles_group(self): """Test default turn-on light profile for all lights.""" @@ -377,7 +427,9 @@ class TestLight(unittest.TestCase): return StringIO(profile_data) return real_open(path, *args, **kwargs) - profile_data = "id,x,y,brightness\ngroup.all_lights.default,.4,.6,99\n" + profile_data = ( + "id,x,y,brightness,transition\ngroup.all_lights.default,.4,.6,99,2\n" + ) with mock.patch("os.path.isfile", side_effect=_mock_isfile), mock.patch( "builtins.open", side_effect=_mock_open ), mock_storage(): @@ -390,7 +442,11 @@ class TestLight(unittest.TestCase): common.turn_on(self.hass, ent.entity_id) self.hass.block_till_done() _, data = ent.last_call("turn_on") - assert {light.ATTR_HS_COLOR: (71.059, 100), light.ATTR_BRIGHTNESS: 99} == data + assert { + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 99, + light.ATTR_TRANSITION: 2, + } == data def test_default_profiles_light(self): """Test default turn-on light profile for a specific light.""" @@ -412,9 +468,9 @@ class TestLight(unittest.TestCase): return real_open(path, *args, **kwargs) profile_data = ( - "id,x,y,brightness\n" - + "group.all_lights.default,.3,.5,200\n" - + "light.ceiling_2.default,.6,.6,100\n" + "id,x,y,brightness,transition\n" + + "group.all_lights.default,.3,.5,200,0\n" + + "light.ceiling_2.default,.6,.6,100,3\n" ) with mock.patch("os.path.isfile", side_effect=_mock_isfile), mock.patch( "builtins.open", side_effect=_mock_open @@ -430,7 +486,11 @@ class TestLight(unittest.TestCase): common.turn_on(self.hass, dev.entity_id) self.hass.block_till_done() _, data = dev.last_call("turn_on") - assert {light.ATTR_HS_COLOR: (50.353, 100), light.ATTR_BRIGHTNESS: 100} == data + assert { + light.ATTR_HS_COLOR: (50.353, 100), + light.ATTR_BRIGHTNESS: 100, + light.ATTR_TRANSITION: 3, + } == data async def test_light_context(hass, hass_admin_user): From 4a5a09a0e928a4ce9780940c71e3aae5abd1f674 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jul 2020 14:29:57 -1000 Subject: [PATCH 081/362] Speed up group setup (#38048) --- homeassistant/components/group/__init__.py | 10 +++++++++- tests/components/group/test_init.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 5a2c11dc481..11c78a9b271 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -39,6 +39,7 @@ from homeassistant.loader import bind_hass # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs DOMAIN = "group" +GROUP_ORDER = "group_order" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -407,16 +408,23 @@ class Group(Entity): This method must be run in the event loop. """ + hass.data.setdefault(GROUP_ORDER, 0) + group = Group( hass, name, - order=len(hass.states.async_entity_ids(DOMAIN)), + order=hass.data[GROUP_ORDER], icon=icon, user_defined=user_defined, entity_ids=entity_ids, mode=mode, ) + # Keep track of the group order without iterating + # every state in the state machine every time + # we setup a new group + hass.data[GROUP_ORDER] += 1 + group.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id or name, hass=hass ) diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 921b810fe39..3709c4856a2 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -490,3 +490,25 @@ async def test_service_group_set_group_remove_group(hass): group_state = hass.states.get("group.user_test_group") assert group_state is None + + +async def test_group_order(hass): + """Test that order gets incremented when creating a new group.""" + hass.states.async_set("light.bowl", STATE_ON) + + assert await async_setup_component( + hass, + "group", + { + "group": { + "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"}, + "group_one": {"entities": "light.Bowl", "icon": "mdi:work"}, + "group_two": {"entities": "light.Bowl", "icon": "mdi:work"}, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("group.group_zero").attributes["order"] == 0 + assert hass.states.get("group.group_one").attributes["order"] == 1 + assert hass.states.get("group.group_two").attributes["order"] == 2 From 726d5fdd94357bf7881b8685fb834bf28cb09404 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 21 Jul 2020 19:41:42 -0500 Subject: [PATCH 082/362] Allow float values in time periods (#38023) --- homeassistant/helpers/config_validation.py | 36 ++++++++--------- tests/helpers/test_config_validation.py | 47 ++++++++++++++++++---- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 9be584403bd..e5b113f8a4d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -86,7 +86,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name -TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" +TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" # Home Assistant types byte = vol.All(vol.Coerce(int), vol.Range(min=0, max=255)) @@ -299,11 +299,11 @@ time_period_dict = vol.All( dict, vol.Schema( { - "days": vol.Coerce(int), - "hours": vol.Coerce(int), - "minutes": vol.Coerce(int), - "seconds": vol.Coerce(int), - "milliseconds": vol.Coerce(int), + "days": vol.Coerce(float), + "hours": vol.Coerce(float), + "minutes": vol.Coerce(float), + "seconds": vol.Coerce(float), + "milliseconds": vol.Coerce(float), } ), has_at_least_one_key("days", "hours", "minutes", "seconds", "milliseconds"), @@ -357,17 +357,17 @@ def time_period_str(value: str) -> timedelta: elif value.startswith("+"): value = value[1:] - try: - parsed = [int(x) for x in value.split(":")] - except ValueError: + parsed = value.split(":") + if len(parsed) not in (2, 3): raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) - - if len(parsed) == 2: - hour, minute = parsed - second = 0 - elif len(parsed) == 3: - hour, minute, second = parsed - else: + try: + hour = int(parsed[0]) + minute = int(parsed[1]) + try: + second = float(parsed[2]) + except IndexError: + second = 0 + except ValueError: raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) offset = timedelta(hours=hour, minutes=minute, seconds=second) @@ -378,10 +378,10 @@ def time_period_str(value: str) -> timedelta: return offset -def time_period_seconds(value: Union[int, str]) -> timedelta: +def time_period_seconds(value: Union[float, str]) -> timedelta: """Validate and transform seconds to a time offset.""" try: - return timedelta(seconds=int(value)) + return timedelta(seconds=float(value)) except (ValueError, TypeError): raise vol.Invalid(f"Expected seconds, got {value}") diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e6c3757ec55..7da0557c9eb 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -260,18 +260,49 @@ def test_time_period(): """Test time_period validation.""" schema = vol.Schema(cv.time_period) - options = (None, "", "hello:world", "12:", "12:34:56:78", {}, {"wrong_key": -10}) + options = ( + None, + "", + "hello:world", + "12:", + "12:34:56:78", + {}, + {"wrong_key": -10}, + "12.5:30", + "12:30.5", + "12.5:30:30", + "12:30.5:30", + ) for value in options: with pytest.raises(vol.MultipleInvalid): schema(value) - options = ("8:20", "23:59", "-8:20", "-23:59:59", "-48:00", {"minutes": 5}, 1, "5") - for value in options: - schema(value) - - assert timedelta(seconds=180) == schema("180") - assert timedelta(hours=23, minutes=59) == schema("23:59") - assert -1 * timedelta(hours=1, minutes=15) == schema("-1:15") + options = ( + ("8:20", timedelta(hours=8, minutes=20)), + ("23:59", timedelta(hours=23, minutes=59)), + ("-8:20", -1 * timedelta(hours=8, minutes=20)), + ("-1:15", -1 * timedelta(hours=1, minutes=15)), + ("-23:59:59", -1 * timedelta(hours=23, minutes=59, seconds=59)), + ("-48:00", -1 * timedelta(days=2)), + ({"minutes": 5}, timedelta(minutes=5)), + (1, timedelta(seconds=1)), + ("5", timedelta(seconds=5)), + ("180", timedelta(seconds=180)), + ("00:08:20.5", timedelta(minutes=8, seconds=20, milliseconds=500)), + ("00:23:59.999", timedelta(minutes=23, seconds=59, milliseconds=999)), + ("-00:08:20.5", -1 * timedelta(minutes=8, seconds=20, milliseconds=500)), + ( + "-12:59:59.999", + -1 * timedelta(hours=12, minutes=59, seconds=59, milliseconds=999), + ), + ({"milliseconds": 1.5}, timedelta(milliseconds=1, microseconds=500)), + ({"seconds": "1.5"}, timedelta(seconds=1, milliseconds=500)), + ({"minutes": "1.5"}, timedelta(minutes=1, seconds=30)), + ({"hours": -1.5}, -1 * timedelta(hours=1, minutes=30)), + ({"days": "-1.5"}, -1 * timedelta(days=1, hours=12)), + ) + for value, result in options: + assert schema(value) == result def test_remove_falsy(): From 0ffeb4dea4bb7235798a87ba6cdf8692190addcc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 21 Jul 2020 19:19:32 -0700 Subject: [PATCH 083/362] Add MQTT to constraints file (#38049) --- homeassistant/package_constraints.txt | 1 + script/gen_requirements_all.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 28783be60b1..b13f47f8302 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,6 +17,7 @@ home-assistant-frontend==20200716.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.0 +paho-mqtt==1.5.0 pip>=8.0.3 python-slugify==4.0.0 pytz>=2020.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d3e4d5c63fc..4625924da29 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -297,8 +297,11 @@ def gather_constraints(): return ( "\n".join( sorted( - core_requirements() - + list(gather_recursive_requirements("default_config")) + { + *core_requirements(), + *gather_recursive_requirements("default_config"), + *gather_recursive_requirements("mqtt"), + } ) + [""] ) From bbff9ff6a0903cfcd1316b19db4d822b29e74884 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 22 Jul 2020 13:08:31 +0200 Subject: [PATCH 084/362] Fix rfxtrx stop after first non light (#38057) --- homeassistant/components/rfxtrx/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index cd57bb99c40..376e8b515be 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -48,7 +48,7 @@ async def async_setup_entry( _LOGGER.error("Invalid device: %s", packet_id) continue if not supported(event): - return + continue device_id = get_device_id(event.device) if device_id in device_ids: From 3e2555e2c18676f4f2e079cfb1d97de8396a23ae Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 22 Jul 2020 16:39:50 +0200 Subject: [PATCH 085/362] Update home assistant base image (#38063) --- build.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.json b/build.json index 1e5b561591d..279a58f4c5a 100644 --- a/build.json +++ b/build.json @@ -1,11 +1,11 @@ { "image": "homeassistant/{arch}-homeassistant", "build_from": { - "aarch64": "homeassistant/aarch64-homeassistant-base:8.0.0", - "armhf": "homeassistant/armhf-homeassistant-base:8.0.0", - "armv7": "homeassistant/armv7-homeassistant-base:8.0.0", - "amd64": "homeassistant/amd64-homeassistant-base:8.0.0", - "i386": "homeassistant/i386-homeassistant-base:8.0.0" + "aarch64": "homeassistant/aarch64-homeassistant-base:8.1.0", + "armhf": "homeassistant/armhf-homeassistant-base:8.1.0", + "armv7": "homeassistant/armv7-homeassistant-base:8.1.0", + "amd64": "homeassistant/amd64-homeassistant-base:8.1.0", + "i386": "homeassistant/i386-homeassistant-base:8.1.0" }, "labels": { "io.hass.type": "core" From aa1c5fc43d78edf287440df3f5c2d0b5acb3b3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 22 Jul 2020 18:06:37 +0300 Subject: [PATCH 086/362] Various type hint improvements (#37952) --- homeassistant/block_async_io.py | 2 +- homeassistant/config_entries.py | 14 ++++++++------ homeassistant/helpers/area_registry.py | 4 ++-- homeassistant/helpers/entityfilter.py | 4 ++-- homeassistant/helpers/script.py | 10 ++++++---- homeassistant/helpers/storage.py | 3 +-- homeassistant/helpers/system_info.py | 4 ++-- homeassistant/runner.py | 14 +++++++------- setup.cfg | 2 +- 9 files changed, 30 insertions(+), 27 deletions(-) diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py index cd33a4207a8..ec56b746706 100644 --- a/homeassistant/block_async_io.py +++ b/homeassistant/block_async_io.py @@ -7,7 +7,7 @@ from homeassistant.util.async_ import protect_loop def enable() -> None: """Enable the detection of I/O in the event loop.""" # Prevent urllib3 and requests doing I/O in event loop - HTTPConnection.putrequest = protect_loop(HTTPConnection.putrequest) + HTTPConnection.putrequest = protect_loop(HTTPConnection.putrequest) # type: ignore # Currently disabled. pytz doing I/O when getting timezone. # Prevent files being opened inside the event loop diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 77efc3824df..f36e2c6accb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -10,7 +10,7 @@ import weakref import attr from homeassistant import data_entry_flow, loader -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import entity_registry from homeassistant.helpers.event import Event @@ -96,6 +96,9 @@ class OperationNotAllowed(ConfigError): """Raised when a config entry operation is not allowed.""" +UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Any] + + class ConfigEntry: """Hold a configuration entry.""" @@ -165,7 +168,7 @@ class ConfigEntry: self.unique_id = unique_id # Listeners to call on update - self.update_listeners: List = [] + self.update_listeners: List[weakref.ReferenceType[UpdateListenerType]] = [] # Function to cancel a scheduled retry self._async_cancel_retry_setup: Optional[Callable[[], Any]] = None @@ -398,11 +401,9 @@ class ConfigEntry: ) return False - def add_update_listener(self, listener: Callable) -> Callable: + def add_update_listener(self, listener: UpdateListenerType) -> CALLBACK_TYPE: """Listen for when entry is updated. - Listener: Callback function(hass, entry) - Returns function to unlisten. """ weak_listener = weakref.ref(listener) @@ -768,7 +769,8 @@ class ConfigEntries: for listener_ref in entry.update_listeners: listener = listener_ref() - self.hass.async_create_task(listener(self.hass, entry)) + if listener is not None: + self.hass.async_create_task(listener(self.hass, entry)) self._async_schedule_save() diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 72f4b2c5e6d..5def290766f 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -2,7 +2,7 @@ from asyncio import Event from collections import OrderedDict import logging -from typing import Iterable, MutableMapping, Optional, cast +from typing import Dict, Iterable, List, MutableMapping, Optional, cast import uuid import attr @@ -132,7 +132,7 @@ class AreaRegistry: self._store.async_delay_save(self._data_to_save, SAVE_DELAY) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> Dict[str, List[Dict[str, Optional[str]]]]: """Return data of area registry to store in a file.""" data = {} diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index dfcbbeb4cd0..608fae0242e 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -102,12 +102,12 @@ INCLUDE_EXCLUDE_FILTER_SCHEMA = vol.All( ) -def _glob_to_re(glob: str) -> Pattern: +def _glob_to_re(glob: str) -> Pattern[str]: """Translate and compile glob string into pattern.""" return re.compile(fnmatch.translate(glob)) -def _test_against_patterns(patterns: List[Pattern], entity_id: str) -> bool: +def _test_against_patterns(patterns: List[Pattern[str]], entity_id: str) -> bool: """Test entity against list of patterns, true if any match.""" for pattern in patterns: if pattern.match(entity_id): diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1ca13e22e9f..32a22fab9e5 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -49,7 +49,7 @@ from homeassistant.helpers.service import ( CONF_SERVICE_DATA, async_prepare_call_from_config, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util import slugify from homeassistant.util.dt import utcnow @@ -134,7 +134,7 @@ class _ScriptRun: self, hass: HomeAssistant, script: "Script", - variables: Optional[Sequence], + variables: TemplateVarsType, context: Optional[Context], log_exceptions: bool, ) -> None: @@ -724,14 +724,16 @@ class Script: self._referenced_entities = referenced return referenced - def run(self, variables=None, context=None): + def run( + self, variables: TemplateVarsType = None, context: Optional[Context] = None + ) -> None: """Run script.""" asyncio.run_coroutine_threadsafe( self.async_run(variables, context), self._hass.loop ).result() async def async_run( - self, variables: Optional[Sequence] = None, context: Optional[Context] = None + self, variables: TemplateVarsType = None, context: Optional[Context] = None ) -> None: """Run script.""" if self.is_running: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index d2b4c334937..13525b4dab1 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -95,8 +95,7 @@ class Store: the second call will wait and return the result of the first call. """ if self._load_task is None: - self._load_task = self.hass.async_add_job(self._async_load()) - assert self._load_task is not None + self._load_task = self.hass.async_create_task(self._async_load()) return await self._load_task diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 855b6153ba0..12fc07dfbd8 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,7 +1,7 @@ """Helper to gather system info.""" import os import platform -from typing import Dict +from typing import Any, Dict from homeassistant.const import __version__ as current_version from homeassistant.loader import bind_hass @@ -11,7 +11,7 @@ from .typing import HomeAssistantType @bind_hass -async def async_get_system_info(hass: HomeAssistantType) -> Dict: +async def async_get_system_info(hass: HomeAssistantType) -> Dict[str, Any]: """Return info about the system.""" info_object = { "installation_type": "Unknown", diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ae68727daf5..26e7bab7616 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -37,7 +37,7 @@ else: PolicyBase = asyncio.DefaultEventLoopPolicy # pylint: disable=invalid-name -class HassEventLoopPolicy(PolicyBase): +class HassEventLoopPolicy(PolicyBase): # type: ignore """Event loop policy for Home Assistant.""" def __init__(self, debug: bool) -> None: @@ -48,11 +48,11 @@ class HassEventLoopPolicy(PolicyBase): @property def loop_name(self) -> str: """Return name of the loop.""" - return self._loop_factory.__name__ + return self._loop_factory.__name__ # type: ignore - def new_event_loop(self): + def new_event_loop(self) -> asyncio.AbstractEventLoop: """Get the event loop.""" - loop = super().new_event_loop() + loop: asyncio.AbstractEventLoop = super().new_event_loop() loop.set_exception_handler(_async_loop_exception_handler) if self.debug: loop.set_debug(True) @@ -68,14 +68,14 @@ class HassEventLoopPolicy(PolicyBase): return loop # Copied from Python 3.9 source - def _do_shutdown(future): + def _do_shutdown(future: asyncio.Future) -> None: try: executor.shutdown(wait=True) loop.call_soon_threadsafe(future.set_result, None) except Exception as ex: # pylint: disable=broad-except loop.call_soon_threadsafe(future.set_exception, ex) - async def shutdown_default_executor(): + async def shutdown_default_executor() -> None: """Schedule the shutdown of the default executor.""" future = loop.create_future() thread = threading.Thread(target=_do_shutdown, args=(future,)) @@ -85,7 +85,7 @@ class HassEventLoopPolicy(PolicyBase): finally: thread.join() - loop.shutdown_default_executor = shutdown_default_executor + setattr(loop, "shutdown_default_executor", shutdown_default_executor) return loop diff --git a/setup.cfg b/setup.cfg index 7df396df528..6dace4932db 100644 --- a/setup.cfg +++ b/setup.cfg @@ -64,7 +64,7 @@ warn_incomplete_stub = true warn_redundant_casts = true warn_unused_configs = true -[mypy-homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*] +[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*] strict = true ignore_errors = false warn_unreachable = true From 65d1dfba625db5f4803f4afcaa5685d72df4ec4d Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 22 Jul 2020 10:55:49 -0500 Subject: [PATCH 087/362] Update automation logger to include object_id like scripts (#37948) --- .../components/automation/__init__.py | 28 +++++++++++----- homeassistant/helpers/script.py | 32 +++++++++++++------ tests/helpers/test_script.py | 19 +++++++++++ 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 3cbb98d85bd..599160534aa 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -23,7 +23,13 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import Context, CoreState, HomeAssistant, callback +from homeassistant.core import ( + Context, + CoreState, + HomeAssistant, + callback, + split_entity_id, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import condition, extract_domain_configs import homeassistant.helpers.config_validation as cv @@ -260,6 +266,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._is_enabled = False self._referenced_entities: Optional[Set[str]] = None self._referenced_devices: Optional[Set[str]] = None + self._logger = _LOGGER @property def name(self): @@ -337,13 +344,18 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """Startup with initial state or previous state.""" await super().async_added_to_hass() + self._logger = logging.getLogger( + f"{__name__}.{split_entity_id(self.entity_id)[1]}" + ) + self.action_script.update_logger(self._logger) + state = await self.async_get_last_state() if state: enable_automation = state.state == STATE_ON last_triggered = state.attributes.get("last_triggered") if last_triggered is not None: self._last_triggered = parse_datetime(last_triggered) - _LOGGER.debug( + self._logger.debug( "Loaded automation %s with state %s from state " " storage last state %s", self.entity_id, @@ -352,7 +364,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) else: enable_automation = DEFAULT_INITIAL_STATE - _LOGGER.debug( + self._logger.debug( "Automation %s not in state storage, state %s from default is used", self.entity_id, enable_automation, @@ -360,7 +372,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): if self._initial_state is not None: enable_automation = self._initial_state - _LOGGER.debug( + self._logger.debug( "Automation %s initial state %s overridden from " "config initial_state", self.entity_id, @@ -403,12 +415,12 @@ class AutomationEntity(ToggleEntity, RestoreEntity): context=trigger_context, ) - _LOGGER.info("Executing %s", self._name) + self._logger.info("Executing %s", self._name) try: await self.action_script.async_run(variables, trigger_context) except Exception: # pylint: disable=broad-except - _LOGGER.exception("While executing automation %s", self.entity_id) + self._logger.exception("While executing automation %s", self.entity_id) async def async_will_remove_from_hass(self): """Remove listeners when removing automation from Home Assistant.""" @@ -476,13 +488,13 @@ class AutomationEntity(ToggleEntity, RestoreEntity): results = await asyncio.gather(*triggers) if None in results: - _LOGGER.error("Error setting up trigger %s", self._name) + self._logger.error("Error setting up trigger %s", self._name) removes = [remove for remove in results if remove is not None] if not removes: return None - _LOGGER.info("Initialized trigger %s", self._name) + self._logger.info("Initialized trigger %s", self._name) @callback def remove_triggers(): diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 32a22fab9e5..475beb02690 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -620,13 +620,7 @@ class Script: self.name = name self.change_listener = change_listener self.script_mode = script_mode - if logger: - self._logger = logger - else: - logger_name = __name__ - if name: - logger_name = ".".join([logger_name, slugify(name)]) - self._logger = logging.getLogger(logger_name) + self._set_logger(logger) self._log_exceptions = log_exceptions self.last_action = None @@ -638,12 +632,30 @@ class Script: self._queue_lck = asyncio.Lock() self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {} self._repeat_script: Dict[int, Script] = {} - self._choose_data: Dict[ - int, List[Tuple[List[Callable[[HomeAssistant, Dict], bool]], Script]] - ] = {} + self._choose_data: Dict[int, Dict[str, Any]] = {} self._referenced_entities: Optional[Set[str]] = None self._referenced_devices: Optional[Set[str]] = None + def _set_logger(self, logger: Optional[logging.Logger] = None) -> None: + if logger: + self._logger = logger + else: + logger_name = __name__ + if self.name: + logger_name = ".".join([logger_name, slugify(self.name)]) + self._logger = logging.getLogger(logger_name) + + def update_logger(self, logger: Optional[logging.Logger] = None) -> None: + """Update logger.""" + self._set_logger(logger) + for script in self._repeat_script.values(): + script.update_logger(self._logger) + for choose_data in self._choose_data.values(): + for _, script in choose_data["choices"]: + script.update_logger(self._logger) + if choose_data["default"]: + choose_data["default"].update_logger(self._logger) + def _changed(self): if self.change_listener: self._hass.async_run_job(self.change_listener) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8a27c1c4e7e..4001b6a3215 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1255,3 +1255,22 @@ async def test_shutdown_after(hass, caplog): "Stopping scripts running too long after shutdown: test script" in caplog.text ) + + +async def test_update_logger(hass, caplog): + """Test updating logger.""" + sequence = cv.SCRIPT_SCHEMA({"event": "test_event"}) + script_obj = script.Script(hass, sequence) + + await script_obj.async_run() + await hass.async_block_till_done() + + assert script.__name__ in caplog.text + + log_name = "testing.123" + script_obj.update_logger(logging.getLogger(log_name)) + + await script_obj.async_run() + await hass.async_block_till_done() + + assert log_name in caplog.text From 393dd4fe7fe1a2ec1536849e5d6736eff9c844b5 Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Wed, 22 Jul 2020 18:58:07 +0100 Subject: [PATCH 088/362] Change sky_hub to async and fix exception spamming (#37129) Co-authored-by: Martin Hjelmare --- .coveragerc | 2 +- CODEOWNERS | 1 + homeassistant/components/sky_hub/__init__.py | 5 + .../components/sky_hub/device_tracker.py | 101 +++++------------- .../components/sky_hub/manifest.json | 3 +- requirements_all.txt | 3 + 6 files changed, 38 insertions(+), 77 deletions(-) diff --git a/.coveragerc b/.coveragerc index fd314287f87..d1fc86e1010 100644 --- a/.coveragerc +++ b/.coveragerc @@ -740,7 +740,7 @@ omit = homeassistant/components/simplisafe/lock.py homeassistant/components/simulated/sensor.py homeassistant/components/sisyphus/* - homeassistant/components/sky_hub/device_tracker.py + homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py homeassistant/components/skybell/* homeassistant/components/slack/notify.py diff --git a/CODEOWNERS b/CODEOWNERS index 711398ae455..58e31bd3858 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -364,6 +364,7 @@ homeassistant/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya homeassistant/components/sinch/* @bendikrb homeassistant/components/sisyphus/* @jkeljo +homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slide/* @ualex73 homeassistant/components/sma/* @kellerza homeassistant/components/smappee/* @bsmappee diff --git a/homeassistant/components/sky_hub/__init__.py b/homeassistant/components/sky_hub/__init__.py index a5b8969018f..9c875507c09 100644 --- a/homeassistant/components/sky_hub/__init__.py +++ b/homeassistant/components/sky_hub/__init__.py @@ -1 +1,6 @@ """The sky_hub component.""" + + +async def async_setup(hass, config): + """Set up the sky_hub component.""" + return True diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 2537196f21d..b97331b6195 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -1,8 +1,7 @@ """Support for Sky Hub.""" import logging -import re -import requests +from pyskyqhub.skyq_hub import SkyQHub import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -10,102 +9,54 @@ from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import CONF_HOST, HTTP_OK +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -_MAC_REGEX = re.compile(r"(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})") PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string}) -def get_scanner(hass, config): +async def async_get_scanner(hass, config): """Return a Sky Hub scanner if successful.""" - scanner = SkyHubDeviceScanner(config[DOMAIN]) + host = config[DOMAIN].get(CONF_HOST, "192.168.1.254") + websession = async_get_clientsession(hass) + hub = SkyQHub(websession, host) - return scanner if scanner.success_init else None + _LOGGER.debug("Initialising Sky Hub") + await hub.async_connect() + if hub.success_init: + scanner = SkyHubDeviceScanner(hub) + return scanner + + return None class SkyHubDeviceScanner(DeviceScanner): """This class queries a Sky Hub router.""" - def __init__(self, config): + def __init__(self, hub): """Initialise the scanner.""" - _LOGGER.info("Initialising Sky Hub") - self.host = config.get(CONF_HOST, "192.168.1.254") + self._hub = hub self.last_results = {} - self.url = f"http://{self.host}/" - # Test the router is accessible - data = _get_skyhub_data(self.url) - self.success_init = data is not None - - def scan_devices(self): + async def async_scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() - - return (device for device in self.last_results) - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - # If not initialised and not already scanned and not found. - if device not in self.last_results: - self._update_info() - - if not self.last_results: - return None + await self._async_update_info() + return list(self.last_results) + async def async_get_device_name(self, device): + """Return the name of the given device.""" return self.last_results.get(device) - def _update_info(self): - """Ensure the information from the Sky Hub is up to date. + async def _async_update_info(self): + """Ensure the information from the Sky Hub is up to date.""" + _LOGGER.debug("Scanning") - Return boolean if scanning successful. - """ - if not self.success_init: - return False - - _LOGGER.info("Scanning") - - data = _get_skyhub_data(self.url) + data = await self._hub.async_get_skyhub_data() if not data: - _LOGGER.warning("Error scanning devices") - return False + return self.last_results = data - - return True - - -def _get_skyhub_data(url): - """Retrieve data from Sky Hub and return parsed result.""" - try: - response = requests.get(url, timeout=5) - except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") - return - if response.status_code == HTTP_OK: - return _parse_skyhub_response(response.text) - _LOGGER.error("Invalid response from Sky Hub: %s", response) - - -def _parse_skyhub_response(data_str): - """Parse the Sky Hub data format.""" - pattmatch = re.search("attach_dev = '(.*)'", data_str) - if pattmatch is None: - raise OSError( - "Error: Impossible to fetch data from Sky Hub. Try to reboot the router." - ) - patt = pattmatch.group(1) - - dev = [patt1.split(",") for patt1 in patt.split("")] - - devices = {} - for dvc in dev: - if _MAC_REGEX.match(dvc[1]): - devices[dvc[1]] = dvc[0] - else: - raise RuntimeError(f"Error: MAC address {dvc[1]} not in correct format.") - - return devices diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json index b358fa76fbf..da9197899e7 100644 --- a/homeassistant/components/sky_hub/manifest.json +++ b/homeassistant/components/sky_hub/manifest.json @@ -2,5 +2,6 @@ "domain": "sky_hub", "name": "Sky Hub", "documentation": "https://www.home-assistant.io/integrations/sky_hub", - "codeowners": [] + "requirements": ["pyskyqhub==0.1.0"], + "codeowners": ["@rogerselwyn"] } diff --git a/requirements_all.txt b/requirements_all.txt index d4ac79183d0..28cb701ec65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1607,6 +1607,9 @@ pysher==1.0.1 # homeassistant.components.signal_messenger pysignalclirestapi==0.3.4 +# homeassistant.components.sky_hub +pyskyqhub==0.1.0 + # homeassistant.components.sma pysma==0.3.5 From d7fdbbc2a5fb08a7d8c34618467946700167310d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jul 2020 20:17:11 +0200 Subject: [PATCH 089/362] Bump version to 0.114.0dev0 (#38071) --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cbb236a426c..22e24e69c25 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,6 +1,6 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 113 +MINOR_VERSION = 114 PATCH_VERSION = "0.dev0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" From e0ceacdf854d62ace473a3094eee05a0af03c6ff Mon Sep 17 00:00:00 2001 From: Dubh Ad Date: Wed, 22 Jul 2020 20:48:48 +0100 Subject: [PATCH 090/362] Update discord.py to v1.3.4 for API change (#38060) --- homeassistant/components/discord/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 7f03e52ca62..1f4ccbdf5f5 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -2,6 +2,6 @@ "domain": "discord", "name": "Discord", "documentation": "https://www.home-assistant.io/integrations/discord", - "requirements": ["discord.py==1.3.3"], + "requirements": ["discord.py==1.3.4"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 28cb701ec65..78b467afb06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -476,7 +476,7 @@ directv==0.3.0 discogs_client==2.2.2 # homeassistant.components.discord -discord.py==1.3.3 +discord.py==1.3.4 # homeassistant.components.updater distro==1.5.0 From b15dad8c4bbec82817be8b6a8d8fb560e4078242 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jul 2020 23:56:28 +0200 Subject: [PATCH 091/362] Upgrade aiohttp to 3.6.2 (#38082) --- homeassistant/package_constraints.txt | 2 +- requirements.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 b13f47f8302..385877e2822 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==1.7.1 PyNaCl==1.3.0 -aiohttp==3.6.1 +aiohttp==3.6.2 aiohttp_cors==0.7.0 astral==1.10.1 async_timeout==3.0.1 diff --git a/requirements.txt b/requirements.txt index 93a95112658..e9f5cd64f19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.6.1 +aiohttp==3.6.2 astral==1.10.1 async_timeout==3.0.1 attrs==19.3.0 diff --git a/setup.py b/setup.py index c2042ab2459..2e000d3192b 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ PROJECT_URLS = { PACKAGES = find_packages(exclude=["tests", "tests.*"]) REQUIRES = [ - "aiohttp==3.6.1", + "aiohttp==3.6.2", "astral==1.10.1", "async_timeout==3.0.1", "attrs==19.3.0", From 83a27f48559cc33e24b1df9cd5459b3e147053a5 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Thu, 23 Jul 2020 00:09:37 +0200 Subject: [PATCH 092/362] Fix issue with creation of PT2262 devices in rfxtrx integration (#38074) --- homeassistant/components/rfxtrx/__init__.py | 4 ++-- homeassistant/components/rfxtrx/cover.py | 5 ++++- homeassistant/components/rfxtrx/light.py | 5 ++++- homeassistant/components/rfxtrx/sensor.py | 5 +++-- homeassistant/components/rfxtrx/switch.py | 5 ++++- 5 files changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 0fdab9f1bf5..edae34d7e37 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -229,7 +229,7 @@ def setup_internal(hass, config): "sub_type": event.device.subtype, "type_string": event.device.type_string, "id_string": event.device.id_string, - "data": "".join(f"{x:02x}" for x in event.data), + "data": binascii.hexlify(event.data).decode("ASCII"), "values": getattr(event, "values", None), } @@ -375,7 +375,7 @@ def get_device_id(device, data_bits=None): if data_bits and device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: masked_id = get_pt2262_deviceid(id_string, data_bits) if masked_id: - id_string = str(masked_id) + id_string = masked_id.decode("ASCII") return (f"{device.packettype:x}", f"{device.subtype:x}", id_string) diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 047f7c67a21..58bc27d95bb 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -7,6 +7,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, SIGNAL_EVENT, @@ -38,7 +39,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index 376e8b515be..ab24520e485 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -13,6 +13,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, SIGNAL_EVENT, @@ -50,7 +51,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 34e6dc1b310..490885bec7a 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -14,6 +14,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, DATA_TYPES, SIGNAL_EVENT, RfxtrxEntity, @@ -64,7 +65,7 @@ async def async_setup_entry( return isinstance(event, (ControlEvent, SensorEvent)) entities = [] - for packet_id in discovery_info[CONF_DEVICES]: + for packet_id, entity in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: _LOGGER.error("Invalid device: %s", packet_id) @@ -72,7 +73,7 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) for data_type in set(event.values) & set(DATA_TYPES): data_id = (*device_id, data_type) if data_id in data_ids: diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index cf8c26edecb..5f99c0761f0 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -9,6 +9,7 @@ from homeassistant.core import callback from . import ( CONF_AUTOMATIC_ADD, + CONF_DATA_BITS, CONF_SIGNAL_REPETITIONS, DEFAULT_SIGNAL_REPETITIONS, DOMAIN, @@ -48,7 +49,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) From 2f4c1e683abb3e68cf8fa0222e48ad15c8a01907 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 23 Jul 2020 00:50:44 +0200 Subject: [PATCH 093/362] Fix ozw light color values check (#38067) Co-authored-by: Paulus Schoutsen --- homeassistant/components/ozw/light.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ozw/light.py b/homeassistant/components/ozw/light.py index d9e3ed2a51d..1fd2c2fec07 100644 --- a/homeassistant/components/ozw/light.py +++ b/homeassistant/components/ozw/light.py @@ -80,11 +80,10 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): if self.values.dimming_duration is not None: self._supported_features |= SUPPORT_TRANSITION - if self.values.color is None and self.values.color_channels is None: + if self.values.color is None or self.values.color_channels is None: return - if self.values.color is not None: - self._supported_features |= SUPPORT_COLOR + self._supported_features |= SUPPORT_COLOR # Support Color Temp if both white channels if (self.values.color_channels.value & COLOR_CHANNEL_WARM_WHITE) and ( From ae5c50c1b655649f134063d19d65f21b2f3115cb Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 22 Jul 2020 18:01:57 -0500 Subject: [PATCH 094/362] Bump pysmartthings to v0.7.2 (#38086) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/conftest.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 3f9ee75b173..bf137ae398d 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -3,7 +3,7 @@ "name": "SmartThings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smartthings", - "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.1"], + "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.2"], "dependencies": ["webhook"], "after_dependencies": ["cloud"], "codeowners": ["@andrewsayre"] diff --git a/requirements_all.txt b/requirements_all.txt index 78b467afb06..aff16a8af3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1620,7 +1620,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.1 +pysmartthings==0.7.2 # homeassistant.components.smarty pysmarty==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c07f4b662b2..d8ce149b4f0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -746,7 +746,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.1 +pysmartthings==0.7.2 # homeassistant.components.soma pysoma==0.0.10 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index a7ca9a4744c..643c084720a 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -249,7 +249,7 @@ def subscription_factory_fixture(): def device_factory_fixture(): """Fixture for creating mock devices.""" api = Mock(Api) - api.post_device_command.return_value = {} + api.post_device_command.return_value = {"results": [{"status": "ACCEPTED"}]} def _factory(label, capabilities, status: dict = None): device_data = { From 15fe727f2c616d04335e6ef75f3bd161b8f1411a Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Thu, 23 Jul 2020 00:02:48 +0000 Subject: [PATCH 095/362] [ci skip] Translation update --- .../components/agent_dvr/translations/nl.json | 11 ++++ .../components/blebox/translations/nl.json | 12 ++++ .../components/bond/translations/sl.json | 17 +++++ .../components/bsblan/translations/nl.json | 11 ++++ .../components/daikin/translations/nl.json | 3 +- .../components/demo/translations/sl.json | 1 + .../components/denonavr/translations/nl.json | 7 ++ .../components/enocean/translations/sl.json | 27 ++++++++ .../flick_electric/translations/nl.json | 11 ++++ .../components/hue/translations/sl.json | 9 +++ .../components/netatmo/translations/sl.json | 5 ++ .../components/plex/translations/nl.json | 5 ++ .../components/syncthru/translations/sl.json | 27 ++++++++ .../components/withings/translations/nl.json | 3 + .../wolflink/translations/sensor.sl.json | 66 +++++++++++++++++++ 15 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/agent_dvr/translations/nl.json create mode 100644 homeassistant/components/blebox/translations/nl.json create mode 100644 homeassistant/components/bond/translations/sl.json create mode 100644 homeassistant/components/bsblan/translations/nl.json create mode 100644 homeassistant/components/denonavr/translations/nl.json create mode 100644 homeassistant/components/enocean/translations/sl.json create mode 100644 homeassistant/components/flick_electric/translations/nl.json create mode 100644 homeassistant/components/syncthru/translations/sl.json create mode 100644 homeassistant/components/wolflink/translations/sensor.sl.json diff --git a/homeassistant/components/agent_dvr/translations/nl.json b/homeassistant/components/agent_dvr/translations/nl.json new file mode 100644 index 00000000000..c1909b19508 --- /dev/null +++ b/homeassistant/components/agent_dvr/translations/nl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blebox/translations/nl.json b/homeassistant/components/blebox/translations/nl.json new file mode 100644 index 00000000000..c7156a5f553 --- /dev/null +++ b/homeassistant/components/blebox/translations/nl.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "IP-adres", + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/sl.json b/homeassistant/components/bond/translations/sl.json new file mode 100644 index 00000000000..63833bfccde --- /dev/null +++ b/homeassistant/components/bond/translations/sl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "cannot_connect": "Povezava ni uspela", + "invalid_auth": "Neveljavna avtentikacija", + "unknown": "Nepri\u010dakovana napaka" + }, + "step": { + "user": { + "data": { + "access_token": "\u017deton za dostop", + "host": "Gostitelj" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/nl.json b/homeassistant/components/bsblan/translations/nl.json new file mode 100644 index 00000000000..c1909b19508 --- /dev/null +++ b/homeassistant/components/bsblan/translations/nl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Poort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/translations/nl.json b/homeassistant/components/daikin/translations/nl.json index 6fa2362ee59..775e358d205 100644 --- a/homeassistant/components/daikin/translations/nl.json +++ b/homeassistant/components/daikin/translations/nl.json @@ -6,7 +6,8 @@ "step": { "user": { "data": { - "host": "Host" + "host": "Host", + "password": "Wachtwoord" }, "description": "Voer het IP-adres van uw Daikin AC in.", "title": "Daikin AC instellen" diff --git a/homeassistant/components/demo/translations/sl.json b/homeassistant/components/demo/translations/sl.json index 33e4ece832c..22cca21db4c 100644 --- a/homeassistant/components/demo/translations/sl.json +++ b/homeassistant/components/demo/translations/sl.json @@ -12,6 +12,7 @@ "options_1": { "data": { "bool": "Izbirna logi\u010dna vrednost", + "constant": "Constant", "int": "\u0160tevil\u010dni vnos" } }, diff --git a/homeassistant/components/denonavr/translations/nl.json b/homeassistant/components/denonavr/translations/nl.json new file mode 100644 index 00000000000..3444568d459 --- /dev/null +++ b/homeassistant/components/denonavr/translations/nl.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "connection_error": "Kan geen verbinding maken, probeer het opnieuw, het kan helpen om de netvoeding en ethernetkabels los te koppelen en opnieuw aan te sluiten" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/sl.json b/homeassistant/components/enocean/translations/sl.json new file mode 100644 index 00000000000..da0df2714c9 --- /dev/null +++ b/homeassistant/components/enocean/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "invalid_dongle_path": "Neveljavna pot klju\u010da", + "single_instance_allowed": "\u017de konfigurirano. Mo\u017ena je samo ena konfiguracija." + }, + "error": { + "invalid_dongle_path": "Za to pot ni bilo mogo\u010de najti veljavnega klju\u010da" + }, + "flow_title": "ENOcean nastavitev", + "step": { + "detect": { + "data": { + "path": "Pot do USB klju\u010da" + }, + "title": "Izberite pot do va\u0161ega ENOcean klju\u010da" + }, + "manual": { + "data": { + "path": "Pot do USB klju\u010da" + }, + "title": "Vnesite pot do va\u0161ega ENOcean klju\u010dka" + } + } + }, + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/nl.json b/homeassistant/components/flick_electric/translations/nl.json new file mode 100644 index 00000000000..5f7433d97db --- /dev/null +++ b/homeassistant/components/flick_electric/translations/nl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/sl.json b/homeassistant/components/hue/translations/sl.json index a8d88ec49ac..c68971f36f9 100644 --- a/homeassistant/components/hue/translations/sl.json +++ b/homeassistant/components/hue/translations/sl.json @@ -47,5 +47,14 @@ "remote_double_button_long_press": "Po dolgem pritisku sta obe \" {subtype} \" spro\u0161\u010deni", "remote_double_button_short_press": "Spro\u0161\u010dena oba \"{podvrsta}\"" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_hue_groups": "Dovoli skupine Hue" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/sl.json b/homeassistant/components/netatmo/translations/sl.json index e9ec3bc8b97..16c74bc0ea7 100644 --- a/homeassistant/components/netatmo/translations/sl.json +++ b/homeassistant/components/netatmo/translations/sl.json @@ -16,6 +16,11 @@ }, "options": { "step": { + "public_weather": { + "data": { + "area_name": "Ime obmo\u010dja" + } + }, "public_weather_areas": { "title": "Javni vremenski senzor Netatmo" } diff --git a/homeassistant/components/plex/translations/nl.json b/homeassistant/components/plex/translations/nl.json index da13477c4af..142f5f8bec7 100644 --- a/homeassistant/components/plex/translations/nl.json +++ b/homeassistant/components/plex/translations/nl.json @@ -16,6 +16,11 @@ }, "flow_title": "{naam} ({host})", "step": { + "manual_setup": { + "data": { + "port": "Poort" + } + }, "select_server": { "data": { "server": "Server" diff --git a/homeassistant/components/syncthru/translations/sl.json b/homeassistant/components/syncthru/translations/sl.json new file mode 100644 index 00000000000..e3f8ff64875 --- /dev/null +++ b/homeassistant/components/syncthru/translations/sl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Naprava je \u017ee konfigurirana" + }, + "error": { + "invalid_url": "Neveljaven URL", + "syncthru_not_supported": "Naprava ne podpira SyncThru", + "unknown_state": "Stanje tiskalnika ni znano, preverite URL in omre\u017eno povezljivost" + }, + "flow_title": "Samsung SyncThru Tiskalnik: {name}", + "step": { + "confirm": { + "data": { + "name": "Ime", + "url": "URL spletnega vmesnika" + } + }, + "user": { + "data": { + "name": "Ime", + "url": "URL spletnega vmesnika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/translations/nl.json b/homeassistant/components/withings/translations/nl.json index 0fe8153cbe4..4f382f02a57 100644 --- a/homeassistant/components/withings/translations/nl.json +++ b/homeassistant/components/withings/translations/nl.json @@ -17,6 +17,9 @@ }, "description": "Welk profiel hebt u op de website van Withings selecteren? Het is belangrijk dat de profielen overeenkomen, anders worden gegevens verkeerd gelabeld.", "title": "Gebruikersprofiel." + }, + "reauth": { + "title": "Profiel opnieuw verifi\u00ebren" } } } diff --git a/homeassistant/components/wolflink/translations/sensor.sl.json b/homeassistant/components/wolflink/translations/sensor.sl.json new file mode 100644 index 00000000000..dbb33ed5b61 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.sl.json @@ -0,0 +1,66 @@ +{ + "state": { + "wolflink__state": { + "at_frostschutz": "OT za\u0161\u010dita pred zmrzaljo", + "aus": "Onemogo\u010deno", + "auto": "Samodejno", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Samodejno izklopljeno", + "automatik_ein": "Samodejno vklopljeno", + "bereit_keine_ladung": "Pripravljeni, se ne nalo\u017ei", + "betrieb_ohne_brenner": "Delo brez gorilnika", + "cooling": "Hlajenje", + "deaktiviert": "Neaktivno", + "dhw_prior": "DHWPrior", + "eco": "Eko", + "ein": "Omogo\u010deno", + "estrichtrocknung": "Su\u0161enje estrihov", + "externe_deaktivierung": "Zunanja deaktivacija", + "fernschalter_ein": "Omogo\u010den daljinski nadzor", + "frost_heizkreis": "Zmrzal ogrevalnega tokokroga", + "frost_warmwasser": "Zmrzal sanitarne vode", + "frostschutz": "Za\u0161\u010dita pred zmrzaljo", + "gasdruck": "Tlak plina", + "glt_betrieb": "Na\u010din BMS", + "gradienten_uberwachung": "Spremljanje stopnje", + "heizbetrieb": "Na\u010din ogrevanja", + "heizgerat_mit_speicher": "Kotel s cilindrom", + "heizung": "Ogrevanje", + "initialisierung": "Inicializacija", + "kalibration": "Kalibracija", + "kalibration_heizbetrieb": "Kalibracija na\u010dina ogrevanja", + "kalibration_kombibetrieb": "Kalibracija v kombiniranem na\u010dinu", + "reduzierter_betrieb": "Omejen na\u010din", + "rt_abschaltung": "Zaustavitev RT", + "rt_frostschutz": "RT za\u0161\u010dita pred zmrzaljo", + "ruhekontakt": "Rest kontakt", + "schornsteinfeger": "Preizkus emisij", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Mehki zagon", + "solarbetrieb": "Son\u010dni na\u010din", + "sparbetrieb": "Var\u010dni na\u010din", + "sparen": "Var\u010dno", + "spreizung_hoch": "dT pre\u0161irok", + "spreizung_kf": "\u0160irjenje KF", + "stabilisierung": "Stabilizacija", + "standby": "V pripravljenosti", + "start": "Zagon", + "storung": "Napaka", + "taktsperre": "Proti-cikel", + "telefonfernschalter": "Telefonsko daljinsko stikalo", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Po\u010ditni\u0161ki na\u010din", + "ventilprufung": "Test ventila", + "vorspulen": "Vstopno izpiranje", + "warmwasser": "Sanitarna voda", + "warmwasser_schnellstart": "Hitri zagon sanitarne vode", + "warmwasserbetrieb": "Na\u010din sanitarne vode", + "warmwassernachlauf": "Iztekanje sanitarne vode", + "warmwasservorrang": "Prioriteta sanitarne vode", + "zunden": "V\u017eig" + } + } +} \ No newline at end of file From 3480fb69962e5eefaddf46d3756e9be3476add20 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Wed, 22 Jul 2020 18:22:25 -0700 Subject: [PATCH 096/362] Refactor bond integration to be completely async (#38066) --- homeassistant/components/bond/__init__.py | 6 +- homeassistant/components/bond/config_flow.py | 35 ++++---- homeassistant/components/bond/cover.py | 20 ++--- homeassistant/components/bond/fan.py | 40 +++++---- homeassistant/components/bond/light.py | 34 +++---- homeassistant/components/bond/manifest.json | 2 +- homeassistant/components/bond/switch.py | 18 ++-- homeassistant/components/bond/utils.py | 24 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bond/common.py | 71 +++------------ tests/components/bond/test_config_flow.py | 19 ++-- tests/components/bond/test_cover.py | 39 ++++---- tests/components/bond/test_fan.py | 94 ++++++++++++-------- tests/components/bond/test_light.py | 75 ++++++++++------ tests/components/bond/test_switch.py | 29 +++--- 16 files changed, 251 insertions(+), 259 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 013b061c08e..60c78ee4dbe 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,7 +1,7 @@ """The Bond integration.""" import asyncio -from bond import Bond +from bond_api import Bond from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -25,9 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): host = entry.data[CONF_HOST] token = entry.data[CONF_ACCESS_TOKEN] - bond = Bond(bondIp=host, bondToken=token) + bond = Bond(host=host, token=token) hub = BondHub(bond) - await hass.async_add_executor_job(hub.setup) + await hub.setup() hass.data[DOMAIN][entry.entry_id] = hub device_registry = await dr.async_get_registry(hass) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index b2f009af44f..215ae4af91d 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,12 +1,11 @@ """Config flow for Bond integration.""" -from json import JSONDecodeError import logging -from bond import Bond -from requests.exceptions import ConnectionError as RequestConnectionError +from aiohttp import ClientConnectionError, ClientResponseError +from bond_api import Bond import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, exceptions from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from .const import DOMAIN # pylint:disable=unused-import @@ -18,24 +17,20 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(data): """Validate the user input allows us to connect.""" - def authenticate(bond_hub: Bond) -> bool: - try: - bond_hub.getDeviceIds() - return True - except RequestConnectionError: - raise CannotConnect - except JSONDecodeError: - return False + try: + bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) + await bond.devices() + except ClientConnectionError: + raise CannotConnect + except ClientResponseError as error: + if error.status == 401: + raise InvalidAuth + raise - bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) - - if not await hass.async_add_executor_job(authenticate, bond): - raise InvalidAuth - - # Return info that you want to store in the config entry. + # Return info to be stored in the config entry. return {"title": data[CONF_HOST]} @@ -50,7 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - info = await validate_input(self.hass, user_input) + info = await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 809bf3d7da5..523a7f5c4d8 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -1,7 +1,7 @@ """Support for Bond covers.""" from typing import Any, Callable, List, Optional -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -24,7 +24,7 @@ async def async_setup_entry( covers = [ BondCover(hub, device) for device in hub.devices - if device.type == DeviceTypes.MOTORIZED_SHADES + if device.type == DeviceType.MOTORIZED_SHADES ] async_add_entities(covers, True) @@ -44,9 +44,9 @@ class BondCover(BondEntity, CoverEntity): """Get device class.""" return DEVICE_CLASS_SHADE - def update(self): + async def async_update(self): """Fetch assumed state of the cover from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device.device_id) cover_open = state.get("open") self._closed = True if cover_open == 0 else False if cover_open == 1 else None @@ -55,14 +55,14 @@ class BondCover(BondEntity, CoverEntity): """Return if the cover is closed or not.""" return self._closed - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._hub.bond.open(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.open()) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._hub.bond.close(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.close()) - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Hold cover.""" - self._hub.bond.hold(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.hold()) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 80ae5d7f6ac..82d437fd7b0 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -2,7 +2,7 @@ import math from typing import Any, Callable, List, Optional -from bond import DeviceTypes, Directions +from bond_api import Action, DeviceType, Direction from homeassistant.components.fan import ( DIRECTION_FORWARD, @@ -33,9 +33,7 @@ async def async_setup_entry( hub: BondHub = hass.data[DOMAIN][entry.entry_id] fans = [ - BondFan(hub, device) - for device in hub.devices - if device.type == DeviceTypes.CEILING_FAN + BondFan(hub, device) for device in hub.devices if DeviceType.is_fan(device.type) ] async_add_entities(fans, True) @@ -85,21 +83,21 @@ class BondFan(BondEntity, FanEntity): def current_direction(self) -> Optional[str]: """Return fan rotation direction.""" direction = None - if self._direction == Directions.FORWARD: + if self._direction == Direction.FORWARD: direction = DIRECTION_FORWARD - elif self._direction == Directions.REVERSE: + elif self._direction == Direction.REVERSE: direction = DIRECTION_REVERSE return direction - def update(self): + async def async_update(self): """Fetch assumed state of the fan from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device.device_id) self._power = state.get("power") self._speed = state.get("speed") self._direction = state.get("direction") - def set_speed(self, speed: str) -> None: + async def async_set_speed(self, speed: str) -> None: """Set the desired speed for the fan.""" max_speed = self._device.props.get("max_speed", 3) if speed == SPEED_LOW: @@ -108,21 +106,27 @@ class BondFan(BondEntity, FanEntity): bond_speed = max_speed else: bond_speed = math.ceil(max_speed / 2) - self._hub.bond.setSpeed(self._device.device_id, speed=bond_speed) - def turn_on(self, speed: Optional[str] = None, **kwargs) -> None: + await self._hub.bond.action( + self._device.device_id, Action.set_speed(bond_speed) + ) + + async def async_turn_on(self, speed: Optional[str] = None, **kwargs) -> None: """Turn on the fan.""" if speed is not None: - self.set_speed(speed) - self._hub.bond.turnOn(self._device.device_id) + await self.async_set_speed(speed) + else: + await self._hub.bond.action(self._device.device_id, Action.turn_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" - self._hub.bond.turnOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_off()) - def set_direction(self, direction: str) -> None: + async def async_set_direction(self, direction: str): """Set fan rotation direction.""" bond_direction = ( - Directions.REVERSE if direction == DIRECTION_REVERSE else Directions.FORWARD + Direction.REVERSE if direction == DIRECTION_REVERSE else Direction.FORWARD + ) + await self._hub.bond.action( + self._device.device_id, Action.set_direction(bond_direction) ) - self._hub.bond.setDirection(self._device.device_id, bond_direction) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index f3539416742..daea1c02638 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -1,7 +1,7 @@ """Support for Bond lights.""" from typing import Any, Callable, List, Optional -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -29,13 +29,13 @@ async def async_setup_entry( lights: List[Entity] = [ BondLight(hub, device) for device in hub.devices - if device.type == DeviceTypes.CEILING_FAN and device.supports_light() + if DeviceType.is_fan(device.type) and device.supports_light() ] fireplaces: List[Entity] = [ BondFireplace(hub, device) for device in hub.devices - if device.type == DeviceTypes.FIREPLACE + if DeviceType.is_fireplace(device.type) ] async_add_entities(lights + fireplaces, True) @@ -55,18 +55,18 @@ class BondLight(BondEntity, LightEntity): """Return if light is currently on.""" return self._light == 1 - def update(self): + async def async_update(self): """Fetch assumed state of the light from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device.device_id) self._light = state.get("light") - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - self._hub.bond.turnLightOn(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_light_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - self._hub.bond.turnLightOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_light_off()) class BondFireplace(BondEntity, LightEntity): @@ -90,18 +90,18 @@ class BondFireplace(BondEntity, LightEntity): """Return True if power is on.""" return self._power == 1 - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the fireplace on.""" - self._hub.bond.turnOn(self._device.device_id) - brightness = kwargs.get(ATTR_BRIGHTNESS) if brightness: flame = round((brightness * 100) / 255) - self._hub.bond.setFlame(self._device.device_id, flame) + await self._hub.bond.action(self._device.device_id, Action.set_flame(flame)) + else: + await self._hub.bond.action(self._device.device_id, Action.turn_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fireplace off.""" - self._hub.bond.turnOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_off()) @property def brightness(self): @@ -113,8 +113,8 @@ class BondFireplace(BondEntity, LightEntity): """Show fireplace icon for the entity.""" return "mdi:fireplace" if self._power == 1 else "mdi:fireplace-off" - def update(self): + async def async_update(self): """Fetch assumed state of the device from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device.device_id) self._power = state.get("power") self._flame = state.get("flame") diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index b9e57981400..6b0bae84893 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", "requirements": [ - "bond-home==0.0.9" + "bond-api==0.1.4" ], "codeowners": [ "@prystupa" diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 4768bbf8eda..3d5b467345e 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -1,13 +1,13 @@ """Support for Bond generic devices.""" from typing import Any, Callable, List, Optional -from bond import DeviceTypes +from bond_api import Action, DeviceType +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity -from ..switch import SwitchEntity from .const import DOMAIN from .entity import BondEntity from .utils import BondDevice, BondHub @@ -24,7 +24,7 @@ async def async_setup_entry( switches = [ BondSwitch(hub, device) for device in hub.devices - if device.type == DeviceTypes.GENERIC_DEVICE + if DeviceType.is_generic(device.type) ] async_add_entities(switches, True) @@ -44,15 +44,15 @@ class BondSwitch(BondEntity, SwitchEntity): """Return True if power is on.""" return self._power == 1 - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - self._hub.bond.turnOn(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_on()) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self._hub.bond.turnOff(self._device.device_id) + await self._hub.bond.action(self._device.device_id, Action.turn_off()) - def update(self): + async def async_update(self): """Fetch assumed state of the device from the hub using API.""" - state: dict = self._hub.bond.getDeviceState(self._device.device_id) + state: dict = await self._hub.bond.device_state(self._device.device_id) self._power = state.get("power") diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 5e1360bcd41..545360b25fd 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -2,7 +2,7 @@ from typing import List, Optional -from bond import Actions, Bond +from bond_api import Action, Bond class BondDevice: @@ -27,18 +27,12 @@ class BondDevice: def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" actions: List[str] = self._attrs["actions"] - return bool([action for action in actions if action in [Actions.SET_SPEED]]) + return bool([action for action in actions if action in [Action.SET_SPEED]]) def supports_direction(self) -> bool: """Return True if this device supports any of the direction related commands.""" actions: List[str] = self._attrs["actions"] - return bool( - [ - action - for action in actions - if action in [Actions.SET_DIRECTION, Actions.TOGGLE_DIRECTION] - ] - ) + return bool([action for action in actions if action in [Action.SET_DIRECTION]]) def supports_light(self) -> bool: """Return True if this device supports any of the light related commands.""" @@ -47,7 +41,7 @@ class BondDevice: [ action for action in actions - if action in [Actions.TURN_LIGHT_ON, Actions.TOGGLE_LIGHT] + if action in [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF] ] ) @@ -61,17 +55,17 @@ class BondHub: self._version: Optional[dict] = None self._devices: Optional[List[BondDevice]] = None - def setup(self): + async def setup(self): """Read hub version information.""" - self._version = self.bond.getVersion() + self._version = await self.bond.version() # Fetch all available devices using Bond API. - device_ids = self.bond.getDeviceIds() + device_ids = await self.bond.devices() self._devices = [ BondDevice( device_id, - self.bond.getDevice(device_id), - self.bond.getProperties(device_id), + await self.bond.device(device_id), + await self.bond.device_properties(device_id), ) for device_id in device_ids ] diff --git a/requirements_all.txt b/requirements_all.txt index aff16a8af3d..63e5d4e044d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ blockchain==1.4.4 bomradarloop==0.1.4 # homeassistant.components.bond -bond-home==0.0.9 +bond-api==0.1.4 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8ce149b4f0..af0c25268c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ blinkpy==0.15.1 bomradarloop==0.1.4 # homeassistant.components.bond -bond-home==0.0.9 +bond-api==0.1.4 # homeassistant.components.braviatv bravia-tv==1.0.6 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 780e235d5c9..28395bfbe77 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -21,9 +21,7 @@ async def setup_bond_entity( config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.bond.Bond.getVersion", return_value=hub_version - ): + with patch("homeassistant.components.bond.Bond.version", return_value=hub_version): return await hass.config_entries.async_setup(config_entry.entry_id) @@ -45,13 +43,15 @@ async def setup_platform( mock_entry.add_to_hass(hass) with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch( - "homeassistant.components.bond.Bond.getVersion", return_value=MOCK_HUB_VERSION + "homeassistant.components.bond.Bond.version", return_value=MOCK_HUB_VERSION ), patch_bond_device_ids(return_value=[bond_device_id],), patch( - "homeassistant.components.bond.Bond.getDevice", return_value=discovered_device + "homeassistant.components.bond.Bond.device", return_value=discovered_device ), patch_bond_device_state( return_value={} ), patch( - "homeassistant.components.bond.Bond.getProperties", return_value=props + "homeassistant.components.bond.Bond.device_properties", return_value=props + ), patch( + "homeassistant.components.bond.Bond.device_state", return_value={} ): assert await async_setup_component(hass, BOND_DOMAIN, {}) await hass.async_block_till_done() @@ -60,70 +60,25 @@ async def setup_platform( def patch_bond_device_ids(return_value=None): - """Patch Bond API getDeviceIds command.""" + """Patch Bond API devices command.""" if return_value is None: return_value = [] return patch( - "homeassistant.components.bond.Bond.getDeviceIds", return_value=return_value, + "homeassistant.components.bond.Bond.devices", return_value=return_value, ) -def patch_bond_turn_on(): - """Patch Bond API turnOn command.""" - return patch("homeassistant.components.bond.Bond.turnOn") - - -def patch_bond_turn_off(): - """Patch Bond API turnOff command.""" - return patch("homeassistant.components.bond.Bond.turnOff") - - -def patch_bond_set_speed(): - """Patch Bond API setSpeed command.""" - return patch("homeassistant.components.bond.Bond.setSpeed") - - -def patch_bond_set_flame(): - """Patch Bond API setFlame command.""" - return patch("homeassistant.components.bond.Bond.setFlame") - - -def patch_bond_open(): - """Patch Bond API open command.""" - return patch("homeassistant.components.bond.Bond.open") - - -def patch_bond_close(): - """Patch Bond API close command.""" - return patch("homeassistant.components.bond.Bond.close") - - -def patch_bond_hold(): - """Patch Bond API hold command.""" - return patch("homeassistant.components.bond.Bond.hold") - - -def patch_bond_set_direction(): - """Patch Bond API setDirection command.""" - return patch("homeassistant.components.bond.Bond.setDirection") - - -def patch_turn_light_on(): - """Patch Bond API turnLightOn command.""" - return patch("homeassistant.components.bond.Bond.turnLightOn") - - -def patch_turn_light_off(): - """Patch Bond API turnLightOff command.""" - return patch("homeassistant.components.bond.Bond.turnLightOff") +def patch_bond_action(): + """Patch Bond API action command.""" + return patch("homeassistant.components.bond.Bond.action") def patch_bond_device_state(return_value=None): - """Patch Bond API getDeviceState command.""" + """Patch Bond API device state endpoint.""" if return_value is None: return_value = {} return patch( - "homeassistant.components.bond.Bond.getDeviceState", return_value=return_value + "homeassistant.components.bond.Bond.device_state", return_value=return_value ) diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 94b98b45d6f..825215207a0 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -1,13 +1,12 @@ """Test the Bond config flow.""" -from json import JSONDecodeError -from requests.exceptions import ConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from homeassistant import config_entries, core, setup from homeassistant.components.bond.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from tests.async_mock import patch +from tests.async_mock import Mock, patch async def test_form(hass: core.HomeAssistant): @@ -20,7 +19,7 @@ async def test_form(hass: core.HomeAssistant): assert result["errors"] == {} with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", return_value=[], + "homeassistant.components.bond.config_flow.Bond.devices", return_value=[], ), patch( "homeassistant.components.bond.async_setup", return_value=True ) as mock_setup, patch( @@ -48,8 +47,8 @@ async def test_form_invalid_auth(hass: core.HomeAssistant): ) with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", - side_effect=JSONDecodeError("test-message", "test-doc", 0), + "homeassistant.components.bond.config_flow.Bond.devices", + side_effect=ClientResponseError(Mock(), Mock(), status=401), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, @@ -66,8 +65,8 @@ async def test_form_cannot_connect(hass: core.HomeAssistant): ) with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", - side_effect=ConnectionError, + "homeassistant.components.bond.config_flow.Bond.devices", + side_effect=ClientConnectionError(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, @@ -84,8 +83,8 @@ async def test_form_unexpected_error(hass: core.HomeAssistant): ) with patch( - "homeassistant.components.bond.config_flow.Bond.getDeviceIds", - side_effect=Exception, + "homeassistant.components.bond.config_flow.Bond.devices", + side_effect=ClientResponseError(Mock(), Mock(), status=500), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index dacb612add9..da73e086a61 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant import core from homeassistant.components.cover import DOMAIN as COVER_DOMAIN @@ -15,13 +15,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import ( - patch_bond_close, - patch_bond_device_state, - patch_bond_hold, - patch_bond_open, - setup_platform, -) +from .common import patch_bond_action, patch_bond_device_state, setup_platform from tests.common import async_fire_time_changed @@ -30,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) def shades(name: str): """Create motorized shades with given name.""" - return {"name": name, "type": DeviceTypes.MOTORIZED_SHADES} + return {"name": name, "type": DeviceType.MOTORIZED_SHADES} async def test_entity_registry(hass: core.HomeAssistant): @@ -43,9 +37,11 @@ async def test_entity_registry(hass: core.HomeAssistant): async def test_open_cover(hass: core.HomeAssistant): """Tests that open cover command delegates to API.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, COVER_DOMAIN, shades("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_open() as mock_open, patch_bond_device_state(): + with patch_bond_action() as mock_open, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -53,14 +49,17 @@ async def test_open_cover(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_open.assert_called_once() + + mock_open.assert_called_once_with("test-device-id", Action.open()) async def test_close_cover(hass: core.HomeAssistant): """Tests that close cover command delegates to API.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, COVER_DOMAIN, shades("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_close() as mock_close, patch_bond_device_state(): + with patch_bond_action() as mock_close, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -68,14 +67,17 @@ async def test_close_cover(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_close.assert_called_once() + + mock_close.assert_called_once_with("test-device-id", Action.close()) async def test_stop_cover(hass: core.HomeAssistant): """Tests that stop cover command delegates to API.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, COVER_DOMAIN, shades("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_hold() as mock_hold, patch_bond_device_state(): + with patch_bond_action() as mock_hold, patch_bond_device_state(): await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, @@ -83,7 +85,8 @@ async def test_stop_cover(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_hold.assert_called_once() + + mock_hold.assert_called_once_with("test-device-id", Action.hold()) async def test_update_reports_open_cover(hass: core.HomeAssistant): diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index b518f72f326..f73310bc504 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -1,7 +1,8 @@ """Tests for the Bond fan device.""" from datetime import timedelta +from typing import Optional -from bond import DeviceTypes, Directions +from bond_api import Action, DeviceType, Direction from homeassistant import core from homeassistant.components import fan @@ -17,14 +18,7 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import ( - patch_bond_device_state, - patch_bond_set_direction, - patch_bond_set_speed, - patch_bond_turn_off, - patch_bond_turn_on, - setup_platform, -) +from .common import patch_bond_action, patch_bond_device_state, setup_platform from tests.common import async_fire_time_changed @@ -33,18 +27,20 @@ def ceiling_fan(name: str): """Create a ceiling fan with given name.""" return { "name": name, - "type": DeviceTypes.CEILING_FAN, + "type": DeviceType.CEILING_FAN, "actions": ["SetSpeed", "SetDirection"], } -async def turn_fan_on(hass: core.HomeAssistant, fan_id: str, speed: str) -> None: +async def turn_fan_on( + hass: core.HomeAssistant, fan_id: str, speed: Optional[str] = None +) -> None: """Turn the fan on at the specified speed.""" + service_data = {ATTR_ENTITY_ID: fan_id} + if speed: + service_data[fan.ATTR_SPEED] = speed await hass.services.async_call( - FAN_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: fan_id, fan.ATTR_SPEED: speed}, - blocking=True, + FAN_DOMAIN, SERVICE_TURN_ON, service_data=service_data, blocking=True, ) await hass.async_block_till_done() @@ -57,7 +53,7 @@ async def test_entity_registry(hass: core.HomeAssistant): assert [key for key in registry.entities] == ["fan.name_1"] -async def test_entity_non_standard_speed_list(hass: core.HomeAssistant): +async def test_non_standard_speed_list(hass: core.HomeAssistant): """Tests that the device is registered with custom speed list if number of supported speeds differs form 3.""" await setup_platform( hass, @@ -76,41 +72,62 @@ async def test_entity_non_standard_speed_list(hass: core.HomeAssistant): ] with patch_bond_device_state(): - with patch_bond_turn_on(), patch_bond_set_speed() as mock_set_speed_low: + with patch_bond_action() as mock_set_speed_low: await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) - mock_set_speed_low.assert_called_once_with("test-device-id", speed=1) + mock_set_speed_low.assert_called_once_with( + "test-device-id", Action.set_speed(1) + ) - with patch_bond_turn_on(), patch_bond_set_speed() as mock_set_speed_medium: + with patch_bond_action() as mock_set_speed_medium: await turn_fan_on(hass, "fan.name_1", fan.SPEED_MEDIUM) - mock_set_speed_medium.assert_called_once_with("test-device-id", speed=3) + mock_set_speed_medium.assert_called_once_with( + "test-device-id", Action.set_speed(3) + ) - with patch_bond_turn_on(), patch_bond_set_speed() as mock_set_speed_high: + with patch_bond_action() as mock_set_speed_high: await turn_fan_on(hass, "fan.name_1", fan.SPEED_HIGH) - mock_set_speed_high.assert_called_once_with("test-device-id", speed=6) + mock_set_speed_high.assert_called_once_with( + "test-device-id", Action.set_speed(6) + ) -async def test_turn_on_fan(hass: core.HomeAssistant): - """Tests that turn on command delegates to API.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) +async def test_turn_on_fan_with_speed(hass: core.HomeAssistant): + """Tests that turn on command delegates to set speed API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_turn_on() as mock_turn_on, patch_bond_set_speed() as mock_set_speed, patch_bond_device_state(): + with patch_bond_action() as mock_set_speed, patch_bond_device_state(): await turn_fan_on(hass, "fan.name_1", fan.SPEED_LOW) - mock_set_speed.assert_called_once() - mock_turn_on.assert_called_once() + mock_set_speed.assert_called_with("test-device-id", Action.set_speed(1)) + + +async def test_turn_on_fan_without_speed(hass: core.HomeAssistant): + """Tests that turn on command delegates to turn on API.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): + await turn_fan_on(hass, "fan.name_1") + + mock_turn_on.assert_called_with("test-device-id", Action.turn_on()) async def test_turn_off_fan(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_turn_off() as mock_turn_off, patch_bond_device_state(): + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "fan.name_1"}, blocking=True, ) await hass.async_block_till_done() - mock_turn_off.assert_called_once() + mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) async def test_update_reports_fan_on(hass: core.HomeAssistant): @@ -139,7 +156,7 @@ async def test_update_reports_direction_forward(hass: core.HomeAssistant): """Tests that update command sets correct direction when Bond API reports fan direction is forward.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch_bond_device_state(return_value={"direction": Directions.FORWARD}): + with patch_bond_device_state(return_value={"direction": Direction.FORWARD}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -150,7 +167,7 @@ async def test_update_reports_direction_reverse(hass: core.HomeAssistant): """Tests that update command sets correct direction when Bond API reports fan direction is reverse.""" await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) - with patch_bond_device_state(return_value={"direction": Directions.REVERSE}): + with patch_bond_device_state(return_value={"direction": Direction.REVERSE}): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -159,9 +176,11 @@ async def test_update_reports_direction_reverse(hass: core.HomeAssistant): async def test_set_fan_direction(hass: core.HomeAssistant): """Tests that set direction command delegates to API.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_set_direction() as mock_set_direction, patch_bond_device_state(): + with patch_bond_action() as mock_set_direction, patch_bond_device_state(): await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, @@ -169,4 +188,7 @@ async def test_set_fan_direction(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_set_direction.assert_called_once() + + mock_set_direction.assert_called_once_with( + "test-device-id", Action.set_direction(Direction.FORWARD) + ) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index edcdc3e63bd..55936e3a11c 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from bond import Actions, DeviceTypes +from bond_api import Action, DeviceType from homeassistant import core from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN @@ -10,15 +10,7 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import ( - patch_bond_device_state, - patch_bond_set_flame, - patch_bond_turn_off, - patch_bond_turn_on, - patch_turn_light_off, - patch_turn_light_on, - setup_platform, -) +from .common import patch_bond_action, patch_bond_device_state, setup_platform from tests.common import async_fire_time_changed @@ -29,14 +21,14 @@ def ceiling_fan(name: str): """Create a ceiling fan (that has built-in light) with given name.""" return { "name": name, - "type": DeviceTypes.CEILING_FAN, - "actions": [Actions.TOGGLE_LIGHT], + "type": DeviceType.CEILING_FAN, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF], } def fireplace(name: str): """Create a fireplace with given name.""" - return {"name": name, "type": DeviceTypes.FIREPLACE} + return {"name": name, "type": DeviceType.FIREPLACE} async def test_entity_registry(hass: core.HomeAssistant): @@ -49,9 +41,11 @@ async def test_entity_registry(hass: core.HomeAssistant): async def test_turn_on_light(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" - await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch_turn_light_on() as mock_turn_light_on, patch_bond_device_state(): + with patch_bond_action() as mock_turn_light_on, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -59,14 +53,17 @@ async def test_turn_on_light(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_light_on.assert_called_once() + + mock_turn_light_on.assert_called_once_with("test-device-id", Action.turn_light_on()) async def test_turn_off_light(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) - with patch_turn_light_off() as mock_turn_light_off, patch_bond_device_state(): + with patch_bond_action() as mock_turn_light_off, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -74,7 +71,10 @@ async def test_turn_off_light(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_light_off.assert_called_once() + + mock_turn_light_off.assert_called_once_with( + "test-device-id", Action.turn_light_off() + ) async def test_update_reports_light_is_on(hass: core.HomeAssistant): @@ -99,13 +99,13 @@ async def test_update_reports_light_is_off(hass: core.HomeAssistant): assert hass.states.get("light.name_1").state == "off" -async def test_turn_on_fireplace(hass: core.HomeAssistant): - """Tests that turn on command delegates to API.""" +async def test_turn_on_fireplace_with_brightness(hass: core.HomeAssistant): + """Tests that turn on command delegates to set flame API.""" await setup_platform( hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" ) - with patch_bond_turn_on() as mock_turn_on, patch_bond_set_flame() as mock_set_flame, patch_bond_device_state(): + with patch_bond_action() as mock_set_flame, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -114,15 +114,34 @@ async def test_turn_on_fireplace(hass: core.HomeAssistant): ) await hass.async_block_till_done() - mock_turn_on.assert_called_once() - mock_set_flame.assert_called_once_with("test-device-id", 50) + mock_set_flame.assert_called_once_with("test-device-id", Action.set_flame(50)) + + +async def test_turn_on_fireplace_without_brightness(hass: core.HomeAssistant): + """Tests that turn on command delegates to turn on API.""" + await setup_platform( + hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_turn_on.assert_called_once_with("test-device-id", Action.turn_on()) async def test_turn_off_fireplace(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, LIGHT_DOMAIN, fireplace("name-1")) + await setup_platform( + hass, LIGHT_DOMAIN, fireplace("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_turn_off() as mock_turn_off, patch_bond_device_state(): + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -130,7 +149,8 @@ async def test_turn_off_fireplace(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_off.assert_called_once() + + mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) async def test_flame_converted_to_brightness(hass: core.HomeAssistant): @@ -141,5 +161,4 @@ async def test_flame_converted_to_brightness(hass: core.HomeAssistant): async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - _LOGGER.warning(hass.states.get("light.name_1").attributes) assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128 diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index b2d77150907..1cfdf682d38 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from bond import DeviceTypes +from bond_api import Action, DeviceType from homeassistant import core from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -10,12 +10,7 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import ( - patch_bond_device_state, - patch_bond_turn_off, - patch_bond_turn_on, - setup_platform, -) +from .common import patch_bond_action, patch_bond_device_state, setup_platform from tests.common import async_fire_time_changed @@ -24,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) def generic_device(name: str): """Create a generic device with given name.""" - return {"name": name, "type": DeviceTypes.GENERIC_DEVICE} + return {"name": name, "type": DeviceType.GENERIC_DEVICE} async def test_entity_registry(hass: core.HomeAssistant): @@ -37,9 +32,11 @@ async def test_entity_registry(hass: core.HomeAssistant): async def test_turn_on_switch(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" - await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_turn_on() as mock_turn_on, patch_bond_device_state(): + with patch_bond_action() as mock_turn_on, patch_bond_device_state(): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -47,14 +44,17 @@ async def test_turn_on_switch(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_on.assert_called_once() + + mock_turn_on.assert_called_once_with("test-device-id", Action.turn_on()) async def test_turn_off_switch(hass: core.HomeAssistant): """Tests that turn off command delegates to API.""" - await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) + await setup_platform( + hass, SWITCH_DOMAIN, generic_device("name-1"), bond_device_id="test-device-id" + ) - with patch_bond_turn_off() as mock_turn_off, patch_bond_device_state(): + with patch_bond_action() as mock_turn_off, patch_bond_device_state(): await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -62,7 +62,8 @@ async def test_turn_off_switch(hass: core.HomeAssistant): blocking=True, ) await hass.async_block_till_done() - mock_turn_off.assert_called_once() + + mock_turn_off.assert_called_once_with("test-device-id", Action.turn_off()) async def test_update_reports_switch_is_on(hass: core.HomeAssistant): From a756d1e637a84cdcdc505d6fa4c748331cbbf775 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Wed, 22 Jul 2020 19:15:27 -0700 Subject: [PATCH 097/362] Centralize bond update state logic (#38093) * Refactor bond integration to centralize update state logic in single superclass * Refactor bond integration to centralize update state logic in single superclass --- homeassistant/components/bond/cover.py | 10 ++++------ homeassistant/components/bond/entity.py | 13 ++++++++++++- homeassistant/components/bond/fan.py | 12 +++++------- homeassistant/components/bond/light.py | 18 +++++++----------- homeassistant/components/bond/switch.py | 8 +++----- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 523a7f5c4d8..dc0fc6d500c 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -39,17 +39,15 @@ class BondCover(BondEntity, CoverEntity): self._closed: Optional[bool] = None + def _apply_state(self, state: dict): + cover_open = state.get("open") + self._closed = True if cover_open == 0 else False if cover_open == 1 else None + @property def device_class(self) -> Optional[str]: """Get device class.""" return DEVICE_CLASS_SHADE - async def async_update(self): - """Fetch assumed state of the cover from the hub using API.""" - state: dict = await self._hub.bond.device_state(self._device.device_id) - cover_open = state.get("open") - self._closed = True if cover_open == 0 else False if cover_open == 1 else None - @property def is_closed(self): """Return if the cover is closed or not.""" diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 0916297c074..d6d314f2844 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,13 +1,15 @@ """An abstract class common to all Bond entities.""" +from abc import abstractmethod from typing import Any, Dict, Optional from homeassistant.const import ATTR_NAME +from homeassistant.helpers.entity import Entity from .const import DOMAIN from .utils import BondDevice, BondHub -class BondEntity: +class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" def __init__(self, hub: BondHub, device: BondDevice): @@ -38,3 +40,12 @@ class BondEntity: def assumed_state(self) -> bool: """Let HA know this entity relies on an assumed state tracked by Bond.""" return True + + async def async_update(self): + """Fetch assumed state of the cover from the hub using API.""" + state: dict = await self._hub.bond.device_state(self._device.device_id) + self._apply_state(state) + + @abstractmethod + def _apply_state(self, state: dict): + raise NotImplementedError diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 82d437fd7b0..6f6a98036c7 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -50,6 +50,11 @@ class BondFan(BondEntity, FanEntity): self._speed: Optional[int] = None self._direction: Optional[int] = None + def _apply_state(self, state: dict): + self._power = state.get("power") + self._speed = state.get("speed") + self._direction = state.get("direction") + @property def supported_features(self) -> int: """Flag supported features.""" @@ -90,13 +95,6 @@ class BondFan(BondEntity, FanEntity): return direction - async def async_update(self): - """Fetch assumed state of the fan from the hub using API.""" - state: dict = await self._hub.bond.device_state(self._device.device_id) - self._power = state.get("power") - self._speed = state.get("speed") - self._direction = state.get("direction") - async def async_set_speed(self, speed: str) -> None: """Set the desired speed for the fan.""" max_speed = self._device.props.get("max_speed", 3) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index daea1c02638..77afb188111 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -50,16 +50,14 @@ class BondLight(BondEntity, LightEntity): self._light: Optional[int] = None + def _apply_state(self, state: dict): + self._light = state.get("light") + @property def is_on(self) -> bool: """Return if light is currently on.""" return self._light == 1 - async def async_update(self): - """Fetch assumed state of the light from the hub using API.""" - state: dict = await self._hub.bond.device_state(self._device.device_id) - self._light = state.get("light") - async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" await self._hub.bond.action(self._device.device_id, Action.turn_light_on()) @@ -80,6 +78,10 @@ class BondFireplace(BondEntity, LightEntity): # Bond flame level, 0-100 self._flame: Optional[int] = None + def _apply_state(self, state: dict): + self._power = state.get("power") + self._flame = state.get("flame") + @property def supported_features(self) -> Optional[int]: """Flag brightness as supported feature to represent flame level.""" @@ -112,9 +114,3 @@ class BondFireplace(BondEntity, LightEntity): def icon(self) -> Optional[str]: """Show fireplace icon for the entity.""" return "mdi:fireplace" if self._power == 1 else "mdi:fireplace-off" - - async def async_update(self): - """Fetch assumed state of the device from the hub using API.""" - state: dict = await self._hub.bond.device_state(self._device.device_id) - self._power = state.get("power") - self._flame = state.get("flame") diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 3d5b467345e..d2f1797225d 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -39,6 +39,9 @@ class BondSwitch(BondEntity, SwitchEntity): self._power: Optional[bool] = None + def _apply_state(self, state: dict): + self._power = state.get("power") + @property def is_on(self) -> bool: """Return True if power is on.""" @@ -51,8 +54,3 @@ class BondSwitch(BondEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._hub.bond.action(self._device.device_id, Action.turn_off()) - - async def async_update(self): - """Fetch assumed state of the device from the hub using API.""" - state: dict = await self._hub.bond.device_state(self._device.device_id) - self._power = state.get("power") From 2b3f22c871adc40979101e684217b443d71db005 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Jul 2020 21:43:51 -0700 Subject: [PATCH 098/362] Fix route53 depending on broken package (#38079) --- homeassistant/components/route53/__init__.py | 10 +++------- homeassistant/components/route53/manifest.json | 2 +- requirements_all.txt | 3 --- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index cda4ba9dc86..1656ca393bf 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -4,7 +4,7 @@ import logging from typing import List import boto3 -from ipify import exceptions, get_ip +import requests import voluptuous as vol from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE, HTTP_OK @@ -84,16 +84,12 @@ def _update_route53( # Get the IP Address and build an array of changes try: - ipaddress = get_ip() + ipaddress = requests.get("https://api.ipify.org/", timeout=5).text() - except exceptions.ConnectionError: + except requests.RequestException: _LOGGER.warning("Unable to reach the ipify service") return - except exceptions.ServiceError: - _LOGGER.warning("Unable to complete the ipfy request") - return - changes = [] for record in records: _LOGGER.debug("Processing record: %s", record) diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json index da2b6dc092c..4879f12a3be 100644 --- a/homeassistant/components/route53/manifest.json +++ b/homeassistant/components/route53/manifest.json @@ -2,6 +2,6 @@ "domain": "route53", "name": "AWS Route53", "documentation": "https://www.home-assistant.io/integrations/route53", - "requirements": ["boto3==1.9.252", "ipify==1.0.0"], + "requirements": ["boto3==1.9.252"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 63e5d4e044d..35dd62e7e92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -786,9 +786,6 @@ influxdb==5.2.3 # homeassistant.components.iperf3 iperf3==0.1.11 -# homeassistant.components.route53 -ipify==1.0.0 - # homeassistant.components.rest # homeassistant.components.verisure jsonpath==0.82 From d7811a4adfb7325bee89aeb700d94a1313018225 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jul 2020 18:52:10 -1000 Subject: [PATCH 099/362] Avoid generating a Context() object every second (#38085) Every second we were calling the getrandom() syscall to generate a uuid4 for a context that will never be looked: * In most setups there are no more time_changed listeners * The ones that do exist never care about context * time_changed events are never saved in the database --- homeassistant/core.py | 7 +++++-- tests/test_core.py | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index a8613dade59..3bfe3c3660c 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1539,6 +1539,7 @@ class Config: def _async_create_timer(hass: HomeAssistant) -> None: """Create a timer that will start on HOMEASSISTANT_START.""" handle = None + timer_context = Context() def schedule_tick(now: datetime.datetime) -> None: """Schedule a timer tick when the next second rolls around.""" @@ -1553,12 +1554,14 @@ def _async_create_timer(hass: HomeAssistant) -> None: """Fire next time event.""" now = dt_util.utcnow() - hass.bus.async_fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}) + hass.bus.async_fire(EVENT_TIME_CHANGED, {ATTR_NOW: now}, context=timer_context) # If we are more than a second late, a tick was missed late = monotonic() - target if late > 1: - hass.bus.async_fire(EVENT_TIMER_OUT_OF_SYNC, {ATTR_SECONDS: late}) + hass.bus.async_fire( + EVENT_TIMER_OUT_OF_SYNC, {ATTR_SECONDS: late}, context=timer_context + ) schedule_tick(now) diff --git a/tests/test_core.py b/tests/test_core.py index 884c5e98125..01a751b6570 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1090,9 +1090,20 @@ def test_timer_out_of_sync(mock_monotonic, loop): ): callback(target) - event_type, event_data = hass.bus.async_fire.mock_calls[1][1] - assert event_type == EVENT_TIMER_OUT_OF_SYNC - assert abs(event_data[ATTR_SECONDS] - 2.433333) < 0.001 + _, event_0_args, event_0_kwargs = hass.bus.async_fire.mock_calls[0] + event_context_0 = event_0_kwargs["context"] + + event_type_0, _ = event_0_args + assert event_type_0 == EVENT_TIME_CHANGED + + _, event_1_args, event_1_kwargs = hass.bus.async_fire.mock_calls[1] + event_type_1, event_data_1 = event_1_args + event_context_1 = event_1_kwargs["context"] + + assert event_type_1 == EVENT_TIMER_OUT_OF_SYNC + assert abs(event_data_1[ATTR_SECONDS] - 2.433333) < 0.001 + + assert event_context_0 == event_context_1 assert len(funcs) == 2 fire_time_event, _ = funcs From 6e2025a7487caa4c843b1180398bd5f1b08d4b23 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jul 2020 18:53:01 -1000 Subject: [PATCH 100/362] Use postgresql style uuid generation (uuid_generate_v1mc) for Context uuids (#38089) This avoids the syscall to getrandom() every time we generate a uuid. --- homeassistant/core.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 3bfe3c3660c..18493d85629 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -12,6 +12,7 @@ from ipaddress import ip_address import logging import os import pathlib +import random import re import threading from time import monotonic @@ -458,7 +459,13 @@ class Context: user_id: str = attr.ib(default=None) parent_id: Optional[str] = attr.ib(default=None) - id: str = attr.ib(factory=lambda: uuid.uuid4().hex) + # The uuid1 uses a random multicast MAC address instead of the real MAC address + # of the machine without the overhead of calling the getrandom() system call. + # + # This is effectively equivalent to PostgreSQL's uuid_generate_v1mc() function + id: str = attr.ib( + factory=lambda: uuid.uuid1(node=random.getrandbits(48) | (1 << 40)).hex + ) def as_dict(self) -> dict: """Return a dictionary representation of the context.""" From f742875e0d9e2c2ceb126e3dc7c81fb5bc6ce35b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 23 Jul 2020 08:20:49 +0200 Subject: [PATCH 101/362] Fix text error when getting getting external IP in route53 (#38100) --- homeassistant/components/route53/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py index 1656ca393bf..5355ed15f38 100644 --- a/homeassistant/components/route53/__init__.py +++ b/homeassistant/components/route53/__init__.py @@ -84,7 +84,7 @@ def _update_route53( # Get the IP Address and build an array of changes try: - ipaddress = requests.get("https://api.ipify.org/", timeout=5).text() + ipaddress = requests.get("https://api.ipify.org/", timeout=5).text except requests.RequestException: _LOGGER.warning("Unable to reach the ipify service") From 5583f43030530a540230115d0517f496bb12498c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 22 Jul 2020 23:21:32 -0700 Subject: [PATCH 102/362] Clean up fido tests (#38098) --- tests/components/fido/test_sensor.py | 35 +++++----------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py index c96e6d0ab58..551a59f0788 100644 --- a/tests/components/fido/test_sensor.py +++ b/tests/components/fido/test_sensor.py @@ -1,6 +1,7 @@ """The test for the fido sensor platform.""" import logging -import sys + +from pyfido.client import PyFidoError from homeassistant.bootstrap import async_setup_component from homeassistant.components.fido import sensor as fido @@ -36,35 +37,12 @@ class FidoClientMockError(FidoClientMock): async def fetch_data(self): """Return fake fetching data.""" - raise PyFidoErrorMock("Fake Error") - - -class PyFidoErrorMock(Exception): - """Fake PyFido Error.""" - - -class PyFidoClientFakeModule: - """Fake pyfido.client module.""" - - PyFidoError = PyFidoErrorMock - - -class PyFidoFakeModule: - """Fake pyfido module.""" - - FidoClient = FidoClientMockError - - -def fake_async_add_entities(component, update_before_add=False): - """Fake async_add_entities function.""" - pass + raise PyFidoError("Fake Error") async def test_fido_sensor(loop, hass): """Test the Fido number sensor.""" - with patch( - "homeassistant.components.fido.sensor.FidoClient", new=FidoClientMock - ), patch("homeassistant.components.fido.sensor.PyFidoError", new=PyFidoErrorMock): + with patch("homeassistant.components.fido.sensor.FidoClient", new=FidoClientMock): config = { "sensor": { "platform": "fido", @@ -87,10 +65,9 @@ async def test_fido_sensor(loop, hass): async def test_error(hass, caplog): """Test the Fido sensor errors.""" caplog.set_level(logging.ERROR) - sys.modules["pyfido"] = PyFidoFakeModule() - sys.modules["pyfido.client"] = PyFidoClientFakeModule() config = {} fake_async_add_entities = MagicMock() - await fido.async_setup_platform(hass, config, fake_async_add_entities) + with patch("homeassistant.components.fido.sensor.FidoClient", FidoClientMockError): + await fido.async_setup_platform(hass, config, fake_async_add_entities) assert fake_async_add_entities.called is False From fdc5208d18b900c429b149aa0a9b1c9ec09bd874 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jul 2020 20:21:57 -1000 Subject: [PATCH 103/362] Prevent the zeroconf service browser from terminating when a device without any addresses is discovered. (#38094) --- homeassistant/components/zeroconf/__init__.py | 8 +++++++ tests/components/zeroconf/test_init.py | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 06832386621..4da6fd5ab80 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -210,6 +210,11 @@ def setup(hass, config): return info = info_from_service(service_info) + if not info: + # Prevent the browser thread from collapsing + _LOGGER.debug("Failed to get addresses for device %s", name) + return + _LOGGER.debug("Discovered new device %s %s", name, info) # If we can handle it as a HomeKit discovery, we do that here. @@ -317,6 +322,9 @@ def info_from_service(service): except UnicodeDecodeError: pass + if not service.addresses: + return None + address = service.addresses[0] info = { diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0c32e60ecca..e8315b5dc75 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -49,6 +49,20 @@ def get_service_info_mock(service_type, name): ) +def get_service_info_mock_without_an_address(service_type, name): + """Return service info for get_service_info without any addresses.""" + return ServiceInfo( + service_type, + name, + addresses=[], + port=80, + weight=0, + priority=0, + server="name.local.", + properties=PROPERTIES, + ) + + def get_homekit_info_mock(model, pairing_status): """Return homekit info for get_service_info for an homekit device.""" @@ -308,6 +322,15 @@ async def test_info_from_service_non_utf8(hass): assert raw_info["non-utf8-value"] is NON_UTF8_VALUE +async def test_info_from_service_with_addresses(hass): + """Test info_from_service does not throw when there are no addresses.""" + service_type = "_test._tcp.local." + info = zeroconf.info_from_service( + get_service_info_mock_without_an_address(service_type, f"test.{service_type}") + ) + assert info is None + + async def test_get_instance(hass, mock_zeroconf): """Test we get an instance.""" assert await hass.components.zeroconf.async_get_instance() is mock_zeroconf From 4001eabafa25f090bfa8eccb60603b00957e8407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2020 13:16:31 +0200 Subject: [PATCH 104/362] Bump codecov/codecov-action from v1.0.11 to v1.0.12 (#38102) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from v1.0.11 to v1.0.12. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Commits](https://github.com/codecov/codecov-action/compare/v1.0.11...07127fde53bc3ccd346d47ab2f14c390161ad108) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c17d6ab36c1..14bfd9d6ee5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -781,4 +781,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.0.11 + uses: codecov/codecov-action@v1.0.12 From 3365677484c3728ba55d4df32d6c54988f8246fe Mon Sep 17 00:00:00 2001 From: melyux <10296053+melyux@users.noreply.github.com> Date: Thu, 23 Jul 2020 07:43:57 -0500 Subject: [PATCH 105/362] Add 'alarm_event_occurred' property from AlarmDecoder (#38055) --- homeassistant/components/alarmdecoder/alarm_control_panel.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 38b9c5999be..117374552f3 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -84,6 +84,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): self._name = "Alarm Panel" self._state = None self._ac_power = None + self._alarm_event_occurred = None self._backlight_on = None self._battery_low = None self._check_zone = None @@ -117,6 +118,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): self._state = STATE_ALARM_DISARMED self._ac_power = message.ac_power + self._alarm_event_occurred = message.alarm_event_occurred self._backlight_on = message.backlight_on self._battery_low = message.battery_low self._check_zone = message.check_zone @@ -163,6 +165,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Return the state attributes.""" return { "ac_power": self._ac_power, + "alarm_event_occurred": self._alarm_event_occurred, "backlight_on": self._backlight_on, "battery_low": self._battery_low, "check_zone": self._check_zone, From 98a8ce0555a20b60f4109b61670767983a3550d7 Mon Sep 17 00:00:00 2001 From: Daniel <45919533+sMauldaeschle@users.noreply.github.com> Date: Thu, 23 Jul 2020 15:02:54 +0200 Subject: [PATCH 106/362] Add homematic IPKeyBlindMulti device (#38059) * Update const.py Added device class "IPKeyBlindMulti" to const.py in order to support HomematicIP device HmIP-DRBLI4. The site package pyhomematic supports this class already in actors.py. * Update const.py --- homeassistant/components/homematic/const.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index bbb8c1ff1ca..e4a481f62d2 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -152,7 +152,14 @@ HM_DEVICE_TYPES = { "IPRemoteMotionV2", "IPWInputDevice", ], - DISCOVER_COVER: ["Blind", "KeyBlind", "IPKeyBlind", "IPKeyBlindTilt", "IPGarage"], + DISCOVER_COVER: [ + "Blind", + "KeyBlind", + "IPKeyBlind", + "IPKeyBlindTilt", + "IPGarage", + "IPKeyBlindMulti", + ], DISCOVER_LOCKS: ["KeyMatic"], } From 6652af5cc0ea898ebfe76d97812df029b873d4da Mon Sep 17 00:00:00 2001 From: mvn23 Date: Thu, 23 Jul 2020 20:24:17 +0200 Subject: [PATCH 107/362] Add set_central_heating_ovrd service to opentherm_gw (#34425) * Add set_central_heating_ovrd service to opentherm_gw * Use await instead of async_create_task Use await instead of async_create_task as per review. Co-authored-by: J. Nick Koston * Use boolean values for service call to opentherm_gw.set_central_heating_ovrd Co-authored-by: J. Nick Koston --- .../components/opentherm_gw/__init__.py | 22 +++++++++++++++++++ .../components/opentherm_gw/const.py | 2 ++ .../components/opentherm_gw/services.yaml | 16 ++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 71fd104bd2f..8d1d3ae4d62 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -29,6 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( + ATTR_CH_OVRD, ATTR_DHW_OVRD, ATTR_GW_ID, ATTR_LEVEL, @@ -39,6 +40,7 @@ from .const import ( DATA_OPENTHERM_GW, DOMAIN, SERVICE_RESET_GATEWAY, + SERVICE_SET_CH_OVRD, SERVICE_SET_CLOCK, SERVICE_SET_CONTROL_SETPOINT, SERVICE_SET_GPIO_MODE, @@ -127,6 +129,14 @@ def register_services(hass): ) } ) + service_set_central_heating_ovrd_schema = vol.Schema( + { + vol.Required(ATTR_GW_ID): vol.All( + cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS]) + ), + vol.Required(ATTR_CH_OVRD): cv.boolean, + } + ) service_set_clock_schema = vol.Schema( { vol.Required(ATTR_GW_ID): vol.All( @@ -235,6 +245,18 @@ def register_services(hass): DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway, service_reset_schema ) + async def set_ch_ovrd(call): + """Set the central heating override on the OpenTherm Gateway.""" + gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] + await gw_dev.gateway.set_ch_enable_bit(1 if call.data[ATTR_CH_OVRD] else 0) + + hass.services.async_register( + DOMAIN, + SERVICE_SET_CH_OVRD, + set_ch_ovrd, + service_set_central_heating_ovrd_schema, + ) + async def set_control_setpoint(call): """Set the control setpoint on the OpenTherm Gateway.""" gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][call.data[ATTR_GW_ID]] diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 14b54366b4a..5be29522535 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_GW_ID = "gateway_id" ATTR_LEVEL = "level" ATTR_DHW_OVRD = "dhw_override" +ATTR_CH_OVRD = "ch_override" CONF_CLIMATE = "climate" CONF_FLOOR_TEMP = "floor_temperature" @@ -27,6 +28,7 @@ DEVICE_CLASS_PROBLEM = "problem" DOMAIN = "opentherm_gw" SERVICE_RESET_GATEWAY = "reset_gateway" +SERVICE_SET_CH_OVRD = "set_central_heating_ovrd" SERVICE_SET_CLOCK = "set_clock" SERVICE_SET_CONTROL_SETPOINT = "set_control_setpoint" SERVICE_SET_HOT_WATER_SETPOINT = "set_hot_water_setpoint" diff --git a/homeassistant/components/opentherm_gw/services.yaml b/homeassistant/components/opentherm_gw/services.yaml index f60648ee8d4..8a1bddc2100 100644 --- a/homeassistant/components/opentherm_gw/services.yaml +++ b/homeassistant/components/opentherm_gw/services.yaml @@ -7,6 +7,22 @@ reset_gateway: description: The gateway_id of the OpenTherm Gateway. example: "opentherm_gateway" +set_central_heating_ovrd: + description: > + Set the central heating override option on the gateway. + When overriding the control setpoint (via a set_control_setpoint service call with a value other than 0), the gateway automatically enables the central heating override to start heating. + This service can then be used to control the central heating override status. + To return control of the central heating to the thermostat, call the set_control_setpoint service with temperature value 0. + You will only need this if you are writing your own software thermostat. + fields: + gateway_id: + description: The gateway_id of the OpenTherm Gateway. + example: "opentherm_gateway" + ch_override: + description: > + The desired boolean value for the central heating override. + example: "on" + set_clock: description: Set the clock and day of the week on the connected thermostat. fields: From 8cfffd00d607e75595e208fbe46794084e6c8707 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Thu, 23 Jul 2020 21:17:11 +0200 Subject: [PATCH 108/362] Fix state automation trigger (#38014) (#38032) for scenarios of enabling/disabling (~ creating/removing) entities, so it does not trigger in removal if a `to: xxx` is defined, and also does not trigger in creation if a `from: xxx` is present. --- homeassistant/components/automation/state.py | 22 ++----- tests/components/automation/test_state.py | 65 ++++++++++++++++---- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index fe49e1cf532..9d504d40de5 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -73,16 +73,13 @@ async def async_attach_trigger( from_s = event.data.get("old_state") to_s = event.data.get("new_state") + old_state = getattr(from_s, "state", None) + new_state = getattr(to_s, "state", None) if ( - (from_s is not None and not match_from_state(from_s.state)) - or (to_s is not None and not match_to_state(to_s.state)) - or ( - not match_all - and from_s is not None - and to_s is not None - and from_s.state == to_s.state - ) + not match_from_state(old_state) + or not match_to_state(new_state) + or (not match_all and old_state == new_state) ): return @@ -104,15 +101,6 @@ async def async_attach_trigger( ) ) - # Ignore changes to state attributes if from/to is in use - if ( - not match_all - and from_s is not None - and to_s is not None - and from_s.state == to_s.state - ): - return - if not time_delta: call_action() return diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 13165da8488..9842818efab 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -519,28 +519,69 @@ async def test_if_fires_on_entity_change_with_for(hass, calls): assert 1 == len(calls) -async def test_if_fires_on_entity_removal(hass, calls): - """Test for firing on entity removal, when new_state is None.""" - context = Context() - hass.states.async_set("test.entity", "hello") - await hass.async_block_till_done() - +async def test_if_fires_on_entity_creation_and_removal(hass, calls): + """Test for firing on entity creation and removal, with to/from constraints.""" + # set automations for multiple combinations to/from assert await async_setup_component( hass, automation.DOMAIN, { - automation.DOMAIN: { - "trigger": {"platform": "state", "entity_id": "test.entity"}, - "action": {"service": "test.automation"}, - } + automation.DOMAIN: [ + { + "trigger": {"platform": "state", "entity_id": "test.entity_0"}, + "action": {"service": "test.automation"}, + }, + { + "trigger": { + "platform": "state", + "from": "hello", + "entity_id": "test.entity_1", + }, + "action": {"service": "test.automation"}, + }, + { + "trigger": { + "platform": "state", + "to": "world", + "entity_id": "test.entity_2", + }, + "action": {"service": "test.automation"}, + }, + ], }, ) await hass.async_block_till_done() - assert hass.states.async_remove("test.entity", context=context) + # use contexts to identify trigger entities + context_0 = Context() + context_1 = Context() + context_2 = Context() + + # automation with match_all triggers on creation + hass.states.async_set("test.entity_0", "any", context=context_0) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].context.parent_id == context.id + assert calls[0].context.parent_id == context_0.id + + # create entities, trigger on test.entity_2 ('to' matches, no 'from') + hass.states.async_set("test.entity_1", "hello", context=context_1) + hass.states.async_set("test.entity_2", "world", context=context_2) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].context.parent_id == context_2.id + + # removal of both, trigger on test.entity_1 ('from' matches, no 'to') + assert hass.states.async_remove("test.entity_1", context=context_1) + assert hass.states.async_remove("test.entity_2", context=context_2) + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].context.parent_id == context_1.id + + # automation with match_all triggers on removal + assert hass.states.async_remove("test.entity_0", context=context_0) + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].context.parent_id == context_0.id async def test_if_fires_on_for_condition(hass, calls): From eb1970c015f0142b361fbc48fe9e1a4ac15ec214 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Thu, 23 Jul 2020 14:18:46 -0700 Subject: [PATCH 109/362] Bump androidtv to 0.0.46 (#38090) --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index d1747b8cd42..40e7575bbb9 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ "adb-shell[async]==0.2.0", - "androidtv[async]==0.0.45", + "androidtv[async]==0.0.46", "pure-python-adb==0.2.2.dev0" ], "codeowners": ["@JeffLIrion"] diff --git a/requirements_all.txt b/requirements_all.txt index 35dd62e7e92..b9d880fd71e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -234,7 +234,7 @@ ambiclimate==0.2.1 amcrest==1.7.0 # homeassistant.components.androidtv -androidtv[async]==0.0.45 +androidtv[async]==0.0.46 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af0c25268c2..c1f2bafe0a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -135,7 +135,7 @@ airly==0.0.2 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv[async]==0.0.45 +androidtv[async]==0.0.46 # homeassistant.components.apns apns2==0.3.0 From 0e0f61764a1a26b97e67bf35d2c382d9a7a5fe02 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Fri, 24 Jul 2020 01:49:46 +0200 Subject: [PATCH 110/362] Fix updates of Rssi for control devices in rfxtrx (#38131) * Change entity to entity_info * Fix bug in RSSI for Control devices --- .../components/rfxtrx/binary_sensor.py | 16 +++--- homeassistant/components/rfxtrx/sensor.py | 9 ++- tests/components/rfxtrx/test_sensor.py | 57 +++++++++++++++++++ 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 0c2cd728afc..627e5a3dd5e 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -48,7 +48,7 @@ async def async_setup_entry( def supported(event): return isinstance(event, rfxtrxmod.ControlEvent) - for packet_id, entity in discovery_info[CONF_DEVICES].items(): + for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: _LOGGER.error("Invalid device: %s", packet_id) @@ -56,7 +56,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) if device_id in device_ids: continue device_ids.add(device_id) @@ -68,11 +70,11 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - entity.get(CONF_DEVICE_CLASS), - entity.get(CONF_OFF_DELAY), - entity.get(CONF_DATA_BITS), - entity.get(CONF_COMMAND_ON), - entity.get(CONF_COMMAND_OFF), + entity_info.get(CONF_DEVICE_CLASS), + entity_info.get(CONF_OFF_DELAY), + entity_info.get(CONF_DATA_BITS), + entity_info.get(CONF_COMMAND_ON), + entity_info.get(CONF_COMMAND_OFF), ) sensors.append(device) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 490885bec7a..8110e8d8c6c 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -65,7 +65,7 @@ async def async_setup_entry( return isinstance(event, (ControlEvent, SensorEvent)) entities = [] - for packet_id, entity in discovery_info[CONF_DEVICES].items(): + for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) if event is None: _LOGGER.error("Invalid device: %s", packet_id) @@ -73,7 +73,9 @@ async def async_setup_entry( if not supported(event): continue - device_id = get_device_id(event.device, data_bits=entity.get(CONF_DATA_BITS)) + device_id = get_device_id( + event.device, data_bits=entity_info.get(CONF_DATA_BITS) + ) for data_type in set(event.values) & set(DATA_TYPES): data_id = (*device_id, data_type) if data_id in data_ids: @@ -169,9 +171,6 @@ class RfxtrxSensor(RfxtrxEntity): @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" - if not isinstance(event, SensorEvent): - return - if device_id != self._device_id: return diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index a8cabcd401e..1d7b2de9a7a 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -290,3 +290,60 @@ async def test_update_of_sensors(hass, rfxtrx): state = hass.states.get("sensor.wt260_wt260h_wt440h_wt450_wt450h_06_01_humidity") assert state assert state.state == "15" + + +async def test_rssi_sensor(hass, rfxtrx): + """Test with 1 sensor.""" + assert await async_setup_component( + hass, + "rfxtrx", + { + "rfxtrx": { + "device": "abcd", + "devices": { + "0913000022670e013b70": { + "data_bits": 4, + "command_on": 0xE, + "command_off": 0x7, + }, + "0b1100cd0213c7f230010f71": {}, + }, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + + state = hass.states.get("sensor.pt2262_22670e_rssi_numeric") + assert state + assert state.state == "unknown" + assert state.attributes.get("friendly_name") == "PT2262 22670e Rssi numeric" + assert state.attributes.get("unit_of_measurement") == "dBm" + + state = hass.states.get("sensor.ac_213c7f2_48_rssi_numeric") + assert state + assert state.state == "unknown" + assert state.attributes.get("friendly_name") == "AC 213c7f2:48 Rssi numeric" + assert state.attributes.get("unit_of_measurement") == "dBm" + + await rfxtrx.signal("0913000022670e013b70") + await rfxtrx.signal("0b1100cd0213c7f230010f71") + + state = hass.states.get("sensor.pt2262_22670e_rssi_numeric") + assert state + assert state.state == "-64" + + state = hass.states.get("sensor.ac_213c7f2_48_rssi_numeric") + assert state + assert state.state == "-64" + + await rfxtrx.signal("0913000022670e013b60") + await rfxtrx.signal("0b1100cd0213c7f230010f61") + + state = hass.states.get("sensor.pt2262_22670e_rssi_numeric") + assert state + assert state.state == "-72" + + state = hass.states.get("sensor.ac_213c7f2_48_rssi_numeric") + assert state + assert state.state == "-72" From 124ea04e57b8d8796114aac15aaa7540ab12a5aa Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Fri, 24 Jul 2020 00:02:30 +0000 Subject: [PATCH 111/362] [ci skip] Translation update --- .../components/control4/translations/es.json | 31 +++++++ .../components/wolflink/translations/es.json | 27 ++++++ .../wolflink/translations/sensor.es.json | 87 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 homeassistant/components/control4/translations/es.json create mode 100644 homeassistant/components/wolflink/translations/es.json create mode 100644 homeassistant/components/wolflink/translations/sensor.es.json diff --git a/homeassistant/components/control4/translations/es.json b/homeassistant/components/control4/translations/es.json new file mode 100644 index 00000000000..c5d49e30680 --- /dev/null +++ b/homeassistant/components/control4/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Fallo al conectar", + "invalid_auth": "Autentificaci\u00f3n invalida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Direcci\u00f3n IP", + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "description": "Por favor, introduzca su cuenta de Control4 y la direcci\u00f3n IP de su controlador local." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Segundos entre actualizaciones" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/es.json b/homeassistant/components/wolflink/translations/es.json new file mode 100644 index 00000000000..359a2d0b27e --- /dev/null +++ b/homeassistant/components/wolflink/translations/es.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "device": { + "data": { + "device_name": "Dispositivo" + }, + "title": "Seleccionar dispositivo WOLF" + }, + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario" + }, + "title": "Conexi\u00f3n WOLF SmartSet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.es.json b/homeassistant/components/wolflink/translations/sensor.es.json new file mode 100644 index 00000000000..98c6b5bd242 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.es.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "Compuerta de gases de combusti\u00f3n", + "absenkbetrieb": "Modo de retroceso", + "absenkstop": "Parada de retroceso", + "aktiviert": "Activado", + "antilegionellenfunktion": "Funci\u00f3n anti legionela", + "at_abschaltung": "Apagado de OT", + "at_frostschutz": "OT protecci\u00f3n contra heladas", + "aus": "Deshabilitado", + "auto": "Autom\u00e1tico", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Apagado autom\u00e1tico", + "automatik_ein": "Encendido autom\u00e1tico", + "bereit_keine_ladung": "Listo, no est\u00e1 cargando", + "betrieb_ohne_brenner": "Trabajando sin quemador", + "cooling": "Enfriamiento", + "deaktiviert": "Inactivo", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "Habilitado", + "estrichtrocknung": "Secado en regla", + "externe_deaktivierung": "Desactivaci\u00f3n externa", + "fernschalter_ein": "Mando a distancia activado", + "frost_heizkreis": "Escarcha del circuito de calefacci\u00f3n", + "frost_warmwasser": "Heladas de DHW", + "frostschutz": "Protecci\u00f3n contra las heladas", + "gasdruck": "Presion del gas", + "glt_betrieb": "Modo BMS", + "gradienten_uberwachung": "Monitoreo de gradiente", + "heizbetrieb": "Modo de calefacci\u00f3n", + "heizgerat_mit_speicher": "Caldera con cilindro", + "heizung": "Calefacci\u00f3n", + "initialisierung": "Inicializaci\u00f3n", + "kalibration": "Calibraci\u00f3n", + "kalibration_heizbetrieb": "Calibraci\u00f3n del modo de calefacci\u00f3n", + "kalibration_kombibetrieb": "Calibraci\u00f3n en modo combinado", + "kalibration_warmwasserbetrieb": "Calibraci\u00f3n DHW", + "kaskadenbetrieb": "Operaci\u00f3n en cascada", + "kombibetrieb": "Modo combinado", + "kombigerat": "Caldera combinada", + "kombigerat_mit_solareinbindung": "Caldera Combi con integraci\u00f3n solar", + "mindest_kombizeit": "Tiempo m\u00ednimo de combinaci\u00f3n", + "nachlauf_heizkreispumpe": "Bomba de circuito de calefacci\u00f3n en ejecuci\u00f3n", + "nachspulen": "Enviar enjuague", + "nur_heizgerat": "S\u00f3lo la caldera", + "parallelbetrieb": "Modo paralelo", + "partymodus": "Modo fiesta", + "perm_cooling": "Enfriamiento permanente", + "permanent": "Permanente", + "permanentbetrieb": "Modo permanente", + "reduzierter_betrieb": "Modo limitado", + "rt_abschaltung": "RT apagado", + "rt_frostschutz": "RT protecci\u00f3n contra heladas", + "ruhekontakt": "Contacto de reposo", + "schornsteinfeger": "Prueba de emisiones", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Arranque suave.", + "solarbetrieb": "Modo solar", + "sparbetrieb": "Modo econ\u00f3mico", + "sparen": "Econom\u00eda", + "spreizung_hoch": "dT demasiado ancho", + "spreizung_kf": "Difundir el KF", + "stabilisierung": "Estabilizaci\u00f3n", + "standby": "En espera", + "start": "Arranque", + "storung": "Fallo", + "taktsperre": "Anti-ciclo", + "telefonfernschalter": "Interruptor remoto del tel\u00e9fono", + "test": "Prueba", + "tpw": "TPW", + "urlaubsmodus": "Modo vacaciones", + "ventilprufung": "Prueba de la v\u00e1lvula", + "vorspulen": "Enjuague de entrada", + "warmwasser": "DHW", + "warmwasser_schnellstart": "Inicio r\u00e1pido de DHW", + "warmwasserbetrieb": "Modo DHW", + "warmwassernachlauf": "DHW en ejecuci\u00f3n", + "warmwasservorrang": "Prioridad de DHW", + "zunden": "Encendido" + } + } +} \ No newline at end of file From 2dfd767b8c4831085695b4014ec40e8423048715 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Thu, 23 Jul 2020 17:55:27 -0700 Subject: [PATCH 112/362] Upgrade bond-api to 0.1.7 (#38121) --- homeassistant/components/bond/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 6b0bae84893..9d5a9975503 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", "requirements": [ - "bond-api==0.1.4" + "bond-api==0.1.7" ], "codeowners": [ "@prystupa" diff --git a/requirements_all.txt b/requirements_all.txt index b9d880fd71e..b999473018f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,7 +356,7 @@ blockchain==1.4.4 bomradarloop==0.1.4 # homeassistant.components.bond -bond-api==0.1.4 +bond-api==0.1.7 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1f2bafe0a1..199183d76ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ blinkpy==0.15.1 bomradarloop==0.1.4 # homeassistant.components.bond -bond-api==0.1.4 +bond-api==0.1.7 # homeassistant.components.braviatv bravia-tv==1.0.6 From a5b7a2c228ca65a7f85cea40a20946abfd750ae1 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 23 Jul 2020 20:02:29 -0600 Subject: [PATCH 113/362] Fix SimpliSafe to work with new MFA (#38097) * Fix SimpliSafe to work with new MFA * Code review (part 1) * Input needed from Martin * Code review * Code review * Restore YAML * Tests * Code review * Remove JSON patching in tests * Add reauth test * One more reauth test * Don't abuse the word "conf" * Update homeassistant/components/simplisafe/config_flow.py Co-authored-by: Martin Hjelmare * Test coverage Co-authored-by: Martin Hjelmare --- .../components/simplisafe/__init__.py | 77 +++++---- .../simplisafe/alarm_control_panel.py | 12 +- .../components/simplisafe/config_flow.py | 132 +++++++++++++--- homeassistant/components/simplisafe/const.py | 3 + homeassistant/components/simplisafe/lock.py | 10 +- .../components/simplisafe/manifest.json | 2 +- .../components/simplisafe/strings.json | 20 ++- .../simplisafe/translations/en.json | 22 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/simplisafe/test_config_flow.py | 148 +++++++++++++----- 11 files changed, 314 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8895244158a..327549eeb62 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,6 +1,6 @@ """Support for SimpliSafe alarm systems.""" import asyncio -import logging +from uuid import UUID from simplipy import API from simplipy.errors import InvalidCredentialsError, SimplipyError @@ -55,11 +55,10 @@ from .const import ( DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, + LOGGER, VOLUMES, ) -_LOGGER = logging.getLogger(__name__) - CONF_ACCOUNTS = "accounts" DATA_LISTENER = "listener" @@ -161,6 +160,13 @@ def _async_save_refresh_token(hass, config_entry, token): ) +async def async_get_client_id(hass): + """Get a client ID (based on the HASS unique ID) for the SimpliSafe API.""" + hass_id = await hass.helpers.instance_id.async_get() + # SimpliSafe requires full, "dashed" versions of UUIDs: + return str(UUID(hass_id)) + + async def async_register_base_station(hass, system, config_entry_id): """Register a new bridge.""" device_registry = await dr.async_get_registry(hass) @@ -220,17 +226,18 @@ async def async_setup_entry(hass, config_entry): _verify_domain_control = verify_domain_control(hass, DOMAIN) + client_id = await async_get_client_id(hass) websession = aiohttp_client.async_get_clientsession(hass) try: api = await API.login_via_token( - config_entry.data[CONF_TOKEN], session=websession + config_entry.data[CONF_TOKEN], client_id=client_id, session=websession ) except InvalidCredentialsError: - _LOGGER.error("Invalid credentials provided") + LOGGER.error("Invalid credentials provided") return False except SimplipyError as err: - _LOGGER.error("Config entry failed: %s", err) + LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady _async_save_refresh_token(hass, config_entry, api.refresh_token) @@ -252,7 +259,7 @@ async def async_setup_entry(hass, config_entry): """Decorate.""" system_id = int(call.data[ATTR_SYSTEM_ID]) if system_id not in simplisafe.systems: - _LOGGER.error("Unknown system ID in service call: %s", system_id) + LOGGER.error("Unknown system ID in service call: %s", system_id) return await coro(call) @@ -266,7 +273,7 @@ async def async_setup_entry(hass, config_entry): """Decorate.""" system = simplisafe.systems[int(call.data[ATTR_SYSTEM_ID])] if system.version != 3: - _LOGGER.error("Service only available on V3 systems") + LOGGER.error("Service only available on V3 systems") return await coro(call) @@ -280,7 +287,7 @@ async def async_setup_entry(hass, config_entry): try: await system.clear_notifications() except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return @verify_system_exists @@ -291,7 +298,7 @@ async def async_setup_entry(hass, config_entry): try: await system.remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return @verify_system_exists @@ -302,7 +309,7 @@ async def async_setup_entry(hass, config_entry): try: await system.set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return @verify_system_exists @@ -320,7 +327,7 @@ async def async_setup_entry(hass, config_entry): } ) except SimplipyError as err: - _LOGGER.error("Error during service call: %s", err) + LOGGER.error("Error during service call: %s", err) return for service, method, schema in [ @@ -373,16 +380,16 @@ class SimpliSafeWebsocket: @staticmethod def _on_connect(): """Define a handler to fire when the websocket is connected.""" - _LOGGER.info("Connected to websocket") + LOGGER.info("Connected to websocket") @staticmethod def _on_disconnect(): """Define a handler to fire when the websocket is disconnected.""" - _LOGGER.info("Disconnected from websocket") + LOGGER.info("Disconnected from websocket") def _on_event(self, event): """Define a handler to fire when a new SimpliSafe event arrives.""" - _LOGGER.debug("New websocket event: %s", event) + LOGGER.debug("New websocket event: %s", event) async_dispatcher_send( self._hass, TOPIC_UPDATE_WEBSOCKET.format(event.system_id), event ) @@ -451,7 +458,7 @@ class SimpliSafe: if not to_add: return - _LOGGER.debug("New system notifications: %s", to_add) + LOGGER.debug("New system notifications: %s", to_add) self._system_notifications[system.system_id].update(to_add) @@ -492,7 +499,7 @@ class SimpliSafe: system.system_id ] = await system.get_latest_event() except SimplipyError as err: - _LOGGER.error("Error while fetching initial event: %s", err) + LOGGER.error("Error while fetching initial event: %s", err) self.initial_event_to_use[system.system_id] = {} async def refresh(event_time): @@ -512,7 +519,7 @@ class SimpliSafe: """Update a system.""" await system.update() self._async_process_new_notifications(system) - _LOGGER.debug('Updated REST API data for "%s"', system.address) + LOGGER.debug('Updated REST API data for "%s"', system.address) async_dispatcher_send( self._hass, TOPIC_UPDATE_REST_API.format(system.system_id) ) @@ -523,27 +530,37 @@ class SimpliSafe: for result in results: if isinstance(result, InvalidCredentialsError): if self._emergency_refresh_token_used: - _LOGGER.error( - "SimpliSafe authentication disconnected. Please restart HASS" + LOGGER.error( + "Token disconnected or invalid. Please re-auth the " + "SimpliSafe integration in HASS" ) - remove_listener = self._hass.data[DOMAIN][DATA_LISTENER].pop( - self._config_entry.entry_id + self._hass.async_create_task( + self._hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data=self._config_entry.data, + ) ) - remove_listener() return - _LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") + LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") self._emergency_refresh_token_used = True - return await self._api.refresh_access_token( - self._config_entry.data[CONF_TOKEN] - ) + + try: + await self._api.refresh_access_token( + self._config_entry.data[CONF_TOKEN] + ) + return + except SimplipyError as err: + LOGGER.error("Error while using stored refresh token: %s", err) + return if isinstance(result, SimplipyError): - _LOGGER.error("SimpliSafe error while updating: %s", result) + LOGGER.error("SimpliSafe error while updating: %s", result) return - if isinstance(result, SimplipyError): - _LOGGER.error("Unknown error while updating: %s", result) + if isinstance(result, Exception): # pylint: disable=broad-except + LOGGER.error("Unknown error while updating: %s", result) return if self._api.refresh_token != self._config_entry.data[CONF_TOKEN]: diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 7998de463f6..acc7e3bb0a8 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -1,5 +1,4 @@ """Support for SimpliSafe alarm control panels.""" -import logging import re from simplipy.errors import SimplipyError @@ -50,11 +49,10 @@ from .const import ( ATTR_VOICE_PROMPT_VOLUME, DATA_CLIENT, DOMAIN, + LOGGER, VOLUME_STRING_MAP, ) -_LOGGER = logging.getLogger(__name__) - ATTR_BATTERY_BACKUP_POWER_LEVEL = "battery_backup_power_level" ATTR_GSM_STRENGTH = "gsm_strength" ATTR_PIN_NAME = "pin_name" @@ -146,7 +144,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): return True if not code or code != self._simplisafe.options[CONF_CODE]: - _LOGGER.warning( + LOGGER.warning( "Incorrect alarm code entered (target state: %s): %s", state, code ) return False @@ -161,7 +159,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): try: await self._system.set_off() except SimplipyError as err: - _LOGGER.error('Error while disarming "%s": %s', self._system.name, err) + LOGGER.error('Error while disarming "%s": %s', self._system.name, err) return self._state = STATE_ALARM_DISARMED @@ -174,7 +172,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): try: await self._system.set_home() except SimplipyError as err: - _LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err) + LOGGER.error('Error while arming "%s" (home): %s', self._system.name, err) return self._state = STATE_ALARM_ARMED_HOME @@ -187,7 +185,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): try: await self._system.set_away() except SimplipyError as err: - _LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err) + LOGGER.error('Error while arming "%s" (away): %s', self._system.name, err) return self._state = STATE_ALARM_ARMING diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 1225f6de818..d4a076860a1 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,6 +1,10 @@ """Config flow to configure the SimpliSafe component.""" from simplipy import API -from simplipy.errors import SimplipyError +from simplipy.errors import ( + InvalidCredentialsError, + PendingAuthorizationError, + SimplipyError, +) import voluptuous as vol from homeassistant import config_entries @@ -8,7 +12,8 @@ from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERN from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DOMAIN # pylint: disable=unused-import +from . import async_get_client_id +from .const import DOMAIN, LOGGER # pylint: disable=unused-import class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -19,21 +24,18 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the config flow.""" - self.data_schema = vol.Schema( + self.full_data_schema = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_CODE): str, } ) + self.password_data_schema = vol.Schema({vol.Required(CONF_PASSWORD): str}) - async def _show_form(self, errors=None): - """Show the form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=self.data_schema, - errors=errors if errors else {}, - ) + self._code = None + self._password = None + self._username = None @staticmethod @callback @@ -41,34 +43,112 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) + async def _async_get_simplisafe_api(self): + """Get an authenticated SimpliSafe API client.""" + client_id = await async_get_client_id(self.hass) + websession = aiohttp_client.async_get_clientsession(self.hass) + + return await API.login_via_credentials( + self._username, self._password, client_id=client_id, session=websession, + ) + + async def _async_login_during_step(self, *, step_id, form_schema): + """Attempt to log into the API from within a config flow step.""" + errors = {} + + try: + simplisafe = await self._async_get_simplisafe_api() + except PendingAuthorizationError: + LOGGER.info("Awaiting confirmation of MFA email click") + return await self.async_step_mfa() + except InvalidCredentialsError: + errors = {"base": "invalid_credentials"} + except SimplipyError as err: + LOGGER.error("Unknown error while logging into SimpliSafe: %s", err) + errors = {"base": "unknown"} + + if errors: + return self.async_show_form( + step_id=step_id, data_schema=form_schema, errors=errors, + ) + + return await self.async_step_finish( + { + CONF_USERNAME: self._username, + CONF_TOKEN: simplisafe.refresh_token, + CONF_CODE: self._code, + } + ) + + async def async_step_finish(self, user_input=None): + """Handle finish config entry setup.""" + existing_entry = await self.async_set_unique_id(self._username) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry(title=self._username, data=user_input) + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) + async def async_step_mfa(self, user_input=None): + """Handle multi-factor auth confirmation.""" + if user_input is None: + return self.async_show_form(step_id="mfa") + + try: + simplisafe = await self._async_get_simplisafe_api() + except PendingAuthorizationError: + LOGGER.error("Still awaiting confirmation of MFA email click") + return self.async_show_form( + step_id="mfa", errors={"base": "still_awaiting_mfa"} + ) + + return await self.async_step_finish( + { + CONF_USERNAME: self._username, + CONF_TOKEN: simplisafe.refresh_token, + CONF_CODE: self._code, + } + ) + + async def async_step_reauth(self, config): + """Handle configuration by re-auth.""" + self._code = config.get(CONF_CODE) + self._username = config[CONF_USERNAME] + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle re-auth completion.""" + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", data_schema=self.password_data_schema + ) + + self._password = user_input[CONF_PASSWORD] + + return await self._async_login_during_step( + step_id="reauth_confirm", form_schema=self.password_data_schema + ) + async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" if not user_input: - return await self._show_form() + return self.async_show_form( + step_id="user", data_schema=self.full_data_schema + ) await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - websession = aiohttp_client.async_get_clientsession(self.hass) + self._code = user_input.get(CONF_CODE) + self._password = user_input[CONF_PASSWORD] + self._username = user_input[CONF_USERNAME] - try: - simplisafe = await API.login_via_credentials( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=websession - ) - except SimplipyError: - return await self._show_form(errors={"base": "invalid_credentials"}) - - return self.async_create_entry( - title=user_input[CONF_USERNAME], - data={ - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_TOKEN: simplisafe.refresh_token, - CONF_CODE: user_input.get(CONF_CODE), - }, + return await self._async_login_during_step( + step_id="user", form_schema=self.full_data_schema ) diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index 6ca5f8323a7..36d191d0ab8 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -1,8 +1,11 @@ """Define constants for the SimpliSafe component.""" from datetime import timedelta +import logging from simplipy.system.v3 import VOLUME_HIGH, VOLUME_LOW, VOLUME_MEDIUM, VOLUME_OFF +LOGGER = logging.getLogger(__package__) + DOMAIN = "simplisafe" DATA_CLIENT = "client" diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 78866ce9004..82177fb4387 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,6 +1,4 @@ """Support for SimpliSafe locks.""" -import logging - from simplipy.errors import SimplipyError from simplipy.lock import LockStates from simplipy.websocket import EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED @@ -9,9 +7,7 @@ from homeassistant.components.lock import LockEntity from homeassistant.core import callback from . import SimpliSafeEntity -from .const import DATA_CLIENT, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import DATA_CLIENT, DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" ATTR_JAMMED = "jammed" @@ -52,7 +48,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): try: await self._lock.lock() except SimplipyError as err: - _LOGGER.error('Error while locking "%s": %s', self._lock.name, err) + LOGGER.error('Error while locking "%s": %s', self._lock.name, err) return self._is_locked = True @@ -62,7 +58,7 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): try: await self._lock.unlock() except SimplipyError as err: - _LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) + LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) return self._is_locked = False diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 6b271012c8e..c986add4539 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,6 +3,6 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==9.2.0"], + "requirements": ["simplisafe-python==9.2.1"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 0a097c9fda8..7f724de9db5 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -1,6 +1,17 @@ { "config": { "step": { + "mfa": { + "title": "SimpliSafe Multi-Factor Authentication", + "description": "Check your email for a link from SimpliSafe. After verifying the link, return here to complete the installation of the integration." + }, + "reauth_confirm": { + "title": "Re-link SimpliSafe Account", + "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "title": "Fill in your information.", "data": { @@ -12,10 +23,13 @@ }, "error": { "identifier_exists": "Account already registered", - "invalid_credentials": "Invalid credentials" + "invalid_credentials": "Invalid credentials", + "still_awaiting_mfa": "Still awaiting MFA email click", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This SimpliSafe account is already in use." + "already_configured": "This SimpliSafe account is already in use.", + "reauth_successful": "SimpliSafe successfully reauthenticated." } }, "options": { @@ -28,4 +42,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index 90867a0163f..29ad4ee88ef 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -1,18 +1,32 @@ { "config": { "abort": { - "already_configured": "This SimpliSafe account is already in use." + "already_configured": "This SimpliSafe account is already in use.", + "reauth_successful": "SimpliSafe successfully reauthenticated." }, "error": { "identifier_exists": "Account already registered", - "invalid_credentials": "Invalid credentials" + "invalid_credentials": "Invalid credentials", + "still_awaiting_mfa": "Still awaiting MFA email click", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "mfa": { + "description": "Check your email for a link from SimpliSafe. After verifying the link, return here to complete the installation of the integration.", + "title": "SimpliSafe Multi-Factor Authentication" + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", + "title": "Re-link SimpliSafe Account" + }, "user": { "data": { "code": "Code (used in Home Assistant UI)", - "password": "Password", - "username": "Email" + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::email%]" }, "title": "Fill in your information." } diff --git a/requirements_all.txt b/requirements_all.txt index b999473018f..ff421988254 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1951,7 +1951,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==9.2.0 +simplisafe-python==9.2.1 # homeassistant.components.sisyphus sisyphus-control==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 199183d76ed..eadf04cad84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -866,7 +866,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==9.2.0 +simplisafe-python==9.2.1 # homeassistant.components.sleepiq sleepyq==0.7 diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index 2448b20b084..d5e22a48d8a 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -1,14 +1,16 @@ """Define tests for the SimpliSafe config flow.""" -import json - -from simplipy.errors import SimplipyError +from simplipy.errors import ( + InvalidCredentialsError, + PendingAuthorizationError, + SimplipyError, +) from homeassistant import data_entry_flow from homeassistant.components.simplisafe import DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from tests.async_mock import MagicMock, PropertyMock, mock_open, patch +from tests.async_mock import MagicMock, PropertyMock, patch from tests.common import MockConfigEntry @@ -21,11 +23,17 @@ def mock_api(): async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" - conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + conf = { + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + } - MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass( - hass - ) + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, + ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -39,8 +47,9 @@ async def test_invalid_credentials(hass): """Test that invalid credentials throws an error.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + print("AARON") with patch( - "simplipy.API.login_via_credentials", side_effect=SimplipyError, + "simplipy.API.login_via_credentials", side_effect=InvalidCredentialsError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -75,15 +84,12 @@ async def test_options_flow(hass): async def test_show_form(hass): """Test that the form is served with no input.""" - with patch( - "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT} - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" async def test_step_import(hass): @@ -94,17 +100,9 @@ async def test_step_import(hass): CONF_CODE: "1234", } - mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) - with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch("simplipy.API.login_via_credentials", return_value=mock_api()), patch( - "homeassistant.util.json.open", mop, create=True - ), patch( - "homeassistant.util.json.os.open", return_value=0 - ), patch( - "homeassistant.util.json.os.replace" - ): + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) @@ -118,25 +116,48 @@ async def test_step_import(hass): } +async def test_step_reauth(hass): + """Test that the reauth step works.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="user@email.com", + data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "reauth"}, + data={CONF_CODE: "1234", CONF_USERNAME: "user@email.com"}, + ) + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "password"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + + async def test_step_user(hass): - """Test that the user step works.""" + """Test that the user step works (without MFA).""" conf = { CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password", CONF_CODE: "1234", } - mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) - with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch("simplipy.API.login_via_credentials", return_value=mock_api()), patch( - "homeassistant.util.json.open", mop, create=True - ), patch( - "homeassistant.util.json.os.open", return_value=0 - ), patch( - "homeassistant.util.json.os.replace" - ): + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) @@ -148,3 +169,58 @@ async def test_step_user(hass): CONF_TOKEN: "12345abc", CONF_CODE: "1234", } + + +async def test_step_user_mfa(hass): + """Test that the user step works when MFA is in the middle.""" + conf = { + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + } + + with patch( + "simplipy.API.login_via_credentials", side_effect=PendingAuthorizationError + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["step_id"] == "mfa" + + with patch( + "simplipy.API.login_via_credentials", side_effect=PendingAuthorizationError + ): + # Simulate the user pressing the MFA submit button without having clicked + # the link in the MFA email: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["step_id"] == "mfa" + + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ), patch("simplipy.API.login_via_credentials", return_value=mock_api()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user@email.com" + assert result["data"] == { + CONF_USERNAME: "user@email.com", + CONF_TOKEN: "12345abc", + CONF_CODE: "1234", + } + + +async def test_unknown_error(hass): + """Test that an unknown error raises the correct error.""" + conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + + with patch( + "simplipy.API.login_via_credentials", side_effect=SimplipyError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {"base": "unknown"} From a7459b3126229b329006af8e3bacd7f134536dd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jul 2020 16:03:42 -1000 Subject: [PATCH 114/362] Log which task is blocking startup when debug logging is on (#38134) * Log which task is blocking startup when debug logging for homeassistant.core is on * test needs to go one level deeper now --- homeassistant/core.py | 17 ++++++++++++++++- tests/helpers/test_entity_platform.py | 4 ++-- tests/test_core.py | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 18493d85629..adeed758555 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -24,6 +24,7 @@ from typing import ( Callable, Coroutine, Dict, + Iterable, List, Mapping, Optional, @@ -99,6 +100,9 @@ CORE_STORAGE_VERSION = 1 DOMAIN = "homeassistant" +# How long to wait to log tasks that are blocking +BLOCK_LOG_TIMEOUT = 60 + # How long we wait for the result of a service call SERVICE_CALL_LIMIT = 10 # seconds @@ -394,10 +398,21 @@ class HomeAssistant: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() if pending: - await asyncio.wait(pending) + await self._await_and_log_pending(pending) else: await asyncio.sleep(0) + async def _await_and_log_pending(self, pending: Iterable[Awaitable[Any]]) -> None: + """Await and log tasks that take a long time.""" + wait_time = 0 + while pending: + _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) + if not pending: + return + wait_time += BLOCK_LOG_TIMEOUT + for task in pending: + _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index d5b817e0655..ddecd1988ed 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -188,8 +188,8 @@ async def test_platform_warn_slow_setup(hass): assert mock_call.called # mock_calls[0] is the warning message for component setup - # mock_calls[5] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[5][1][:2] + # mock_calls[6] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[6][1][:2] assert timeout == entity_platform.SLOW_SETUP_WARNING assert logger_method == _LOGGER.warning diff --git a/tests/test_core.py b/tests/test_core.py index 01a751b6570..c144af86804 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1404,3 +1404,24 @@ async def test_start_events(hass): EVENT_HOMEASSISTANT_STARTED, ] assert core_states == [ha.CoreState.starting, ha.CoreState.running] + + +async def test_log_blocking_events(hass, caplog): + """Ensure we log which task is blocking startup when debug logging is on.""" + caplog.set_level(logging.DEBUG) + + async def _wait_a_bit_1(): + await asyncio.sleep(0.1) + + async def _wait_a_bit_2(): + await asyncio.sleep(0.1) + + hass.async_create_task(_wait_a_bit_1()) + await hass.async_block_till_done() + + with patch.object(ha, "BLOCK_LOG_TIMEOUT", 0.00001): + hass.async_create_task(_wait_a_bit_2()) + await hass.async_block_till_done() + + assert "_wait_a_bit_2" in caplog.text + assert "_wait_a_bit_1" not in caplog.text From 2f87da8aa935ee085dc4b014778171a924316f12 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Fri, 24 Jul 2020 01:11:21 -0500 Subject: [PATCH 115/362] Fix script repeat variable lifetime (#38124) --- homeassistant/helpers/script.py | 44 +++++++----- tests/helpers/test_script.py | 116 ++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 18 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 475beb02690..bc6e4bdbd36 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -140,7 +140,7 @@ class _ScriptRun: ) -> None: self._hass = hass self._script = script - self._variables = variables + self._variables = variables or {} self._context = context self._log_exceptions = log_exceptions self._step = -1 @@ -431,22 +431,23 @@ class _ScriptRun: async def _async_repeat_step(self): """Repeat a sequence.""" - description = self._action.get(CONF_ALIAS, "sequence") repeat = self._action[CONF_REPEAT] - async def async_run_sequence(iteration, extra_msg="", extra_vars=None): + saved_repeat_vars = self._variables.get("repeat") + + def set_repeat_var(iteration, count=None): + repeat_vars = {"first": iteration == 1, "index": iteration} + if count: + repeat_vars["last"] = iteration == count + self._variables["repeat"] = repeat_vars + + # pylint: disable=protected-access + script = self._script._get_repeat_script(self._step) + + async def async_run_sequence(iteration, extra_msg=""): self._log("Repeating %s: Iteration %i%s", description, iteration, extra_msg) - repeat_vars = {"repeat": {"first": iteration == 1, "index": iteration}} - if extra_vars: - repeat_vars["repeat"].update(extra_vars) - # pylint: disable=protected-access - await self._async_run_script( - self._script._get_repeat_script(self._step), - # Add repeat to variables. Override if it already exists in case of - # nested calls. - {**(self._variables or {}), **repeat_vars}, - ) + await self._async_run_script(script) if CONF_COUNT in repeat: count = repeat[CONF_COUNT] @@ -461,10 +462,10 @@ class _ScriptRun: level=logging.ERROR, ) raise _StopScript + extra_msg = f" of {count}" for iteration in range(1, count + 1): - await async_run_sequence( - iteration, f" of {count}", {"last": iteration == count} - ) + set_repeat_var(iteration, count) + await async_run_sequence(iteration, extra_msg) if self._stop.is_set(): break @@ -473,6 +474,7 @@ class _ScriptRun: await self._async_get_condition(config) for config in repeat[CONF_WHILE] ] for iteration in itertools.count(1): + set_repeat_var(iteration) if self._stop.is_set() or not all( cond(self._hass, self._variables) for cond in conditions ): @@ -484,12 +486,18 @@ class _ScriptRun: await self._async_get_condition(config) for config in repeat[CONF_UNTIL] ] for iteration in itertools.count(1): + set_repeat_var(iteration) await async_run_sequence(iteration) if self._stop.is_set() or all( cond(self._hass, self._variables) for cond in conditions ): break + if saved_repeat_vars: + self._variables["repeat"] = saved_repeat_vars + else: + del self._variables["repeat"] + async def _async_choose_step(self): """Choose a sequence.""" # pylint: disable=protected-access @@ -503,11 +511,11 @@ class _ScriptRun: if choose_data["default"]: await self._async_run_script(choose_data["default"]) - async def _async_run_script(self, script, variables=None): + async def _async_run_script(self, script): """Execute a script.""" await self._async_run_long_action( self._hass.async_create_task( - script.async_run(variables or self._variables, self._context) + script.async_run(self._variables, self._context) ) ) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 4001b6a3215..ab30b0457c5 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -854,6 +854,122 @@ async def test_repeat_conditional(hass, condition): assert event.data.get("index") == str(index + 1) +@pytest.mark.parametrize("condition", ["while", "until"]) +async def test_repeat_var_in_condition(hass, condition): + """Test repeat action w/ while option.""" + event = "test_event" + events = async_capture_events(hass, event) + + sequence = {"repeat": {"sequence": {"event": event}}} + if condition == "while": + sequence["repeat"]["while"] = { + "condition": "template", + "value_template": "{{ repeat.index <= 2 }}", + } + else: + sequence["repeat"]["until"] = { + "condition": "template", + "value_template": "{{ repeat.index == 2 }}", + } + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA(sequence)) + + with mock.patch( + "homeassistant.helpers.condition._LOGGER.error", + side_effect=AssertionError("Template Error"), + ): + await script_obj.async_run() + + assert len(events) == 2 + + +async def test_repeat_nested(hass): + """Test nested repeats.""" + event = "test_event" + events = async_capture_events(hass, event) + + sequence = cv.SCRIPT_SCHEMA( + [ + { + "event": event, + "event_data_template": { + "repeat": "{{ None if repeat is not defined else repeat }}" + }, + }, + { + "repeat": { + "count": 2, + "sequence": [ + { + "event": event, + "event_data_template": { + "first": "{{ repeat.first }}", + "index": "{{ repeat.index }}", + "last": "{{ repeat.last }}", + }, + }, + { + "repeat": { + "count": 2, + "sequence": { + "event": event, + "event_data_template": { + "first": "{{ repeat.first }}", + "index": "{{ repeat.index }}", + "last": "{{ repeat.last }}", + }, + }, + } + }, + { + "event": event, + "event_data_template": { + "first": "{{ repeat.first }}", + "index": "{{ repeat.index }}", + "last": "{{ repeat.last }}", + }, + }, + ], + } + }, + { + "event": event, + "event_data_template": { + "repeat": "{{ None if repeat is not defined else repeat }}" + }, + }, + ] + ) + script_obj = script.Script(hass, sequence, "test script") + + with mock.patch( + "homeassistant.helpers.condition._LOGGER.error", + side_effect=AssertionError("Template Error"), + ): + await script_obj.async_run() + + assert len(events) == 10 + assert events[0].data == {"repeat": "None"} + assert events[-1].data == {"repeat": "None"} + for index, result in enumerate( + ( + ("True", "1", "False"), + ("True", "1", "False"), + ("False", "2", "True"), + ("True", "1", "False"), + ("False", "2", "True"), + ("True", "1", "False"), + ("False", "2", "True"), + ("False", "2", "True"), + ), + 1, + ): + assert events[index].data == { + "first": result[0], + "index": result[1], + "last": result[2], + } + + @pytest.mark.parametrize("var,result", [(1, "first"), (2, "second"), (3, "default")]) async def test_choose(hass, var, result): """Test choose action.""" From 0b9663a23b763b2a44eb576f53ae990c48166314 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Jul 2020 11:00:17 +0200 Subject: [PATCH 116/362] Fix incorrect mesurement in Toon for meter low (#38149) --- homeassistant/components/toon/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index d7f403f7013..5015d50fa63 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -224,7 +224,7 @@ SENSOR_ENTITIES = { "power_meter_reading_low": { ATTR_NAME: "Electricity Meter Feed IN Tariff 2", ATTR_SECTION: "power_usage", - ATTR_MEASUREMENT: "meter_high", + ATTR_MEASUREMENT: "meter_low", ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:power-plug", From c76b11f9d7237e9e8e21b25c7abf5277e68d5c53 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Fri, 24 Jul 2020 11:49:48 +0200 Subject: [PATCH 117/362] Write device_id to ConfigEntry of rfxtrx integration (#38064) * Write device_id to ConfigEntry * Rework based on review comment * Add hass add job * Cleanup --- homeassistant/components/rfxtrx/__init__.py | 44 +++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index edae34d7e37..690ac67b7f6 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_DEVICE, CONF_DEVICE_CLASS, + CONF_DEVICE_ID, CONF_DEVICES, CONF_HOST, CONF_PORT, @@ -151,6 +152,14 @@ async def async_setup(hass, config): CONF_DEVICES: config[DOMAIN][CONF_DEVICES], } + # Read device_id from the event code add to the data that will end up in the ConfigEntry + for event_code, event_config in data[CONF_DEVICES].items(): + event = get_rfx_object(event_code) + device_id = get_device_id( + event.device, data_bits=event_config.get(CONF_DATA_BITS) + ) + event_config[CONF_DEVICE_ID] = device_id + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data, @@ -163,7 +172,7 @@ async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" hass.data.setdefault(DOMAIN, {}) - await hass.async_add_executor_job(setup_internal, hass, entry.data) + await hass.async_add_executor_job(setup_internal, hass, entry) for domain in DOMAINS: hass.async_create_task( @@ -203,19 +212,18 @@ def unload_internal(hass, config): rfx_object.close_connection() -def setup_internal(hass, config): +def setup_internal(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" + config = entry.data + # Setup some per device config - device_events = set() - device_bits = {} + devices = dict() for event_code, event_config in config[CONF_DEVICES].items(): event = get_rfx_object(event_code) device_id = get_device_id( event.device, data_bits=event_config.get(CONF_DATA_BITS) ) - device_bits[device_id] = event_config.get(CONF_DATA_BITS) - if event_config[CONF_FIRE_EVENT]: - device_events.add(device_id) + devices[device_id] = event_config # Declare the Handle event def handle_receive(event): @@ -235,16 +243,29 @@ def setup_internal(hass, config): _LOGGER.debug("Receive RFXCOM event: %s", event_data) - data_bits = get_device_data_bits(event.device, device_bits) + data_bits = get_device_data_bits(event.device, devices) device_id = get_device_id(event.device, data_bits=data_bits) # Callback to HA registered components. hass.helpers.dispatcher.dispatcher_send(SIGNAL_EVENT, event, device_id) # Signal event to any other listeners - if device_id in device_events: + fire_event = devices.get(device_id, {}).get(CONF_FIRE_EVENT) + if fire_event: hass.bus.fire(EVENT_RFXTRX_EVENT, event_data) + @callback + def device_update(event, device_id): + if device_id not in devices: + data = entry.data.copy() + event_code = binascii.hexlify(event.data).decode("ASCII") + data[CONF_DEVICES][event_code] = device_id + hass.config_entries.async_update_entry(entry=entry, data=data) + devices[device_id] = {} + + if config[CONF_AUTOMATIC_ADD]: + hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, device_update) + device = config[CONF_DEVICE] host = config[CONF_HOST] port = config[CONF_PORT] @@ -331,11 +352,12 @@ def get_pt2262_cmd(device_id, data_bits): return hex(data[-1] & mask) -def get_device_data_bits(device, device_bits): +def get_device_data_bits(device, devices): """Deduce data bits for device based on a cache of device bits.""" data_bits = None if device.packettype == DEVICE_PACKET_TYPE_LIGHTING4: - for device_id, bits in device_bits.items(): + for device_id, entity_config in devices.items(): + bits = entity_config.get(CONF_DATA_BITS) if get_device_id(device, bits) == device_id: data_bits = bits break From 686e6b8fc383a3c15a1954092a869bc0e65a28c6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 24 Jul 2020 12:29:19 +0200 Subject: [PATCH 118/362] Add test (#37890) --- tests/components/mqtt/test_config_flow.py | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 581395b702a..5fbb772f949 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -185,10 +185,12 @@ async def test_option_flow(hass, mqtt_mock, mock_try_connection): result["flow_id"], user_input={ mqtt.CONF_DISCOVERY: True, + "birth_enable": True, "birth_topic": "ha_state/online", "birth_payload": "online", "birth_qos": 1, "birth_retain": True, + "will_enable": True, "will_topic": "ha_state/offline", "will_payload": "offline", "will_qos": 2, @@ -221,6 +223,69 @@ async def test_option_flow(hass, mqtt_mock, mock_try_connection): assert mqtt_mock.async_connect.call_count == 1 +async def test_disable_birth_will(hass, mqtt_mock, mock_try_connection): + """Test disabling birth and will.""" + mock_try_connection.return_value = True + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + config_entry.data = { + mqtt.CONF_BROKER: "test-broker", + mqtt.CONF_PORT: 1234, + } + + mqtt_mock.async_connect.reset_mock() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "broker" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "options" + + await hass.async_block_till_done() + assert mqtt_mock.async_connect.call_count == 0 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + mqtt.CONF_DISCOVERY: True, + "birth_enable": False, + "birth_topic": "ha_state/online", + "birth_payload": "online", + "birth_qos": 1, + "birth_retain": True, + "will_enable": False, + "will_topic": "ha_state/offline", + "will_payload": "offline", + "will_qos": 2, + "will_retain": True, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] is None + assert config_entry.data == { + mqtt.CONF_BROKER: "another-broker", + mqtt.CONF_PORT: 2345, + mqtt.CONF_USERNAME: "user", + mqtt.CONF_PASSWORD: "pass", + mqtt.CONF_DISCOVERY: True, + mqtt.CONF_BIRTH_MESSAGE: {}, + mqtt.CONF_WILL_MESSAGE: {}, + } + + await hass.async_block_till_done() + assert mqtt_mock.async_connect.call_count == 1 + + def get_default(schema, key): """Get default value for key in voluptuous schema.""" for k in schema.keys(): From 5d28e109e88475d6632475805c6f251029977449 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 24 Jul 2020 14:40:10 +0200 Subject: [PATCH 119/362] Asyncify rfxtrx startup and event handling (#38155) * Asyncify startup and event handling * Adjust linting error * Must use the thread safe add_job function * Switch to correct async function --- homeassistant/components/rfxtrx/__init__.py | 119 +++++++++++--------- 1 file changed, 63 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 690ac67b7f6..3bb8a753aa9 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -172,7 +172,7 @@ async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up the RFXtrx component.""" hass.data.setdefault(DOMAIN, {}) - await hass.async_add_executor_job(setup_internal, hass, entry) + await async_setup_internal(hass, entry) for domain in DOMAINS: hass.async_create_task( @@ -184,49 +184,68 @@ async def async_setup_entry(hass, entry: config_entries.ConfigEntry): async def async_unload_entry(hass, entry: config_entries.ConfigEntry): """Unload RFXtrx component.""" - unload_ok = all( + if not all( await asyncio.gather( *[ hass.config_entries.async_forward_entry_unload(entry, component) for component in DOMAINS ] ) - ) + ): + return False - if unload_ok: - await hass.async_add_executor_job(unload_internal, hass, entry.data) - - hass.data.pop(DOMAIN) - - return unload_ok - - -def unload_internal(hass, config): - """Unload the RFXtrx component.""" - hass.services.remove(DOMAIN, SERVICE_SEND) + hass.services.async_remove(DOMAIN, SERVICE_SEND) listener = hass.data[DOMAIN][DATA_LISTENER] listener() rfx_object = hass.data[DOMAIN][DATA_RFXOBJECT] - rfx_object.close_connection() + await hass.async_add_executor_job(rfx_object.close_connection) + + return True -def setup_internal(hass, entry: config_entries.ConfigEntry): - """Set up the RFXtrx component.""" - config = entry.data +def _create_rfx(config): + """Construct a rfx object based on config.""" + if config[CONF_PORT] is not None: + # If port is set then we create a TCP connection + rfx = rfxtrxmod.Connect( + (config[CONF_HOST], config[CONF_PORT]), + None, + debug=config[CONF_DEBUG], + transport_protocol=rfxtrxmod.PyNetworkTransport, + ) + else: + rfx = rfxtrxmod.Connect(config[CONF_DEVICE], None, debug=config[CONF_DEBUG]) - # Setup some per device config - devices = dict() - for event_code, event_config in config[CONF_DEVICES].items(): + return rfx + + +def _get_device_lookup(devices): + """Get a lookup structure for devices.""" + lookup = dict() + for event_code, event_config in devices.items(): event = get_rfx_object(event_code) device_id = get_device_id( event.device, data_bits=event_config.get(CONF_DATA_BITS) ) - devices[device_id] = event_config + lookup[device_id] = event_config + return lookup + + +async def async_setup_internal(hass, entry: config_entries.ConfigEntry): + """Set up the RFXtrx component.""" + config = entry.data + + # Initialize library + rfx_object = await hass.async_add_executor_job(_create_rfx, config) + + # Setup some per device config + devices = _get_device_lookup(config[CONF_DEVICES]) # Declare the Handle event - def handle_receive(event): + @callback + def async_handle_receive(event): """Handle received messages from RFXtrx gateway.""" # Log RFXCOM event if not event.device.id_string: @@ -246,52 +265,40 @@ def setup_internal(hass, entry: config_entries.ConfigEntry): data_bits = get_device_data_bits(event.device, devices) device_id = get_device_id(event.device, data_bits=data_bits) + # Register new devices + if config[CONF_AUTOMATIC_ADD] and device_id not in devices: + _add_device(event, device_id) + # Callback to HA registered components. - hass.helpers.dispatcher.dispatcher_send(SIGNAL_EVENT, event, device_id) + hass.helpers.dispatcher.async_dispatcher_send(SIGNAL_EVENT, event, device_id) # Signal event to any other listeners fire_event = devices.get(device_id, {}).get(CONF_FIRE_EVENT) if fire_event: - hass.bus.fire(EVENT_RFXTRX_EVENT, event_data) + hass.bus.async_fire(EVENT_RFXTRX_EVENT, event_data) @callback - def device_update(event, device_id): - if device_id not in devices: - data = entry.data.copy() - event_code = binascii.hexlify(event.data).decode("ASCII") - data[CONF_DEVICES][event_code] = device_id - hass.config_entries.async_update_entry(entry=entry, data=data) - devices[device_id] = {} - - if config[CONF_AUTOMATIC_ADD]: - hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_EVENT, device_update) - - device = config[CONF_DEVICE] - host = config[CONF_HOST] - port = config[CONF_PORT] - debug = config[CONF_DEBUG] - - if port is not None: - # If port is set then we create a TCP connection - rfx_object = rfxtrxmod.Connect( - (host, port), - None, - debug=debug, - transport_protocol=rfxtrxmod.PyNetworkTransport, - ) - else: - rfx_object = rfxtrxmod.Connect(device, None, debug=debug) + def _add_device(event, device_id): + """Add a device to config entry.""" + data = entry.data.copy() + event_code = binascii.hexlify(event.data).decode("ASCII") + data[CONF_DEVICES][event_code] = device_id + hass.config_entries.async_update_entry(entry=entry, data=data) + devices[device_id] = {} + @callback def _start_rfxtrx(event): - rfx_object.event_callback = handle_receive - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) + """Start receiving events.""" + rfx_object.event_callback = lambda event: hass.add_job( + async_handle_receive, event + ) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" rfx_object.close_connection() - listener = hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) + listener = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) hass.data[DOMAIN][DATA_LISTENER] = listener hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object @@ -300,7 +307,7 @@ def setup_internal(hass, entry: config_entries.ConfigEntry): event = call.data[ATTR_EVENT] rfx_object.transport.send(event) - hass.services.register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) + hass.services.async_register(DOMAIN, SERVICE_SEND, send, schema=SERVICE_SEND_SCHEMA) def get_rfx_object(packetid): From 3ff5c170096d7f257c36b82d67d79a55f5b12419 Mon Sep 17 00:00:00 2001 From: Thomas Delaet Date: Fri, 24 Jul 2020 15:48:07 +0200 Subject: [PATCH 120/362] Support unavailable state in template fan (#38114) Co-authored-by: Martin Hjelmare --- homeassistant/components/template/fan.py | 9 ++-- tests/components/template/test_fan.py | 58 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 037cc6c40e1..1f5c433bf89 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -26,6 +26,7 @@ from homeassistant.const import ( MATCH_ALL, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import callback @@ -344,7 +345,7 @@ class TemplateFan(FanEntity): # Validate state if state in _VALID_STATES: self._state = state - elif state == STATE_UNKNOWN: + elif state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._state = None else: _LOGGER.error( @@ -366,7 +367,7 @@ class TemplateFan(FanEntity): # Validate speed if speed in self._speed_list: self._speed = speed - elif speed == STATE_UNKNOWN: + elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._speed = None else: _LOGGER.error( @@ -388,7 +389,7 @@ class TemplateFan(FanEntity): self._oscillating = True elif oscillating == "False" or oscillating is False: self._oscillating = False - elif oscillating == STATE_UNKNOWN: + elif oscillating in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._oscillating = None else: _LOGGER.error( @@ -409,7 +410,7 @@ class TemplateFan(FanEntity): # Validate speed if direction in _VALID_DIRECTIONS: self._direction = direction - elif direction == STATE_UNKNOWN: + elif direction in [STATE_UNAVAILABLE, STATE_UNKNOWN]: self._direction = None else: _LOGGER.error( diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 2e44ec6f0ca..58fa80c10d5 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -222,6 +222,64 @@ async def test_templates_with_entities(hass, calls): _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) +async def test_template_with_unavailable_entities(hass, calls): + """Test unavailability with value_template.""" + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'unavailable' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + assert hass.states.get(_TEST_FAN).state == STATE_OFF + + +async def test_template_with_unavailable_parameters(hass, calls): + """Test unavailability of speed, direction and oscillating parameters.""" + + with assert_setup_component(1, "fan"): + assert await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_fan": { + "value_template": "{{ 'on' }}", + "speed_template": "{{ 'unavailable' }}", + "oscillating_template": "{{ 'unavailable' }}", + "direction_template": "{{ 'unavailable' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify(hass, STATE_ON, None, None, None) + + async def test_availability_template_with_entities(hass, calls): """Test availability tempalates with values from other entities.""" From 5f6bd22f18b7369c3fd108ed9dc8a8e7f799ba09 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 24 Jul 2020 08:11:02 -0600 Subject: [PATCH 121/362] Remove leftover print statement (#38163) --- tests/components/simplisafe/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index d5e22a48d8a..d94c431aa14 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -47,7 +47,6 @@ async def test_invalid_credentials(hass): """Test that invalid credentials throws an error.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} - print("AARON") with patch( "simplipy.API.login_via_credentials", side_effect=InvalidCredentialsError, ): From 19e06c613b49da1bd737ddbd60e0d0e45d0af2c7 Mon Sep 17 00:00:00 2001 From: Tomasz Date: Fri, 24 Jul 2020 16:24:19 +0200 Subject: [PATCH 122/362] convert_until isn't returning anything (#38157) --- homeassistant/components/evohome/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index e436268db63..9af53981957 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -107,7 +107,7 @@ def _dt_aware_to_naive(dt_aware: dt) -> dt: return dt_naive.replace(microsecond=0) -def convert_until(status_dict: dict, until_key: str) -> str: +def convert_until(status_dict: dict, until_key: str) -> None: """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" if until_key in status_dict: # only present for certain modes dt_utc_naive = dt_util.parse_datetime(status_dict[until_key]) From 027ece52e60d7439a9f5ce561773782ba2e8129f Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Fri, 24 Jul 2020 16:42:42 +0200 Subject: [PATCH 123/362] Fix Nuki Locks and Openers not being available after some time (#38159) --- CODEOWNERS | 2 +- homeassistant/components/nuki/manifest.json | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 58e31bd3858..025f6d7930c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -280,7 +280,7 @@ homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte homeassistant/components/nuheat/* @bdraco -homeassistant/components/nuki/* @pvizeli +homeassistant/components/nuki/* @pschmitt @pvizeli homeassistant/components/numato/* @clssn homeassistant/components/nut/* @bdraco homeassistant/components/nws/* @MatthewFlamm diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 386b36a3ca9..09cf112d41c 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -2,6 +2,6 @@ "domain": "nuki", "name": "Nuki", "documentation": "https://www.home-assistant.io/integrations/nuki", - "requirements": ["pynuki==1.3.7"], - "codeowners": ["@pvizeli"] + "requirements": ["pynuki==1.3.8"], + "codeowners": ["@pschmitt", "@pvizeli"] } diff --git a/requirements_all.txt b/requirements_all.txt index ff421988254..f74cae919dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1497,7 +1497,7 @@ pynetgear==0.6.1 pynetio==0.1.9.1 # homeassistant.components.nuki -pynuki==1.3.7 +pynuki==1.3.8 # homeassistant.components.nut pynut2==2.1.2 From 3149cf68490beaa73188493af0a667c0010fe00a Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Fri, 24 Jul 2020 10:55:54 -0400 Subject: [PATCH 124/362] Bump python-slugify to 4.0.1 (#38140) * Bump python-slugify to 4.0.1 * Dummy commit to re-trigger tests --- homeassistant/package_constraints.txt | 2 +- requirements.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 385877e2822..595af35f9da 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ jinja2>=2.11.1 netdisco==2.8.0 paho-mqtt==1.5.0 pip>=8.0.3 -python-slugify==4.0.0 +python-slugify==4.0.1 pytz>=2020.1 pyyaml==5.3.1 requests==2.24.0 diff --git a/requirements.txt b/requirements.txt index e9f5cd64f19..38d077e3da1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,7 @@ jinja2>=2.11.1 PyJWT==1.7.1 cryptography==2.9.2 pip>=8.0.3 -python-slugify==4.0.0 +python-slugify==4.0.1 pytz>=2020.1 pyyaml==5.3.1 requests==2.24.0 diff --git a/setup.py b/setup.py index 2e000d3192b..ae762d2d8ce 100755 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ REQUIRES = [ # PyJWT has loose dependency. We want the latest one. "cryptography==2.9.2", "pip>=8.0.3", - "python-slugify==4.0.0", + "python-slugify==4.0.1", "pytz>=2020.1", "pyyaml==5.3.1", "requests==2.24.0", From 84df0efb5ea06a3778cb2c5592c3904be6a31868 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Jul 2020 17:03:10 +0200 Subject: [PATCH 125/362] Upgrade coverage to 5.2.1 (#38158) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 44977fd8904..25e3db656da 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -6,7 +6,7 @@ -r requirements_test_pre_commit.txt asynctest==0.13.0 codecov==2.1.0 -coverage==5.2 +coverage==5.2.1 mock-open==1.4.0 mypy==0.780 pre-commit==2.6.0 From 632a36d819a4b5c125711ef5f4a8c68e394b996c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 24 Jul 2020 17:16:41 +0200 Subject: [PATCH 126/362] Support rfxtrx smoke detectors, motion sensors as binary_sensors (#38000) * Add binary sensor support to motion sensors and smoke detectors * Add support for new sensor events as binary sensors Adds a default device_class for motion sensors and smoke detector * Use device type instead of event to set class * Add some additional binary values --- .../components/rfxtrx/binary_sensor.py | 68 +++++++++++++-- tests/components/rfxtrx/conftest.py | 37 +++++++- tests/components/rfxtrx/test_binary_sensor.py | 85 ++++++++++++------- tests/components/rfxtrx/test_cover.py | 8 +- tests/components/rfxtrx/test_light.py | 8 +- tests/components/rfxtrx/test_sensor.py | 8 +- tests/components/rfxtrx/test_switch.py | 8 +- 7 files changed, 160 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 627e5a3dd5e..b8976454d68 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -3,7 +3,11 @@ import logging import RFXtrx as rfxtrxmod -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, @@ -34,6 +38,33 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +SENSOR_STATUS_ON = [ + "Panic", + "Motion", + "Motion Tamper", + "Light Detected", + "Alarm", + "Alarm Tamper", +] + +SENSOR_STATUS_OFF = [ + "End Panic", + "No Motion", + "No Motion Tamper", + "Dark Detected", + "Normal", + "Normal Tamper", +] + +DEVICE_TYPE_DEVICE_CLASS = { + "X10 Security Motion Detector": DEVICE_CLASS_MOTION, + "KD101 Smoke Detector": DEVICE_CLASS_SMOKE, + "Visonic Powercode Motion Detector": DEVICE_CLASS_MOTION, + "Alecto SA30 Smoke Detector": DEVICE_CLASS_SMOKE, + "RM174RF Smoke Detector": DEVICE_CLASS_SMOKE, +} + + async def async_setup_entry( hass, config_entry, async_add_entities, ): @@ -46,7 +77,14 @@ async def async_setup_entry( discovery_info = config_entry.data def supported(event): - return isinstance(event, rfxtrxmod.ControlEvent) + if isinstance(event, rfxtrxmod.ControlEvent): + return True + if isinstance(event, rfxtrxmod.SensorEvent): + return event.values.get("Sensor Status") in [ + *SENSOR_STATUS_ON, + *SENSOR_STATUS_OFF, + ] + return False for packet_id, entity_info in discovery_info[CONF_DEVICES].items(): event = get_rfx_object(packet_id) @@ -70,7 +108,10 @@ async def async_setup_entry( device = RfxtrxBinarySensor( event.device, device_id, - entity_info.get(CONF_DEVICE_CLASS), + entity_info.get( + CONF_DEVICE_CLASS, + DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + ), entity_info.get(CONF_OFF_DELAY), entity_info.get(CONF_DATA_BITS), entity_info.get(CONF_COMMAND_ON), @@ -97,7 +138,12 @@ async def async_setup_entry( event.device.subtype, "".join(f"{x:02x}" for x in event.data), ) - sensor = RfxtrxBinarySensor(event.device, device_id, event=event) + sensor = RfxtrxBinarySensor( + event.device, + device_id, + event=event, + device_class=DEVICE_TYPE_DEVICE_CLASS.get(event.device.type_string), + ) async_add_entities([sensor]) # Subscribe to main RFXtrx events @@ -170,9 +216,13 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self._state = True def _apply_event_standard(self, event): - if event.values["Command"] in COMMAND_ON_LIST: + if event.values.get("Command") in COMMAND_ON_LIST: self._state = True - elif event.values["Command"] in COMMAND_OFF_LIST: + elif event.values.get("Command") in COMMAND_OFF_LIST: + self._state = False + elif event.values.get("Sensor Status") in SENSOR_STATUS_ON: + self._state = True + elif event.values.get("Sensor Status") in SENSOR_STATUS_OFF: self._state = False def _apply_event(self, event): @@ -200,7 +250,11 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): self.async_write_ha_state() - if self.is_on and self._off_delay is not None and self._delay_listener is None: + if self._delay_listener: + self._delay_listener() + self._delay_listener = None + + if self.is_on and self._off_delay is not None: @callback def off_delay_listener(now): diff --git a/tests/components/rfxtrx/conftest.py b/tests/components/rfxtrx/conftest.py index 9ce7ec07f4b..94d440312b4 100644 --- a/tests/components/rfxtrx/conftest.py +++ b/tests/components/rfxtrx/conftest.py @@ -1,16 +1,21 @@ """Common test tools.""" -from unittest import mock +from datetime import timedelta import pytest from homeassistant.components import rfxtrx +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed @pytest.fixture(autouse=True, name="rfxtrx") async def rfxtrx_fixture(hass): """Fixture that cleans up threads from integration.""" - with mock.patch("RFXtrx.Connect") as connect, mock.patch("RFXtrx.DummyTransport2"): + with patch("RFXtrx.Connect") as connect, patch("RFXtrx.DummyTransport2"): rfx = connect.return_value async def _signal_event(packet_id): @@ -26,3 +31,31 @@ async def rfxtrx_fixture(hass): rfx.signal = _signal_event yield rfx + + +@pytest.fixture(name="rfxtrx_automatic") +async def rfxtrx_automatic_fixture(hass, rfxtrx): + """Fixture that starts up with automatic additions.""" + + assert await async_setup_component( + hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, + ) + await hass.async_block_till_done() + await hass.async_start() + yield rfxtrx + + +@pytest.fixture +async def timestep(hass): + """Step system time forward.""" + + with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: + mock_utcnow.return_value = utcnow() + + async def delay(seconds): + """Trigger delay in system.""" + mock_utcnow.return_value += timedelta(seconds=seconds) + async_fire_time_changed(hass, mock_utcnow.return_value) + await hass.async_block_till_done() + + yield delay diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 50f9ccf0eca..8fbc06f6ddb 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -1,14 +1,20 @@ """The tests for the Rfxtrx sensor platform.""" -from datetime import timedelta - import pytest from homeassistant.components.rfxtrx.const import ATTR_EVENT from homeassistant.core import State from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed, mock_restore_cache +from tests.common import mock_restore_cache + +EVENT_SMOKE_DETECTOR_PANIC = "08200300a109000670" +EVENT_SMOKE_DETECTOR_NO_PANIC = "08200300a109000770" + +EVENT_MOTION_DETECTOR_MOTION = "08200100a109000470" +EVENT_MOTION_DETECTOR_NO_MOTION = "08200100a109000570" + +EVENT_LIGHT_DETECTOR_LIGHT = "08200100a109001570" +EVENT_LIGHT_DETECTOR_DARK = "08200100a109001470" async def test_one(hass, rfxtrx): @@ -116,25 +122,9 @@ async def test_several(hass, rfxtrx): assert state.attributes.get("friendly_name") == "AC 1118cdea:2" -async def test_discover(hass, rfxtrx): +async def test_discover(hass, rfxtrx_automatic): """Test with discovery.""" - assert await async_setup_component( - hass, - "rfxtrx", - { - "rfxtrx": { - "device": "abcd", - "automatic_add": True, - "devices": { - "0b1100cd0213c7f230010f71": {}, - "0b1100100118cdea02010f70": {}, - "0b1100101118cdea02010f70": {}, - }, - } - }, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("binary_sensor.ac_118cdea_2") @@ -147,7 +137,7 @@ async def test_discover(hass, rfxtrx): assert state.state == "on" -async def test_off_delay(hass, rfxtrx): +async def test_off_delay(hass, rfxtrx, timestep): """Test with discovery.""" assert await async_setup_component( hass, @@ -171,16 +161,53 @@ async def test_off_delay(hass, rfxtrx): assert state assert state.state == "on" - base_time = utcnow() - - async_fire_time_changed(hass, base_time + timedelta(seconds=4)) - await hass.async_block_till_done() + await timestep(4) state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "on" - async_fire_time_changed(hass, base_time + timedelta(seconds=6)) - await hass.async_block_till_done() + await timestep(4) state = hass.states.get("binary_sensor.ac_118cdea_2") assert state assert state.state == "off" + + +async def test_panic(hass, rfxtrx_automatic): + """Test panic entities.""" + rfxtrx = rfxtrx_automatic + + entity_id = "binary_sensor.kd101_smoke_detector_a10900_32" + + await rfxtrx.signal(EVENT_SMOKE_DETECTOR_PANIC) + assert hass.states.get(entity_id).state == "on" + assert hass.states.get(entity_id).attributes.get("device_class") == "smoke" + + await rfxtrx.signal(EVENT_SMOKE_DETECTOR_NO_PANIC) + assert hass.states.get(entity_id).state == "off" + + +async def test_motion(hass, rfxtrx_automatic): + """Test motion entities.""" + rfxtrx = rfxtrx_automatic + + entity_id = "binary_sensor.x10_security_motion_detector_a10900_32" + + await rfxtrx.signal(EVENT_MOTION_DETECTOR_MOTION) + assert hass.states.get(entity_id).state == "on" + assert hass.states.get(entity_id).attributes.get("device_class") == "motion" + + await rfxtrx.signal(EVENT_MOTION_DETECTOR_NO_MOTION) + assert hass.states.get(entity_id).state == "off" + + +async def test_light(hass, rfxtrx_automatic): + """Test light entities.""" + rfxtrx = rfxtrx_automatic + + entity_id = "binary_sensor.x10_security_motion_detector_a10900_32" + + await rfxtrx.signal(EVENT_LIGHT_DETECTOR_LIGHT) + assert hass.states.get(entity_id).state == "on" + + await rfxtrx.signal(EVENT_LIGHT_DETECTOR_DARK) + assert hass.states.get(entity_id).state == "off" diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index ffbef93daec..ce4838cebf1 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -101,13 +101,9 @@ async def test_several_covers(hass, rfxtrx): assert state.attributes.get("friendly_name") == "RollerTrol 009ba8:1" -async def test_discover_covers(hass, rfxtrx): +async def test_discover_covers(hass, rfxtrx_automatic): """Test with discovery of covers.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}} - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic await rfxtrx.signal("0a140002f38cae010f0070") state = hass.states.get("cover.lightwaverf_siemens_f38cae_1") diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py index ead6d638841..3ebd1bdef39 100644 --- a/tests/components/rfxtrx/test_light.py +++ b/tests/components/rfxtrx/test_light.py @@ -165,13 +165,9 @@ async def test_repetitions(hass, rfxtrx, repetitions): assert rfxtrx.transport.send.call_count == repetitions -async def test_discover_light(hass, rfxtrx): +async def test_discover_light(hass, rfxtrx_automatic): """Test with discovery of lights.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic await rfxtrx.signal("0b11009e00e6116202020070") state = hass.states.get("light.ac_0e61162_2") diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 1d7b2de9a7a..d87cf1a71e2 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -148,13 +148,9 @@ async def test_several_sensors(hass, rfxtrx): assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE -async def test_discover_sensor(hass, rfxtrx): +async def test_discover_sensor(hass, rfxtrx_automatic): """Test with discovery of sensor.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic # 1 await rfxtrx.signal("0a520801070100b81b0279") diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index e4f7763c299..c163401142e 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -120,13 +120,9 @@ async def test_repetitions(hass, rfxtrx, repetitions): assert rfxtrx.transport.send.call_count == repetitions -async def test_discover_switch(hass, rfxtrx): +async def test_discover_switch(hass, rfxtrx_automatic): """Test with discovery of switches.""" - assert await async_setup_component( - hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}}, - ) - await hass.async_block_till_done() - await hass.async_start() + rfxtrx = rfxtrx_automatic await rfxtrx.signal("0b1100100118cdea02010f70") state = hass.states.get("switch.ac_118cdea_2") From 21db4a4160157268bdd2c3d57e0dfc4d8a5fb433 Mon Sep 17 00:00:00 2001 From: Heiko Rothe Date: Fri, 24 Jul 2020 21:45:59 +0200 Subject: [PATCH 127/362] Fix Xbox Live integration (#38146) * Fix Xbox Live component The API moved to a different domain, so the integration was broken. The library upgrade contains the required fixes. * Fix API connectivity check * Access dict values directly --- .../components/xbox_live/manifest.json | 2 +- homeassistant/components/xbox_live/sensor.py | 38 +++++++++---------- requirements_all.txt | 2 +- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json index f00f49c1589..3ebffc425ad 100644 --- a/homeassistant/components/xbox_live/manifest.json +++ b/homeassistant/components/xbox_live/manifest.json @@ -2,6 +2,6 @@ "domain": "xbox_live", "name": "Xbox Live", "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "requirements": ["xboxapi==0.1.1"], + "requirements": ["xboxapi==2.0.0"], "codeowners": ["@MartinHjelmare"] } diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py index ed5abe74bb6..1f46267967a 100644 --- a/homeassistant/components/xbox_live/sensor.py +++ b/homeassistant/components/xbox_live/sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging import voluptuous as vol -from xboxapi import xbox_api +from xboxapi import Client from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_API_KEY, CONF_SCAN_INTERVAL @@ -28,17 +28,17 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Xbox platform.""" - api = xbox_api.XboxApi(config[CONF_API_KEY]) + api = Client(api_key=config[CONF_API_KEY]) entities = [] - # request personal profile to check api connection - profile = api.get_profile() - if profile.get("error_code") is not None: + # request profile info to check api connection + response = api.api_get("profile") + if not response.ok: _LOGGER.error( - "Can't setup XboxAPI connection. Check your account or " - "api key on xboxapi.com. Code: %s Description: %s ", - profile.get("error_code", "unknown"), - profile.get("error_message", "unknown"), + "Can't setup X API connection. Check your account or " + "api key on xapi.us. Code: %s Description: %s ", + response.status_code, + response.reason, ) return @@ -59,7 +59,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def get_user_gamercard(api, xuid): """Get profile info.""" - gamercard = api.get_user_gamercard(xuid) + gamercard = api.gamer(gamertag="", xuid=xuid).get("gamercard") _LOGGER.debug("User gamercard: %s", gamercard) if gamercard.get("success", True) and gamercard.get("code") is None: @@ -82,11 +82,11 @@ class XboxSensor(Entity): self._presence = [] self._xuid = xuid self._api = api - self._gamertag = gamercard.get("gamertag") - self._gamerscore = gamercard.get("gamerscore") + self._gamertag = gamercard["gamertag"] + self._gamerscore = gamercard["gamerscore"] self._interval = interval - self._picture = gamercard.get("gamerpicSmallSslImagePath") - self._tier = gamercard.get("tier") + self._picture = gamercard["gamerpicSmallSslImagePath"] + self._tier = gamercard["tier"] @property def name(self): @@ -111,10 +111,8 @@ class XboxSensor(Entity): attributes["tier"] = self._tier for device in self._presence: - for title in device.get("titles"): - attributes[ - f'{device.get("type")} {title.get("placement")}' - ] = title.get("name") + for title in device["titles"]: + attributes[f'{device["type"]} {title["placement"]}'] = title["name"] return attributes @@ -140,7 +138,7 @@ class XboxSensor(Entity): def update(self): """Update state data from Xbox API.""" - presence = self._api.get_user_presence(self._xuid) + presence = self._api.gamer(gamertag="", xuid=self._xuid).get("presence") _LOGGER.debug("User presence: %s", presence) - self._state = presence.get("state") + self._state = presence["state"] self._presence = presence.get("devices", []) diff --git a/requirements_all.txt b/requirements_all.txt index f74cae919dd..4c9f2fdb7d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2219,7 +2219,7 @@ wolf_smartset==0.1.4 xbee-helper==0.0.7 # homeassistant.components.xbox_live -xboxapi==0.1.1 +xboxapi==2.0.0 # homeassistant.components.xfinity xfinity-gateway==0.0.4 From 69203b5373e77e39b020d181c5cf1b45493b278d Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Fri, 24 Jul 2020 16:14:47 -0400 Subject: [PATCH 128/362] Gracefully handle bond API errors and timeouts through available state (#38137) * Gracefully handle API errors and timeouts through available state * Gracefully handle API errors and timeouts through available state --- homeassistant/components/bond/__init__.py | 5 +++- homeassistant/components/bond/entity.py | 27 +++++++++++++++++-- tests/components/bond/common.py | 32 ++++++++++++++++++++--- tests/components/bond/test_cover.py | 14 +++++++++- tests/components/bond/test_fan.py | 14 +++++++++- tests/components/bond/test_light.py | 14 +++++++++- tests/components/bond/test_switch.py | 14 +++++++++- 7 files changed, 109 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 60c78ee4dbe..50025adbc1a 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,17 +1,20 @@ """The Bond integration.""" import asyncio +from aiohttp import ClientTimeout from bond_api import Bond from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import SLOW_UPDATE_WARNING from .const import DOMAIN from .utils import BondHub PLATFORMS = ["cover", "fan", "light", "switch"] +_API_TIMEOUT = SLOW_UPDATE_WARNING - 1 async def async_setup(hass: HomeAssistant, config: dict): @@ -25,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): host = entry.data[CONF_HOST] token = entry.data[CONF_ACCESS_TOKEN] - bond = Bond(host=host, token=token) + bond = Bond(host=host, token=token, timeout=ClientTimeout(total=_API_TIMEOUT)) hub = BondHub(bond) await hub.setup() hass.data[DOMAIN][entry.entry_id] = hub diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index d6d314f2844..8c3b9c638f5 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,13 +1,19 @@ """An abstract class common to all Bond entities.""" from abc import abstractmethod +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging from typing import Any, Dict, Optional +from aiohttp import ClientError + from homeassistant.const import ATTR_NAME from homeassistant.helpers.entity import Entity from .const import DOMAIN from .utils import BondDevice, BondHub +_LOGGER = logging.getLogger(__name__) + class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" @@ -16,6 +22,7 @@ class BondEntity(Entity): """Initialize entity with API and device info.""" self._hub = hub self._device = device + self._available = True @property def unique_id(self) -> Optional[str]: @@ -41,10 +48,26 @@ class BondEntity(Entity): """Let HA know this entity relies on an assumed state tracked by Bond.""" return True + @property + def available(self) -> bool: + """Report availability of this entity based on last API call results.""" + return self._available + async def async_update(self): """Fetch assumed state of the cover from the hub using API.""" - state: dict = await self._hub.bond.device_state(self._device.device_id) - self._apply_state(state) + try: + state: dict = await self._hub.bond.device_state(self._device.device_id) + except (ClientError, AsyncIOTimeoutError, OSError) as error: + if self._available: + _LOGGER.warning( + "Entity %s has become unavailable", self.entity_id, exc_info=error + ) + self._available = False + else: + if not self._available: + _LOGGER.info("Entity %s has come back", self.entity_id) + self._available = True + self._apply_state(state) @abstractmethod def _apply_state(self, state: dict): diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 28395bfbe77..b4d22641204 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -1,13 +1,16 @@ """Common methods used across tests for Bond.""" +from asyncio import TimeoutError as AsyncIOTimeoutError +from datetime import timedelta from typing import Any, Dict from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, STATE_UNAVAILABLE from homeassistant.setup import async_setup_component +from homeassistant.util import utcnow from tests.async_mock import patch -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MOCK_HUB_VERSION: dict = {"bondid": "test-bond-id"} @@ -74,11 +77,32 @@ def patch_bond_action(): return patch("homeassistant.components.bond.Bond.action") -def patch_bond_device_state(return_value=None): +def patch_bond_device_state(return_value=None, side_effect=None): """Patch Bond API device state endpoint.""" if return_value is None: return_value = {} return patch( - "homeassistant.components.bond.Bond.device_state", return_value=return_value + "homeassistant.components.bond.Bond.device_state", + return_value=return_value, + side_effect=side_effect, ) + + +async def help_test_entity_available( + hass: core.HomeAssistant, domain: str, device: Dict[str, Any], entity_id: str +): + """Run common test to verify available property.""" + await setup_platform(hass, domain, device) + + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + with patch_bond_device_state(side_effect=AsyncIOTimeoutError()): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + with patch_bond_device_state(return_value={}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert hass.states.get(entity_id).state != STATE_UNAVAILABLE diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index da73e086a61..a9d55ce593c 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -15,7 +15,12 @@ from homeassistant.const import ( from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import patch_bond_action, patch_bond_device_state, setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) from tests.common import async_fire_time_changed @@ -109,3 +114,10 @@ async def test_update_reports_closed_cover(hass: core.HomeAssistant): await hass.async_block_till_done() assert hass.states.get("cover.name_1").state == "closed" + + +async def test_cover_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, COVER_DOMAIN, shades("name-1"), "cover.name_1" + ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index f73310bc504..6a8a15fc4c0 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -18,7 +18,12 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import patch_bond_action, patch_bond_device_state, setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) from tests.common import async_fire_time_changed @@ -192,3 +197,10 @@ async def test_set_fan_direction(hass: core.HomeAssistant): mock_set_direction.assert_called_once_with( "test-device-id", Action.set_direction(Direction.FORWARD) ) + + +async def test_fan_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, FAN_DOMAIN, ceiling_fan("name-1"), "fan.name_1" + ) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 55936e3a11c..b507395dab3 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -10,7 +10,12 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import patch_bond_action, patch_bond_device_state, setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) from tests.common import async_fire_time_changed @@ -162,3 +167,10 @@ async def test_flame_converted_to_brightness(hass: core.HomeAssistant): await hass.async_block_till_done() assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128 + + +async def test_light_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), "light.name_1" + ) diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 1cfdf682d38..8a5803d4eee 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -10,7 +10,12 @@ from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_O from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow -from .common import patch_bond_action, patch_bond_device_state, setup_platform +from .common import ( + help_test_entity_available, + patch_bond_action, + patch_bond_device_state, + setup_platform, +) from tests.common import async_fire_time_changed @@ -86,3 +91,10 @@ async def test_update_reports_switch_is_off(hass: core.HomeAssistant): await hass.async_block_till_done() assert hass.states.get("switch.name_1").state == "off" + + +async def test_switch_available(hass: core.HomeAssistant): + """Tests that available state is updated based on API errors.""" + await help_test_entity_available( + hass, SWITCH_DOMAIN, generic_device("name-1"), "switch.name_1" + ) From 8943954b185a9a17af5c16c5bcfdd7626b3b3871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 24 Jul 2020 22:45:34 +0200 Subject: [PATCH 129/362] Prevent unnecessary updates of zone component (#38167) --- homeassistant/components/zone/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index aad8eb51dd2..2a7c3f01a27 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -323,6 +323,8 @@ class Zone(entity.Entity): async def async_update_config(self, config: Dict) -> None: """Handle when the config is updated.""" + if self._config == config: + return self._config = config self._generate_attrs() self.async_write_ha_state() From 9fe142a11491727ae4446e0f86f0df693594ede9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Fri, 24 Jul 2020 22:46:05 +0200 Subject: [PATCH 130/362] Prevent unnecessary updates of sun component (#38169) --- homeassistant/components/sun/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index c4692598447..fe89413f4d5 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -100,7 +100,10 @@ class Sun(Entity): self._next_change = None def update_location(_event): - self.location = get_astral_location(self.hass) + location = get_astral_location(self.hass) + if location == self.location: + return + self.location = location self.update_events(dt_util.utcnow()) update_location(None) From 581c4a4eddfab3725add34d2f8861b32d144be8b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 24 Jul 2020 22:59:15 +0200 Subject: [PATCH 131/362] Add AccuWeather integration (#37166) * Initial commit * Fix strings * Fix unit system * Add config_flow tests * Simplify tests * More tests * Update comment * Fix pylint error * Run gen_requirements_all * Fix pyline error * Round precipitation and precipitation probability * Bump backend library * Bump backend library * Add undo update listener on unload * Add translation key for invalid_api_key * Remove entity_registry_enabled_default property * Suggested change * Bump library --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/accuweather/__init__.py | 132 ++++++++ .../components/accuweather/config_flow.py | 112 +++++++ homeassistant/components/accuweather/const.py | 23 ++ .../components/accuweather/manifest.json | 8 + .../components/accuweather/strings.json | 35 +++ .../components/accuweather/weather.py | 179 +++++++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/accuweather/__init__.py | 1 + .../accuweather/test_config_flow.py | 158 ++++++++++ .../accuweather/current_conditions_data.json | 290 ++++++++++++++++++ tests/fixtures/accuweather/location_data.json | 49 +++ 15 files changed, 998 insertions(+) create mode 100644 homeassistant/components/accuweather/__init__.py create mode 100644 homeassistant/components/accuweather/config_flow.py create mode 100644 homeassistant/components/accuweather/const.py create mode 100644 homeassistant/components/accuweather/manifest.json create mode 100644 homeassistant/components/accuweather/strings.json create mode 100644 homeassistant/components/accuweather/weather.py create mode 100644 tests/components/accuweather/__init__.py create mode 100644 tests/components/accuweather/test_config_flow.py create mode 100644 tests/fixtures/accuweather/current_conditions_data.json create mode 100644 tests/fixtures/accuweather/location_data.json diff --git a/.coveragerc b/.coveragerc index d1fc86e1010..7384ee23245 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,9 @@ omit = homeassistant/scripts/*.py # omit pieces of code that rely on external devices being present + homeassistant/components/accuweather/__init__.py + homeassistant/components/accuweather/const.py + homeassistant/components/accuweather/weather.py homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py homeassistant/components/acmeda/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 025f6d7930c..3b17bca9595 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -14,6 +14,7 @@ homeassistant/scripts/check_config.py @kellerza # Integrations homeassistant/components/abode/* @shred86 +homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray homeassistant/components/adguard/* @frenck homeassistant/components/agent_dvr/* @ispysoftware diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py new file mode 100644 index 00000000000..0107262e490 --- /dev/null +++ b/homeassistant/components/accuweather/__init__.py @@ -0,0 +1,132 @@ +"""The AccuWeather component.""" +import asyncio +from datetime import timedelta +import logging + +from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout + +from homeassistant.const import CONF_API_KEY +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_FORECAST, + CONF_FORECAST, + COORDINATOR, + DOMAIN, + UNDO_UPDATE_LISTENER, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["weather"] + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured AccuWeather.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass, config_entry) -> bool: + """Set up AccuWeather as config entry.""" + api_key = config_entry.data[CONF_API_KEY] + location_key = config_entry.unique_id + forecast = config_entry.options.get(CONF_FORECAST, False) + + _LOGGER.debug("Using location_key: %s, get forecast: %s", location_key, forecast) + + websession = async_get_clientsession(hass) + + coordinator = AccuWeatherDataUpdateCoordinator( + hass, websession, api_key, location_key, forecast + ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + undo_listener = config_entry.add_update_listener(update_listener) + + hass.data[DOMAIN][config_entry.entry_id] = { + COORDINATOR: coordinator, + UNDO_UPDATE_LISTENER: undo_listener, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def update_listener(hass, config_entry): + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + +class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching AccuWeather data API.""" + + def __init__(self, hass, session, api_key, location_key, forecast: bool): + """Initialize.""" + self.location_key = location_key + self.forecast = forecast + self.is_metric = hass.config.units.is_metric + self.accuweather = AccuWeather(api_key, session, location_key=self.location_key) + + # Enabling the forecast download increases the number of requests per data + # update, we use 32 minutes for current condition only and 64 minutes for + # current condition and forecast as update interval to not exceed allowed number + # of requests. We have 50 requests allowed per day, so we use 45 and leave 5 as + # a reserve for restarting HA. + update_interval = ( + timedelta(minutes=64) if self.forecast else timedelta(minutes=32) + ) + _LOGGER.debug("Data will be update every %s", update_interval) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Update data via library.""" + try: + with timeout(10): + current = await self.accuweather.async_get_current_conditions() + forecast = ( + await self.accuweather.async_get_forecast(metric=self.is_metric) + if self.forecast + else {} + ) + except ( + ApiError, + ClientConnectorError, + InvalidApiKeyError, + RequestsExceededError, + ) as error: + raise UpdateFailed(error) + _LOGGER.debug("Requests remaining: %s", self.accuweather.requests_remaining) + return {**current, **{ATTR_FORECAST: forecast}} diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py new file mode 100644 index 00000000000..d50a2ac406b --- /dev/null +++ b/homeassistant/components/accuweather/config_flow.py @@ -0,0 +1,112 @@ +"""Adds config flow for AccuWeather.""" +import asyncio + +from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectorError +from async_timeout import timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_FORECAST, DOMAIN # pylint:disable=unused-import + + +class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for AccuWeather.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + # Under the terms of use of the API, one user can use one free API key. Due to + # the small number of requests allowed, we only allow one integration instance. + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + + if user_input is not None: + websession = async_get_clientsession(self.hass) + try: + with timeout(10): + accuweather = AccuWeather( + user_input[CONF_API_KEY], + websession, + latitude=user_input[CONF_LATITUDE], + longitude=user_input[CONF_LONGITUDE], + ) + await accuweather.async_get_location() + except (ApiError, ClientConnectorError, asyncio.TimeoutError, ClientError): + errors["base"] = "cannot_connect" + except InvalidApiKeyError: + errors[CONF_API_KEY] = "invalid_api_key" + except RequestsExceededError: + errors[CONF_API_KEY] = "requests_exceeded" + else: + await self.async_set_unique_id( + accuweather.location_key, raise_on_progress=False + ) + + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Options callback for AccuWeather.""" + return AccuWeatherOptionsFlowHandler(config_entry) + + +class AccuWeatherOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options for AccuWeather.""" + + def __init__(self, config_entry): + """Initialize AccuWeather options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_FORECAST, + default=self.config_entry.options.get(CONF_FORECAST, False), + ): bool + } + ), + ) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py new file mode 100644 index 00000000000..2b903d8aa6e --- /dev/null +++ b/homeassistant/components/accuweather/const.py @@ -0,0 +1,23 @@ +"""Constants for AccuWeather integration.""" +ATTRIBUTION = "Data provided by AccuWeather" +ATTR_FORECAST = CONF_FORECAST = "forecast" +COORDINATOR = "coordinator" +DOMAIN = "accuweather" +UNDO_UPDATE_LISTENER = "undo_update_listener" + +CONDITION_CLASSES = { + "clear-night": [33, 34, 37], + "cloudy": [7, 8, 38], + "exceptional": [24, 30, 31], + "fog": [11], + "hail": [25], + "lightning": [15], + "lightning-rainy": [16, 17, 41, 42], + "partlycloudy": [4, 6, 35, 36], + "pouring": [18], + "rainy": [12, 13, 14, 26, 39, 40], + "snowy": [19, 20, 21, 22, 23, 43, 44], + "snowy-rainy": [29], + "sunny": [1, 2, 3, 5], + "windy": [32], +} diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json new file mode 100644 index 00000000000..3b74087a61d --- /dev/null +++ b/homeassistant/components/accuweather/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "accuweather", + "name": "AccuWeather", + "documentation": "https://github.com/bieniu/ha-accuweather", + "requirements": ["accuweather==0.0.9"], + "codeowners": ["@bieniu"], + "config_flow": true +} diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json new file mode 100644 index 00000000000..80f3159ad9e --- /dev/null +++ b/homeassistant/components/accuweather/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "title": "AccuWeather", + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "data": { + "name": "Name of the integration", + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "requests_exceeded": "The allowed number of requests to Accuweather API has been exceeded. You have to wait or change API Key." + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "user": { + "title": "AccuWeather Options", + "description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 64 minutes instead of every 32 minutes.", + "data": { + "forecast": "Weather forecast" + } + } + } + } +} diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py new file mode 100644 index 00000000000..866f4821b02 --- /dev/null +++ b/homeassistant/components/accuweather/weather.py @@ -0,0 +1,179 @@ +"""Support for the AccuWeather service.""" +from statistics import mean + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + WeatherEntity, +) +from homeassistant.const import CONF_NAME, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.util.dt import utc_from_timestamp + +from .const import ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, COORDINATOR, DOMAIN + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a AccuWeather weather entity from a config_entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + async_add_entities([AccuWeatherEntity(name, coordinator)], False) + + +class AccuWeatherEntity(WeatherEntity): + """Define an AccuWeather entity.""" + + def __init__(self, name, coordinator): + """Initialize.""" + self._name = name + self.coordinator = coordinator + self._attrs = {} + self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return self.coordinator.location_key + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def condition(self): + """Return the current condition.""" + try: + return [ + k + for k, v in CONDITION_CLASSES.items() + if self.coordinator.data["WeatherIcon"] in v + ][0] + except IndexError: + return STATE_UNKNOWN + + @property + def temperature(self): + """Return the temperature.""" + return self.coordinator.data["Temperature"][self._unit_system]["Value"] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS if self.coordinator.is_metric else TEMP_FAHRENHEIT + + @property + def pressure(self): + """Return the pressure.""" + return self.coordinator.data["Pressure"][self._unit_system]["Value"] + + @property + def humidity(self): + """Return the humidity.""" + return self.coordinator.data["RelativeHumidity"] + + @property + def wind_speed(self): + """Return the wind speed.""" + return self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self.coordinator.data["Wind"]["Direction"]["Degrees"] + + @property + def visibility(self): + """Return the visibility.""" + return self.coordinator.data["Visibility"][self._unit_system]["Value"] + + @property + def ozone(self): + """Return the ozone level.""" + # We only have ozone data for certain locations and only in the forecast data. + if self.coordinator.forecast and self.coordinator.data[ATTR_FORECAST][0].get( + "Ozone" + ): + return self.coordinator.data[ATTR_FORECAST][0]["Ozone"]["Value"] + return None + + @property + def forecast(self): + """Return the forecast array.""" + if self.coordinator.forecast: + # remap keys from library to keys understood by the weather component + forecast = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp( + item["EpochDate"] + ).isoformat(), + ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], + ATTR_FORECAST_TEMP_LOW: item["TemperatureMin"]["Value"], + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(item), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( + mean( + [ + item["PrecipitationProbabilityDay"], + item["PrecipitationProbabilityNight"], + ] + ) + ), + ATTR_FORECAST_WIND_SPEED: item["WindDay"]["Speed"]["Value"], + ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], + ATTR_FORECAST_CONDITION: [ + k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v + ][0], + } + for item in self.coordinator.data[ATTR_FORECAST] + ] + return forecast + return None + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update AccuWeather entity.""" + await self.coordinator.async_request_refresh() + + @staticmethod + def _calc_precipitation(day: dict) -> float: + """Return sum of the precipitation.""" + precip_sum = 0 + precip_types = ["Rain", "Snow", "Ice"] + for precip in precip_types: + precip_sum = sum( + [ + precip_sum, + day[f"{precip}Day"]["Value"], + day[f"{precip}Night"]["Value"], + ] + ) + return round(precip_sum, 1) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a1e062d4e28..a686116229e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -7,6 +7,7 @@ To update, run python3 -m script.hassfest FLOWS = [ "abode", + "accuweather", "acmeda", "adguard", "agent_dvr", diff --git a/requirements_all.txt b/requirements_all.txt index 4c9f2fdb7d2..b68e46db63a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -102,6 +102,9 @@ YesssSMS==0.4.1 # homeassistant.components.abode abodepy==0.19.0 +# homeassistant.components.accuweather +accuweather==0.0.9 + # homeassistant.components.mcp23017 adafruit-blinka==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eadf04cad84..bbd1eac938b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,6 +45,9 @@ YesssSMS==0.4.1 # homeassistant.components.abode abodepy==0.19.0 +# homeassistant.components.accuweather +accuweather==0.0.9 + # homeassistant.components.androidtv adb-shell[async]==0.2.0 diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py new file mode 100644 index 00000000000..97ae531ddd0 --- /dev/null +++ b/tests/components/accuweather/__init__.py @@ -0,0 +1 @@ +"""Tests for AccuWeather.""" diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py new file mode 100644 index 00000000000..399a69902e1 --- /dev/null +++ b/tests/components/accuweather/test_config_flow.py @@ -0,0 +1,158 @@ +"""Define tests for the AccuWeather config flow.""" +import json + +from accuweather import ApiError, InvalidApiKeyError, RequestsExceededError + +from homeassistant import data_entry_flow +from homeassistant.components.accuweather.const import CONF_FORECAST, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture + +VALID_CONFIG = { + CONF_NAME: "abcd", + CONF_API_KEY: "32-character-string-1234567890qw", + CONF_LATITUDE: 55.55, + CONF_LONGITUDE: 122.12, +} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + +async def test_invalid_api_key_1(hass): + """Test that errors are shown when API key is invalid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_NAME: "abcd", + CONF_API_KEY: "foo", + CONF_LATITUDE: 55.55, + CONF_LONGITUDE: 122.12, + }, + ) + + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_invalid_api_key_2(hass): + """Test that errors are shown when API key is invalid.""" + with patch( + "accuweather.AccuWeather._async_get_data", + side_effect=InvalidApiKeyError("Invalid API key"), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_api_error(hass): + """Test API error.""" + with patch( + "accuweather.AccuWeather._async_get_data", + side_effect=ApiError("Invalid response from AccuWeather API"), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_requests_exceeded_error(hass): + """Test requests exceeded error.""" + with patch( + "accuweather.AccuWeather._async_get_data", + side_effect=RequestsExceededError( + "The allowed number of requests has been exceeded" + ), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["errors"] == {CONF_API_KEY: "requests_exceeded"} + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + with patch( + "accuweather.AccuWeather._async_get_data", + return_value=json.loads(load_fixture("accuweather/location_data.json")), + ): + MockConfigEntry( + domain=DOMAIN, unique_id="123456", data=VALID_CONFIG, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" + + +async def test_create_entry(hass): + """Test that the user step works.""" + with patch( + "accuweather.AccuWeather._async_get_data", + return_value=json.loads(load_fixture("accuweather/location_data.json")), + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "abcd" + assert result["data"][CONF_NAME] == "abcd" + assert result["data"][CONF_LATITUDE] == 55.55 + assert result["data"][CONF_LONGITUDE] == 122.12 + assert result["data"][CONF_API_KEY] == "32-character-string-1234567890qw" + + +async def test_options_flow(hass): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="123456", data=VALID_CONFIG, + ) + config_entry.add_to_hass(hass) + + with patch( + "accuweather.AccuWeather._async_get_data", + return_value=json.loads(load_fixture("accuweather/location_data.json")), + ), patch( + "accuweather.AccuWeather.async_get_current_conditions", + return_value=json.loads( + load_fixture("accuweather/current_conditions_data.json") + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_FORECAST: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_FORECAST: True} diff --git a/tests/fixtures/accuweather/current_conditions_data.json b/tests/fixtures/accuweather/current_conditions_data.json new file mode 100644 index 00000000000..f94ea071ee9 --- /dev/null +++ b/tests/fixtures/accuweather/current_conditions_data.json @@ -0,0 +1,290 @@ +{ + "WeatherIcon": 1, + "HasPrecipitation": false, + "PrecipitationType": null, + "Temperature": { + "Metric": { + "Value": 22.6, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 73.0, + "Unit": "F", + "UnitType": 18 + } + }, + "RealFeelTemperature": { + "Metric": { + "Value": 25.1, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 77.0, + "Unit": "F", + "UnitType": 18 + } + }, + "RealFeelTemperatureShade": { + "Metric": { + "Value": 21.1, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 70.0, + "Unit": "F", + "UnitType": 18 + } + }, + "RelativeHumidity": 67, + "IndoorRelativeHumidity": 67, + "DewPoint": { + "Metric": { + "Value": 16.2, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 61.0, + "Unit": "F", + "UnitType": 18 + } + }, + "Wind": { + "Direction": { + "Degrees": 180, + "Localized": "S", + "English": "S" + }, + "Speed": { + "Metric": { + "Value": 14.5, + "Unit": "km/h", + "UnitType": 7 + }, + "Imperial": { + "Value": 9.0, + "Unit": "mi/h", + "UnitType": 9 + } + } + }, + "WindGust": { + "Speed": { + "Metric": { + "Value": 20.3, + "Unit": "km/h", + "UnitType": 7 + }, + "Imperial": { + "Value": 12.6, + "Unit": "mi/h", + "UnitType": 9 + } + } + }, + "UVIndex": 6, + "UVIndexText": "High", + "Visibility": { + "Metric": { + "Value": 16.1, + "Unit": "km", + "UnitType": 6 + }, + "Imperial": { + "Value": 10.0, + "Unit": "mi", + "UnitType": 2 + } + }, + "ObstructionsToVisibility": "", + "CloudCover": 10, + "Ceiling": { + "Metric": { + "Value": 3200.0, + "Unit": "m", + "UnitType": 5 + }, + "Imperial": { + "Value": 10500.0, + "Unit": "ft", + "UnitType": 0 + } + }, + "Pressure": { + "Metric": { + "Value": 1012.0, + "Unit": "mb", + "UnitType": 14 + }, + "Imperial": { + "Value": 29.88, + "Unit": "inHg", + "UnitType": 12 + } + }, + "PressureTendency": { + "LocalizedText": "Falling", + "Code": "F" + }, + "Past24HourTemperatureDeparture": { + "Metric": { + "Value": 0.3, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 0.0, + "Unit": "F", + "UnitType": 18 + } + }, + "ApparentTemperature": { + "Metric": { + "Value": 22.8, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 73.0, + "Unit": "F", + "UnitType": 18 + } + }, + "WindChillTemperature": { + "Metric": { + "Value": 22.8, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 73.0, + "Unit": "F", + "UnitType": 18 + } + }, + "WetBulbTemperature": { + "Metric": { + "Value": 18.6, + "Unit": "C", + "UnitType": 17 + }, + "Imperial": { + "Value": 65.0, + "Unit": "F", + "UnitType": 18 + } + }, + "Precip1hr": { + "Metric": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.0, + "Unit": "in", + "UnitType": 1 + } + }, + "PrecipitationSummary": { + "Precipitation": { + "Metric": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.0, + "Unit": "in", + "UnitType": 1 + } + }, + "PastHour": { + "Metric": { + "Value": 0.0, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.0, + "Unit": "in", + "UnitType": 1 + } + }, + "Past3Hours": { + "Metric": { + "Value": 1.3, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.05, + "Unit": "in", + "UnitType": 1 + } + }, + "Past6Hours": { + "Metric": { + "Value": 1.3, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.05, + "Unit": "in", + "UnitType": 1 + } + }, + "Past9Hours": { + "Metric": { + "Value": 2.5, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.1, + "Unit": "in", + "UnitType": 1 + } + }, + "Past12Hours": { + "Metric": { + "Value": 3.8, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.15, + "Unit": "in", + "UnitType": 1 + } + }, + "Past18Hours": { + "Metric": { + "Value": 5.1, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.2, + "Unit": "in", + "UnitType": 1 + } + }, + "Past24Hours": { + "Metric": { + "Value": 7.6, + "Unit": "mm", + "UnitType": 3 + }, + "Imperial": { + "Value": 0.3, + "Unit": "in", + "UnitType": 1 + } + } + } +} \ No newline at end of file diff --git a/tests/fixtures/accuweather/location_data.json b/tests/fixtures/accuweather/location_data.json new file mode 100644 index 00000000000..43094d108ed --- /dev/null +++ b/tests/fixtures/accuweather/location_data.json @@ -0,0 +1,49 @@ +{ + "Version": 1, + "Key": "268068", + "Type": "City", + "Rank": 85, + "LocalizedName": "Piątek", + "EnglishName": "Piątek", + "PrimaryPostalCode": "", + "Region": { "ID": "EUR", "LocalizedName": "Europe", "EnglishName": "Europe" }, + "Country": { "ID": "PL", "LocalizedName": "Poland", "EnglishName": "Poland" }, + "AdministrativeArea": { + "ID": "10", + "LocalizedName": "Łódź", + "EnglishName": "Łódź", + "Level": 1, + "LocalizedType": "Voivodship", + "EnglishType": "Voivodship", + "CountryID": "PL" + }, + "TimeZone": { + "Code": "CEST", + "Name": "Europe/Warsaw", + "GmtOffset": 2.0, + "IsDaylightSaving": true, + "NextOffsetChange": "2020-10-25T01:00:00Z" + }, + "GeoPosition": { + "Latitude": 52.069, + "Longitude": 19.479, + "Elevation": { + "Metric": { "Value": 94.0, "Unit": "m", "UnitType": 5 }, + "Imperial": { "Value": 308.0, "Unit": "ft", "UnitType": 0 } + } + }, + "IsAlias": false, + "SupplementalAdminAreas": [ + { "Level": 2, "LocalizedName": "Łęczyca", "EnglishName": "Łęczyca" }, + { "Level": 3, "LocalizedName": "Piątek", "EnglishName": "Piątek" } + ], + "DataSets": [ + "AirQualityCurrentConditions", + "AirQualityForecasts", + "Alerts", + "ForecastConfidence", + "FutureRadar", + "MinuteCast", + "Radar" + ] +} From b55d1127de4666c3900a8a1a35b8c1f4892d449b Mon Sep 17 00:00:00 2001 From: Markus Korbel Date: Fri, 24 Jul 2020 22:17:39 +0100 Subject: [PATCH 132/362] Added 2020 version Aqara double wall switch (#38164) Added support for new 2020 version of the Aqara D1 double wall switch (lumi.remote.b286acn02) Confirmed that all button press events use the same codes after updating deconz rest api to add support for this switch. A contributor of the API currently has the working version @ git clone --branch Legrand-teleruptor https://github.com/Smanar/deconz-rest-plugin.git --- homeassistant/components/deconz/device_trigger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index b0486a99dc8..6e5f4a11ca4 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -237,6 +237,7 @@ AQARA_CUBE = { } AQARA_DOUBLE_WALL_SWITCH_MODEL = "lumi.remote.b286acn01" +AQARA_DOUBLE_WALL_SWITCH_MODEL_2020 = "lumi.remote.b286acn02" AQARA_DOUBLE_WALL_SWITCH = { (CONF_SHORT_PRESS, CONF_LEFT): {CONF_EVENT: 1002}, (CONF_LONG_PRESS, CONF_LEFT): {CONF_EVENT: 1001}, @@ -357,6 +358,7 @@ REMOTES = { AQARA_CUBE_MODEL: AQARA_CUBE, AQARA_CUBE_MODEL_ALT1: AQARA_CUBE, AQARA_DOUBLE_WALL_SWITCH_MODEL: AQARA_DOUBLE_WALL_SWITCH, + AQARA_DOUBLE_WALL_SWITCH_MODEL_2020: AQARA_DOUBLE_WALL_SWITCH, AQARA_DOUBLE_WALL_SWITCH_WXKG02LM_MODEL: AQARA_DOUBLE_WALL_SWITCH_WXKG02LM, AQARA_SINGLE_WALL_SWITCH_WXKG03LM_MODEL: AQARA_SINGLE_WALL_SWITCH_WXKG03LM, AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH, From a1ebb528138fae2d5372f21c406b0a0963e0be86 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sat, 25 Jul 2020 00:04:22 +0000 Subject: [PATCH 133/362] [ci skip] Translation update --- .../accuweather/translations/en.json | 35 ++++++++++++ .../components/bond/translations/lb.json | 15 ++++++ .../components/control4/translations/lb.json | 30 +++++++++++ .../components/demo/translations/lb.json | 1 + .../components/enocean/translations/lb.json | 3 ++ .../components/hue/translations/lb.json | 3 +- .../humidifier/translations/lb.json | 10 ++++ .../components/netatmo/translations/lb.json | 17 ++++++ .../components/pi_hole/translations/lb.json | 1 + .../components/plugwise/translations/lb.json | 1 + .../simplisafe/translations/en.json | 8 +-- .../simplisafe/translations/lb.json | 18 ++++++- .../simplisafe/translations/ru.json | 18 ++++++- .../simplisafe/translations/zh-Hant.json | 18 ++++++- .../components/smarthab/translations/lb.json | 17 ++++++ .../components/syncthru/translations/lb.json | 16 ++++++ .../components/wolflink/translations/lb.json | 26 +++++++++ .../wolflink/translations/sensor.lb.json | 53 +++++++++++++++++++ 18 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/en.json create mode 100644 homeassistant/components/bond/translations/lb.json create mode 100644 homeassistant/components/control4/translations/lb.json create mode 100644 homeassistant/components/enocean/translations/lb.json create mode 100644 homeassistant/components/smarthab/translations/lb.json create mode 100644 homeassistant/components/syncthru/translations/lb.json create mode 100644 homeassistant/components/wolflink/translations/lb.json create mode 100644 homeassistant/components/wolflink/translations/sensor.lb.json diff --git a/homeassistant/components/accuweather/translations/en.json b/homeassistant/components/accuweather/translations/en.json new file mode 100644 index 00000000000..1b6a5052fc0 --- /dev/null +++ b/homeassistant/components/accuweather/translations/en.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", + "requests_exceeded": "The allowed number of requests to Accuweather API has been exceeded. You have to wait or change API Key." + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name of the integration" + }, + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Weather forecast" + }, + "description": "Due to the limitations of the free version of the AccuWeather API key, when you enable weather forecast, data updates will be performed every 64 minutes instead of every 32 minutes.", + "title": "AccuWeather Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/lb.json b/homeassistant/components/bond/translations/lb.json new file mode 100644 index 00000000000..c0e3c9c97a0 --- /dev/null +++ b/homeassistant/components/bond/translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "access_token": "Acc\u00e8s jeton" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/lb.json b/homeassistant/components/control4/translations/lb.json new file mode 100644 index 00000000000..e1209bca632 --- /dev/null +++ b/homeassistant/components/control4/translations/lb.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "host": "IP Adress", + "password": "Passwuert", + "username": "Benotzernumm" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekonnen t\u00ebscht Atkualis\u00e9ierungen" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/lb.json b/homeassistant/components/demo/translations/lb.json index e138b7d7fa4..bfb094c00f9 100644 --- a/homeassistant/components/demo/translations/lb.json +++ b/homeassistant/components/demo/translations/lb.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Optionelle Boolean", + "constant": "Konstant", "int": "Numeresch Agab" } }, diff --git a/homeassistant/components/enocean/translations/lb.json b/homeassistant/components/enocean/translations/lb.json new file mode 100644 index 00000000000..1b32ae55b19 --- /dev/null +++ b/homeassistant/components/enocean/translations/lb.json @@ -0,0 +1,3 @@ +{ + "title": "EnOcean" +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/lb.json b/homeassistant/components/hue/translations/lb.json index 4e33d39072c..b6af356f387 100644 --- a/homeassistant/components/hue/translations/lb.json +++ b/homeassistant/components/hue/translations/lb.json @@ -58,7 +58,8 @@ "step": { "init": { "data": { - "allow_how_groups": "Hue Gruppen erlaaben" + "allow_how_groups": "Hue Gruppen erlaaben", + "allow_hue_groups": "Hue Gruppen erlaaben" } } } diff --git a/homeassistant/components/humidifier/translations/lb.json b/homeassistant/components/humidifier/translations/lb.json index 3dc1261132f..ff20290e950 100644 --- a/homeassistant/components/humidifier/translations/lb.json +++ b/homeassistant/components/humidifier/translations/lb.json @@ -6,6 +6,16 @@ "toggle": "{entity_name} \u00ebmschalten", "turn_off": "{entity_name} ausschalten", "turn_on": "{entity_name} uschalten" + }, + "condition_type": { + "is_mode": "{entity_name} ass op e spezifesche Modus gesat", + "is_off": "{entity_name} ass ausgeschalt", + "is_on": "{entity_name} ass un" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} Ziel Fiichtegkeet ge\u00e4nnert", + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt" } }, "state": { diff --git a/homeassistant/components/netatmo/translations/lb.json b/homeassistant/components/netatmo/translations/lb.json index 3605a76b372..cb7701e9a0a 100644 --- a/homeassistant/components/netatmo/translations/lb.json +++ b/homeassistant/components/netatmo/translations/lb.json @@ -13,5 +13,22 @@ "title": "Authentifikatiouns Method auswielen" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "Numm vum Ber\u00e4ich", + "mode": "Berechnung", + "show_on_map": "Op der Kaart uweisen" + }, + "title": "Netatmo Publique Wieder Sensor" + }, + "public_weather_areas": { + "data": { + "new_area": "Numm vum Ber\u00e4ich" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/lb.json b/homeassistant/components/pi_hole/translations/lb.json index 4224546df43..540462c889d 100644 --- a/homeassistant/components/pi_hole/translations/lb.json +++ b/homeassistant/components/pi_hole/translations/lb.json @@ -12,6 +12,7 @@ "data": { "api_key": "API Schl\u00ebssel (Optionell)", "host": "Host", + "location": "Standuert", "name": "Numm", "port": "Port", "ssl": "SSL benotzen", diff --git a/homeassistant/components/plugwise/translations/lb.json b/homeassistant/components/plugwise/translations/lb.json index ea9785d2039..8b0ea38c2f6 100644 --- a/homeassistant/components/plugwise/translations/lb.json +++ b/homeassistant/components/plugwise/translations/lb.json @@ -8,6 +8,7 @@ "invalid_auth": "Ong\u00eblteg Authentifikatioun, iwwerpr\u00e9if d\u00e9i 8 Charakteren vun denger Smile ID", "unknown": "Onerwaarte Feeler" }, + "flow_title": "Smile: {name}", "step": { "user": { "data": { diff --git a/homeassistant/components/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index 29ad4ee88ef..7e2cf0bf98a 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -8,7 +8,7 @@ "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials", "still_awaiting_mfa": "Still awaiting MFA email click", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Unexpected error" }, "step": { "mfa": { @@ -17,7 +17,7 @@ }, "reauth_confirm": { "data": { - "password": "[%key:common::config_flow::data::password%]" + "password": "Password" }, "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", "title": "Re-link SimpliSafe Account" @@ -25,8 +25,8 @@ "user": { "data": { "code": "Code (used in Home Assistant UI)", - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::email%]" + "password": "Password", + "username": "Email" }, "title": "Fill in your information." } diff --git a/homeassistant/components/simplisafe/translations/lb.json b/homeassistant/components/simplisafe/translations/lb.json index e6e2f760a8d..d81ddd63f0b 100644 --- a/homeassistant/components/simplisafe/translations/lb.json +++ b/homeassistant/components/simplisafe/translations/lb.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "D\u00ebse SimpliSafe Kont g\u00ebtt scho benotzt." + "already_configured": "D\u00ebse SimpliSafe Kont g\u00ebtt scho benotzt.", + "reauth_successful": "SimpliSafe erfollegr\u00e4ich re-authentifiz\u00e9iert." }, "error": { "identifier_exists": "Konto ass scho registr\u00e9iert", - "invalid_credentials": "Ong\u00eblteg Login Informatioune" + "invalid_credentials": "Ong\u00eblteg Login Informatioune", + "still_awaiting_mfa": "Waart nach den MFA E-Mail Klick.", + "unknown": "Onerwaarte Feeler" }, "step": { + "mfa": { + "description": "Kuck den E-Mailen fir ee Link vun SimpliSafe. Nodeem de Link opgeruff gouf, komm heihinner zer\u00e9ck fir d'Installatioun vun der Integratioun ofzeschl\u00e9issen.", + "title": "SimpliSafe Multi-Faktor Authentifikatioun" + }, + "reauth_confirm": { + "data": { + "password": "Passwuert" + }, + "description": "D\u00e4in Acc\u00e8s Jeton as ofgelaf oder gouf revok\u00e9iert. G\u00ebff d\u00e4i Passwuert an fir d\u00e4i Kont fr\u00ebsch ze verbannen.", + "title": "SimpliSafe Kont fr\u00ebsch verbannen" + }, "user": { "data": { "code": "Code (benotzt am Home Assistant Benotzer Interface)", diff --git a/homeassistant/components/simplisafe/translations/ru.json b/homeassistant/components/simplisafe/translations/ru.json index 26665617b1d..cd539ae184c 100644 --- a/homeassistant/components/simplisafe/translations/ru.json +++ b/homeassistant/components/simplisafe/translations/ru.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", + "reauth_successful": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", - "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "still_awaiting_mfa": "\u041e\u0436\u0438\u0434\u0430\u043d\u0438\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f, \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u043f\u043e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u0435.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { + "mfa": { + "description": "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0441\u0432\u043e\u044e \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u0443\u044e \u043f\u043e\u0447\u0442\u0443 \u043d\u0430 \u043d\u0430\u043b\u0438\u0447\u0438\u0435 \u0441\u0441\u044b\u043b\u043a\u0438 \u043e\u0442 SimpliSafe. \u041f\u043e\u0441\u043b\u0435 \u0442\u043e\u0433\u043e \u043a\u0430\u043a \u043e\u0442\u043a\u0440\u043e\u0435\u0442\u0435 \u0441\u0441\u044b\u043b\u043a\u0443, \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0443 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", + "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f SimpliSafe" + }, + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u0412\u0430\u0448 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438\u0441\u0442\u0435\u043a \u0438\u043b\u0438 \u0431\u044b\u043b \u0430\u043d\u043d\u0443\u043b\u0438\u0440\u043e\u0432\u0430\u043d. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u043d\u043e\u0432\u043e \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u043f\u0440\u0438\u0432\u044f\u0437\u043a\u0430 \u0430\u043a\u043a\u0430\u0443\u043d\u0442\u0430 SimpliSafe" + }, "user": { "data": { "code": "\u041a\u043e\u0434 (\u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u0432 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0435 Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/zh-Hant.json b/homeassistant/components/simplisafe/translations/zh-Hant.json index 2c522045cab..4b195cc5466 100644 --- a/homeassistant/components/simplisafe/translations/zh-Hant.json +++ b/homeassistant/components/simplisafe/translations/zh-Hant.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "\u6b64 SimpliSafe \u5e33\u865f\u5df2\u88ab\u4f7f\u7528\u3002" + "already_configured": "\u6b64 SimpliSafe \u5e33\u865f\u5df2\u88ab\u4f7f\u7528\u3002", + "reauth_successful": "SimpliSafe \u5df2\u6210\u529f\u8a8d\u8b49\u3002" }, "error": { "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", - "invalid_credentials": "\u6191\u8b49\u7121\u6548" + "invalid_credentials": "\u6191\u8b49\u7121\u6548", + "still_awaiting_mfa": "\u4ecd\u5728\u7b49\u5019\u9ede\u64ca\u591a\u6b65\u9a5f\u8a8d\u8b49\u90f5\u4ef6", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, "step": { + "mfa": { + "description": "\u8acb\u6aa2\u67e5\u4f86\u81ea SimpliSafe \u7684\u90f5\u4ef6\u4ee5\u53d6\u5f97\u9023\u7d50\u3002\u78ba\u8a8d\u9023\u7d50\u5f8c\uff0c\u518d\u56de\u5230\u6b64\u8655\u4ee5\u5b8c\u6210\u6574\u5408\u5b89\u88dd\u3002", + "title": "SimpliSafe \u591a\u6b65\u9a5f\u9a57\u8b49" + }, + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "\u5b58\u53d6\u5bc6\u9470\u5df2\u7d93\u904e\u671f\u6216\u53d6\u6d88\uff0c\u8acb\u8f38\u5165\u5bc6\u78bc\u4ee5\u91cd\u65b0\u9023\u7d50\u5e33\u865f\u3002", + "title": "\u91cd\u65b0\u9023\u7d50 SimpliSafe \u5e33\u865f" + }, "user": { "data": { "code": "\u9a57\u8b49\u78bc\uff08\u4f7f\u7528\u65bc Home Assistant UI\uff09", diff --git a/homeassistant/components/smarthab/translations/lb.json b/homeassistant/components/smarthab/translations/lb.json new file mode 100644 index 00000000000..0378cd2300e --- /dev/null +++ b/homeassistant/components/smarthab/translations/lb.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "unknown_error": "Onerwaarte Feeler", + "wrong_login": "Ong\u00eblteg Authentifikatioun" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwuert" + }, + "title": "SmartHab ariichten" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/lb.json b/homeassistant/components/syncthru/translations/lb.json new file mode 100644 index 00000000000..4e5a8218bd7 --- /dev/null +++ b/homeassistant/components/syncthru/translations/lb.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_url": "Ong\u00eblteg URL" + }, + "flow_title": "Samsung SyncThru Printer: {name}", + "step": { + "user": { + "data": { + "name": "Numm", + "url": "Web interface URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/lb.json b/homeassistant/components/wolflink/translations/lb.json new file mode 100644 index 00000000000..97a65b12d02 --- /dev/null +++ b/homeassistant/components/wolflink/translations/lb.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "device": { + "data": { + "device_name": "Apparat" + }, + "title": "WOLF Apparat auswielen" + }, + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.lb.json b/homeassistant/components/wolflink/translations/sensor.lb.json new file mode 100644 index 00000000000..4a19955c56e --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.lb.json @@ -0,0 +1,53 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1x DHW", + "aktiviert": "Aktiv\u00e9iert", + "at_abschaltung": "OT ausmaachen", + "at_frostschutz": "OT Frostschutz", + "aus": "Deaktiv\u00e9iert", + "auto": "Auto", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatik AUS", + "automatik_ein": "Automatik UN", + "bereit_keine_ladung": "Prett, lued net", + "cooling": "Ofkillen", + "deaktiviert": "Inaktiv", + "eco": "Eco", + "ein": "Aktiv\u00e9iert", + "frostschutz": "Frostschutz", + "gasdruck": "Gas Drock", + "glt_betrieb": "BMS Modus", + "heizbetrieb": "Heizung Modus", + "heizung": "Heizung", + "initialisierung": "Initialis\u00e9ierung", + "kalibration": "Kalibratioun", + "kalibration_warmwasserbetrieb": "DHW Kalibratioun", + "partymodus": "Party Modus", + "reduzierter_betrieb": "Limit\u00e9ierte Modus", + "rt_abschaltung": "RT ausmaachen", + "rt_frostschutz": "RT Frostschutz", + "schornsteinfeger": "Emissioun Test", + "smart_home": "SmartHome", + "softstart": "Soft Start", + "solarbetrieb": "Solar Modus", + "sparbetrieb": "Economy Modus", + "sparen": "Economy", + "spreizung_hoch": "dT ze breet", + "spreizung_kf": "KF ausbreeden", + "stabilisierung": "Stabilis\u00e9ierung", + "standby": "Standby", + "start": "Start", + "storung": "Feeler", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Vakanze Modus", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW Schnell Start", + "warmwasserbetrieb": "DHW Modus", + "warmwasservorrang": "DHW Priorit\u00e9it", + "zunden": "Z\u00fcndung" + } + } +} \ No newline at end of file From b868f13591c260a8d81aa8a00f54c904a50c6777 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jul 2020 16:04:36 -1000 Subject: [PATCH 134/362] Ensure all track time change tests mock a specific start time (#38178) * Ensure all track time change tests mock a specific start time * make sure tests calling async_track_utc_time_change fire time in utc --- tests/helpers/test_event.py | 173 +++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 50 deletions(-) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 669b2ab25c8..0c16e624fc3 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -50,7 +50,7 @@ async def test_track_point_in_time(hass): runs = [] async_track_point_in_utc_time( - hass, callback(lambda x: runs.append(1)), birthday_paulus + hass, callback(lambda x: runs.append(x)), birthday_paulus ) async_fire_time_changed(hass, before_birthday) @@ -67,7 +67,7 @@ async def test_track_point_in_time(hass): assert len(runs) == 1 async_track_point_in_utc_time( - hass, callback(lambda x: runs.append(1)), birthday_paulus + hass, callback(lambda x: runs.append(x)), birthday_paulus ) async_fire_time_changed(hass, after_birthday) @@ -75,7 +75,7 @@ async def test_track_point_in_time(hass): assert len(runs) == 2 unsub = async_track_point_in_time( - hass, callback(lambda x: runs.append(1)), birthday_paulus + hass, callback(lambda x: runs.append(x)), birthday_paulus ) unsub() @@ -539,7 +539,7 @@ async def test_track_time_interval(hass): utc_now = dt_util.utcnow() unsub = async_track_time_interval( - hass, lambda x: specific_runs.append(1), timedelta(seconds=10) + hass, lambda x: specific_runs.append(x), timedelta(seconds=10) ) async_fire_time_changed(hass, utc_now + timedelta(seconds=5)) @@ -750,22 +750,33 @@ async def test_async_track_time_change(hass): now = dt_util.utcnow() - unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(1)) - unsub_utc = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), second=[0, 30] - ) + time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 11, 59, 55) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999)) + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(x)) + unsub_utc = async_track_utc_time_change( + hass, lambda x: specific_runs.append(x), second=[0, 30] + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 15, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 15, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 assert len(wildcard_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -773,7 +784,9 @@ async def test_async_track_time_change(hass): unsub() unsub_utc() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 30, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 assert len(wildcard_runs) == 3 @@ -785,25 +798,38 @@ async def test_periodic_task_minute(hass): now = dt_util.utcnow() - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), minute="/5", second=0 + time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 11, 59, 55) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, lambda x: specific_runs.append(x), minute="/5", second=0 + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) ) - - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 3, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 3, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 12, 5, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -814,33 +840,50 @@ async def test_periodic_task_hour(hass): now = dt_util.utcnow() - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 + time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 21, 59, 55) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, lambda x: specific_runs.append(x), hour="/2", minute=0, second=0 + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) ) - - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 1, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 1, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 3 unsub() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 3 @@ -853,10 +896,12 @@ async def test_periodic_task_wrong_input(hass): with pytest.raises(ValueError): async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/two" + hass, lambda x: specific_runs.append(x), hour="/two" ) - async_fire_time_changed(hass, datetime(now.year + 1, 5, 2, 0, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 2, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 0 @@ -867,37 +912,54 @@ async def test_periodic_task_clock_rollback(hass): now = dt_util.utcnow() - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 + time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 21, 59, 55) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, lambda x: specific_runs.append(x), hour="/2", minute=0, second=0 + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) ) - - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) - await hass.async_block_till_done() - assert len(specific_runs) == 1 - - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999), fire_all=True + hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + ) + await hass.async_block_till_done() + assert len(specific_runs) == 1 + + async_fire_time_changed( + hass, + datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 2 async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 0, 0, 0, 999999), fire_all=True + hass, + datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 3 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 4 unsub() - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 4 @@ -908,19 +970,30 @@ async def test_periodic_task_duplicate_time(hass): now = dt_util.utcnow() - unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(1), hour="/2", minute=0, second=0 + time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 21, 59, 55) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + unsub = async_track_utc_time_change( + hass, lambda x: specific_runs.append(x), hour="/2", minute=0, second=0 + ) + + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) ) - - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed(hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999)) + async_fire_time_changed( + hass, datetime(now.year + 1, 5, 25, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + ) await hass.async_block_till_done() assert len(specific_runs) == 2 @@ -942,7 +1015,7 @@ async def test_periodic_task_entering_dst(hass): "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_time_change( - hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + hass, lambda x: specific_runs.append(x), hour=2, minute=30, second=0 ) async_fire_time_changed( @@ -988,7 +1061,7 @@ async def test_periodic_task_leaving_dst(hass): "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_time_change( - hass, lambda x: specific_runs.append(1), hour=2, minute=30, second=0 + hass, lambda x: specific_runs.append(x), hour=2, minute=30, second=0 ) async_fire_time_changed( From 7599997a4693b2963ccf7f24888137796dba6233 Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Sat, 25 Jul 2020 14:20:29 +1000 Subject: [PATCH 135/362] Enable Homekit remote support for devices without play/pause (#37180) * Enable Homekit remote support for devices without play/pause * linting * Update tests * code review * code review --- .../components/homekit/type_media_players.py | 17 +++++++---------- .../homekit/test_type_media_players.py | 7 ++++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index dca75ee83fb..91cdd25ee42 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -260,13 +260,11 @@ class TelevisionMediaPlayer(HomeAccessory): self.sources = [] - # Add additional characteristics if volume or input selection supported - self.chars_tv = [] + self.chars_tv = [CHAR_REMOTE_KEY] self.chars_speaker = [] features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & (SUPPORT_PLAY | SUPPORT_PAUSE): - self.chars_tv.append(CHAR_REMOTE_KEY) + self._supports_play_pause = features & (SUPPORT_PLAY | SUPPORT_PAUSE) if features & SUPPORT_VOLUME_MUTE or features & SUPPORT_VOLUME_STEP: self.chars_speaker.extend( (CHAR_NAME, CHAR_ACTIVE, CHAR_VOLUME_CONTROL_TYPE, CHAR_VOLUME_SELECTOR) @@ -285,10 +283,9 @@ class TelevisionMediaPlayer(HomeAccessory): CHAR_ACTIVE, setter_callback=self.set_on_off ) - if CHAR_REMOTE_KEY in self.chars_tv: - self.char_remote_key = serv_tv.configure_char( - CHAR_REMOTE_KEY, setter_callback=self.set_remote_key - ) + self.char_remote_key = serv_tv.configure_char( + CHAR_REMOTE_KEY, setter_callback=self.set_remote_key + ) if CHAR_VOLUME_SELECTOR in self.chars_speaker: serv_speaker = self.add_preload_service( @@ -382,7 +379,7 @@ class TelevisionMediaPlayer(HomeAccessory): _LOGGER.warning("%s: Unhandled key press for %s", self.entity_id, value) return - if key_name == KEY_PLAY_PAUSE: + if key_name == KEY_PLAY_PAUSE and self._supports_play_pause: # Handle Play Pause by directly updating the media player entity. state = self.hass.states.get(self.entity_id).state if state in (STATE_PLAYING, STATE_PAUSED): @@ -394,7 +391,7 @@ class TelevisionMediaPlayer(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} self.call_service(DOMAIN, service, params) else: - # Other keys can be handled by listening to the event bus + # Unhandled keys can be handled by listening to the event bus self.hass.bus.fire( EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, {ATTR_KEY_NAME: key_name, ATTR_ENTITY_ID: self.entity_id}, diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index e4842b93125..9516963a982 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -3,6 +3,7 @@ from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + CHAR_REMOTE_KEY, CONF_FEATURE_LIST, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, FEATURE_ON_OFF, @@ -377,7 +378,7 @@ async def test_media_player_television_basic(hass, hk_driver, events, caplog): await acc.run_handler() await hass.async_block_till_done() - assert acc.chars_tv == [] + assert acc.chars_tv == [CHAR_REMOTE_KEY] assert acc.chars_speaker == [] assert acc.support_select_source is False @@ -448,7 +449,7 @@ async def test_tv_restore(hass, hk_driver, events): hass, hk_driver, "MediaPlayer", "media_player.simple", 2, None ) assert acc.category == 31 - assert acc.chars_tv == [] + assert acc.chars_tv == [CHAR_REMOTE_KEY] assert acc.chars_speaker == [] assert acc.support_select_source is False assert not hasattr(acc, "char_input_source") @@ -457,7 +458,7 @@ async def test_tv_restore(hass, hk_driver, events): hass, hk_driver, "MediaPlayer", "media_player.all_info_set", 2, None ) assert acc.category == 31 - assert acc.chars_tv == ["RemoteKey"] + assert acc.chars_tv == [CHAR_REMOTE_KEY] assert acc.chars_speaker == [ "Name", "Active", From 973688d87e19e9e553a42da89bd4de26a6fe91ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jul 2020 21:00:08 -1000 Subject: [PATCH 136/362] Bump netdisco to 2.8.1 (#38173) * Bump netdisco to 2.8.1 * bump ssdp --- homeassistant/components/discovery/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json index 962ba9b8e8c..232237484d1 100644 --- a/homeassistant/components/discovery/manifest.json +++ b/homeassistant/components/discovery/manifest.json @@ -2,7 +2,7 @@ "domain": "discovery", "name": "Discovery", "documentation": "https://www.home-assistant.io/integrations/discovery", - "requirements": ["netdisco==2.8.0"], + "requirements": ["netdisco==2.8.1"], "after_dependencies": ["zeroconf"], "codeowners": [], "quality_scale": "internal" diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 3dde2e9002e..85ae4725e93 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["defusedxml==0.6.0", "netdisco==2.8.0"], + "requirements": ["defusedxml==0.6.0", "netdisco==2.8.1"], "after_dependencies": ["zeroconf"], "codeowners": [] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 595af35f9da..41bb086bfbd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ hass-nabucasa==0.34.7 home-assistant-frontend==20200716.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 -netdisco==2.8.0 +netdisco==2.8.1 paho-mqtt==1.5.0 pip>=8.0.3 python-slugify==4.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index b68e46db63a..25569ee1a45 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -942,7 +942,7 @@ netdata==0.2.0 # homeassistant.components.discovery # homeassistant.components.ssdp -netdisco==2.8.0 +netdisco==2.8.1 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbd1eac938b..24a14ddfc70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ nessclient==0.9.15 # homeassistant.components.discovery # homeassistant.components.ssdp -netdisco==2.8.0 +netdisco==2.8.1 # homeassistant.components.nexia nexia==0.9.3 From a07f4e0986aa9d5d89aa7c7b8fde355317eebcbb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jul 2020 23:13:47 -1000 Subject: [PATCH 137/362] Ignore harmony hubs ips that are already configured during ssdp discovery (#38181) We would connect to the hub via discovery and via setup around the same time. This put additional load on the hub which can increase the risk of timeouts. --- .../components/harmony/config_flow.py | 10 ++++++++ tests/components/harmony/test_config_flow.py | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 576451ef2d6..9142eed2dba 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -86,6 +86,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): parsed_url = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]) friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] + if self._host_already_configured(parsed_url.hostname): + return self.async_abort(reason="already_configured") + # pylint: disable=no-member self.context["title_placeholders"] = {"name": friendly_name} @@ -158,6 +161,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=validated[CONF_NAME], data=data) + def _host_already_configured(self, host): + """See if we already have a harmony entry matching the host.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == host: + return True + return False + def _options_from_user_input(user_input): options = {} diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index 0228983ef9d..d159a0f025f 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -145,6 +145,30 @@ async def test_form_ssdp(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known(hass): + """Test we abort without connecting if the host is already known.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, data={"host": "2.2.2.2", "name": "any"}, + ) + config_entry.add_to_hass(hass) + + harmonyapi = _get_mock_harmonyapi(connect=True) + + with patch( + "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + "friendlyName": "Harmony Hub", + "ssdp_location": "http://2.2.2.2:8088/description", + }, + ) + assert result["type"] == "abort" + + async def test_form_cannot_connect(hass): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From 662d79eb86301d076a0818be080998969582c60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 25 Jul 2020 11:18:30 +0200 Subject: [PATCH 138/362] Prevent unnecessary updates of met component (#38168) --- homeassistant/components/met/weather.py | 33 +++++++++++-------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 6523efa0eb7..7f71fbe07eb 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -19,7 +19,6 @@ from homeassistant.const import ( PRESSURE_INHG, TEMP_CELSIUS, ) -from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_call_later @@ -82,27 +81,25 @@ class MetWeather(WeatherEntity): self._unsub_fetch_data = None self._weather_data = None self._current_weather_data = {} + self._coordinates = {} self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" - self._init_data() - await self._fetch_data() + await self._init_data() if self._config.get(CONF_TRACK_HOME): self._unsub_track_home = self.hass.bus.async_listen( - EVENT_CORE_CONFIG_UPDATE, self._core_config_updated + EVENT_CORE_CONFIG_UPDATE, self._init_data ) - @callback - def _init_data(self): - """Initialize a data object.""" - conf = self._config - + async def _init_data(self, _event=None): + """Initialize and fetch data object.""" if self.track_home: latitude = self.hass.config.latitude longitude = self.hass.config.longitude elevation = self.hass.config.elevation else: + conf = self._config latitude = conf[CONF_LATITUDE] longitude = conf[CONF_LONGITUDE] elevation = conf[CONF_ELEVATION] @@ -116,16 +113,13 @@ class MetWeather(WeatherEntity): "lon": str(longitude), "msl": str(elevation), } + if coordinates == self._coordinates: + return + self._coordinates = coordinates + self._weather_data = metno.MetWeatherData( coordinates, async_get_clientsession(self.hass), URL ) - - async def _core_config_updated(self, _event): - """Handle core config updated.""" - self._init_data() - if self._unsub_fetch_data: - self._unsub_fetch_data() - self._unsub_fetch_data = None await self._fetch_data() async def will_remove_from_hass(self): @@ -140,6 +134,10 @@ class MetWeather(WeatherEntity): async def _fetch_data(self, *_): """Get the latest data from met.no.""" + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None + if not await self._weather_data.fetching_data(): # Retry in 15 to 20 minutes. minutes = 15 + randrange(6) @@ -155,10 +153,7 @@ class MetWeather(WeatherEntity): self._unsub_fetch_data = async_call_later( self.hass, randrange(55, 65) * 60, self._fetch_data ) - self._update() - def _update(self, *_): - """Get the latest data from Met.no.""" self._current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE self._forecast_data = self._weather_data.get_forecast(time_zone) From bbc8748e3b6a426eff7938fd911ffa0ddb68eaf7 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 25 Jul 2020 05:19:55 -0500 Subject: [PATCH 139/362] Stop automation runs when turned off or reloaded (#38174) * Add automation turn off / reload test * Stop automation runs when turned off or reloaded --- .../components/automation/__init__.py | 2 + tests/components/automation/test_init.py | 57 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 599160534aa..0a2cd9c3b51 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -467,6 +467,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._async_detach_triggers() self._async_detach_triggers = None + await self.action_script.async_stop() + self.async_write_ha_state() async def _async_attach_triggers( diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 909493ddf53..bdae9a1f326 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,4 +1,6 @@ """The tests for the automation component.""" +import asyncio + import pytest from homeassistant.components import logbook @@ -12,10 +14,11 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, EVENT_HOMEASSISTANT_STARTED, + SERVICE_TURN_OFF, STATE_OFF, STATE_ON, ) -from homeassistant.core import Context, CoreState, State +from homeassistant.core import Context, CoreState, State, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -553,6 +556,58 @@ async def test_reload_config_handles_load_fails(hass, calls): assert len(calls) == 2 +@pytest.mark.parametrize("service", ["turn_off", "reload"]) +async def test_automation_stops(hass, calls, service): + """Test that turning off / reloading an automation stops any running actions.""" + entity_id = "automation.hello" + test_entity = "test.entity" + + config = { + automation.DOMAIN: { + "alias": "hello", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + {"event": "running"}, + {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, + {"service": "test.automation"}, + ], + } + } + assert await async_setup_component(hass, automation.DOMAIN, config,) + + running = asyncio.Event() + + @callback + def running_cb(event): + running.set() + + hass.bus.async_listen_once("running", running_cb) + hass.states.async_set(test_entity, "hello") + + hass.bus.async_fire("test_event") + await running.wait() + + if service == "turn_off": + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + else: + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=config, + ): + await common.async_reload(hass) + + hass.states.async_set(test_entity, "goodbye") + await hass.async_block_till_done() + + assert len(calls) == 0 + + async def test_automation_restore_state(hass): """Ensure states are restored on startup.""" time = dt_util.utcnow() From 3206f4dc8344beac9e485004769564f8680f23e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2020 07:12:14 -1000 Subject: [PATCH 140/362] Support multiple camera streams in HomeKit (#37968) * Support multiple camera stream in HomeKit * Update homeassistant/components/homekit/type_cameras.py Co-authored-by: Paulus Schoutsen * Revert "Update homeassistant/components/homekit/type_cameras.py" This reverts commit d7624c5bffcd9d6cecd9e096ea7ae34b29c21e74. * Update homeassistant/components/homekit/type_cameras.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/homekit/type_cameras.py Co-authored-by: Paulus Schoutsen * black * bump pyhap Co-authored-by: Paulus Schoutsen --- homeassistant/components/homekit/const.py | 2 + .../components/homekit/manifest.json | 2 +- .../components/homekit/type_cameras.py | 53 ++++++++----------- homeassistant/components/homekit/util.py | 5 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 32 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ead5179b5dc..b32d7f4bad4 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -55,6 +55,7 @@ CONF_SUPPORT_AUDIO = "support_audio" CONF_VIDEO_CODEC = "video_codec" CONF_VIDEO_MAP = "video_map" CONF_VIDEO_PACKET_SIZE = "video_packet_size" +CONF_STREAM_COUNT = "stream_count" # #### Config Defaults #### DEFAULT_SUPPORT_AUDIO = False @@ -72,6 +73,7 @@ DEFAULT_SAFE_MODE = False DEFAULT_VIDEO_CODEC = VIDEO_CODEC_LIBX264 DEFAULT_VIDEO_MAP = "0:v:0" DEFAULT_VIDEO_PACKET_SIZE = 1316 +DEFAULT_STREAM_COUNT = 3 # #### Features #### FEATURE_ON_OFF = "on_off" diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 916a8cbde76..87ef0dc5ec8 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==2.9.2", + "HAP-python==3.0.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 9cfacc9866d..629e1019f4a 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -5,7 +5,6 @@ import logging from haffmpeg.core import HAFFmpeg from pyhap.camera import ( - STREAMING_STATUS, VIDEO_CODEC_PARAM_LEVEL_TYPES, VIDEO_CODEC_PARAM_PROFILE_ID_TYPES, Camera as PyhapCamera, @@ -24,7 +23,6 @@ from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( CHAR_MOTION_DETECTED, - CHAR_STREAMING_STRATUS, CONF_AUDIO_CODEC, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, @@ -33,6 +31,7 @@ from .const import ( CONF_MAX_HEIGHT, CONF_MAX_WIDTH, CONF_STREAM_ADDRESS, + CONF_STREAM_COUNT, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, @@ -44,11 +43,11 @@ from .const import ( DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, + DEFAULT_STREAM_COUNT, DEFAULT_SUPPORT_AUDIO, DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, - SERV_CAMERA_RTP_STREAM_MANAGEMENT, SERV_MOTION_SENSOR, ) from .img_util import scale_jpeg_camera_image @@ -121,6 +120,7 @@ CONFIG_DEFAULTS = { CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, + CONF_STREAM_COUNT: DEFAULT_STREAM_COUNT, } @@ -131,7 +131,6 @@ class Camera(HomeAccessory, PyhapCamera): def __init__(self, hass, driver, name, entity_id, aid, config): """Initialize a Camera accessory object.""" self._ffmpeg = hass.data[DATA_FFMPEG] - self._cur_session = None for config_key in CONFIG_DEFAULTS: if config_key not in config: config[config_key] = CONFIG_DEFAULTS[config_key] @@ -178,6 +177,7 @@ class Camera(HomeAccessory, PyhapCamera): "audio": audio_options, "address": stream_address, "srtp": True, + "stream_count": config[CONF_STREAM_COUNT], } super().__init__( @@ -313,51 +313,42 @@ class Camera(HomeAccessory, PyhapCamera): if not opened: _LOGGER.error("Failed to open ffmpeg stream") return False - session_info["stream"] = stream + _LOGGER.info( "[%s] Started stream process - PID %d", session_info["id"], stream.process.pid, ) - ffmpeg_watcher = async_track_time_interval( - self.hass, self._async_ffmpeg_watch, FFMPEG_WATCH_INTERVAL + session_info["stream"] = stream + session_info[FFMPEG_PID] = stream.process.pid + + async def watch_session(_): + await self._async_ffmpeg_watch(session_info["id"]) + + session_info[FFMPEG_WATCHER] = async_track_time_interval( + self.hass, watch_session, FFMPEG_WATCH_INTERVAL, ) - self._cur_session = { - FFMPEG_WATCHER: ffmpeg_watcher, - FFMPEG_PID: stream.process.pid, - SESSION_ID: session_info["id"], - } - return await self._async_ffmpeg_watch(0) + return await self._async_ffmpeg_watch(session_info["id"]) - async def _async_ffmpeg_watch(self, _): + async def _async_ffmpeg_watch(self, session_id): """Check to make sure ffmpeg is still running and cleanup if not.""" - ffmpeg_pid = self._cur_session[FFMPEG_PID] - session_id = self._cur_session[SESSION_ID] + ffmpeg_pid = self.sessions[session_id][FFMPEG_PID] if pid_is_alive(ffmpeg_pid): return True _LOGGER.warning("Streaming process ended unexpectedly - PID %d", ffmpeg_pid) - self._async_stop_ffmpeg_watch() - self._async_set_streaming_available(session_id) + self._async_stop_ffmpeg_watch(session_id) + self.set_streaming_available(self.sessions[session_id]["stream_idx"]) return False @callback - def _async_stop_ffmpeg_watch(self): + def _async_stop_ffmpeg_watch(self, session_id): """Cleanup a streaming session after stopping.""" - if not self._cur_session: + if FFMPEG_WATCHER not in self.sessions[session_id]: return - self._cur_session[FFMPEG_WATCHER]() - self._cur_session = None - - @callback - def _async_set_streaming_available(self, session_id): - """Free the session so they can start another.""" - self.streaming_status = STREAMING_STATUS["AVAILABLE"] - self.get_service(SERV_CAMERA_RTP_STREAM_MANAGEMENT).get_characteristic( - CHAR_STREAMING_STRATUS - ).notify() + self.sessions[session_id].pop(FFMPEG_WATCHER)() async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" @@ -367,7 +358,7 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.debug("No stream for session ID %s", session_id) return - self._async_stop_ffmpeg_watch() + self._async_stop_ffmpeg_watch(session_id) if not pid_is_alive(stream.process.pid): _LOGGER.info("[%s] Stream already stopped", session_id) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index c79b97adb87..449d2506d04 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -41,6 +41,7 @@ from .const import ( CONF_MAX_HEIGHT, CONF_MAX_WIDTH, CONF_STREAM_ADDRESS, + CONF_STREAM_COUNT, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, @@ -53,6 +54,7 @@ from .const import ( DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, + DEFAULT_STREAM_COUNT, DEFAULT_SUPPORT_AUDIO, DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, @@ -112,6 +114,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( vol.Optional(CONF_MAX_FPS, default=DEFAULT_MAX_FPS): cv.positive_int, vol.Optional(CONF_AUDIO_MAP, default=DEFAULT_AUDIO_MAP): cv.string, vol.Optional(CONF_VIDEO_MAP, default=DEFAULT_VIDEO_MAP): cv.string, + vol.Optional(CONF_STREAM_COUNT, default=DEFAULT_STREAM_COUNT): vol.All( + vol.Coerce(int), vol.Range(min=1, max=10) + ), vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In( VALID_VIDEO_CODECS ), diff --git a/requirements_all.txt b/requirements_all.txt index 25569ee1a45..e49abe8b6f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -18,7 +18,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==2.9.2 +HAP-python==3.0.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24a14ddfc70..1e235e38aba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -5,7 +5,7 @@ -r requirements_test.txt # homeassistant.components.homekit -HAP-python==2.9.2 +HAP-python==3.0.0 # homeassistant.components.plugwise Plugwise_Smile==1.1.0 From 1776540757544981ddb3457a6166681777c3a163 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 25 Jul 2020 19:13:10 +0200 Subject: [PATCH 141/362] Rfxtrx fixup config entry creation (#38185) * Make sure import flow completely replace existing config * Make sure added device contain correct config data * Revert change to directly run init --- homeassistant/components/rfxtrx/__init__.py | 7 +++++-- homeassistant/components/rfxtrx/config_flow.py | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 3bb8a753aa9..b1b196dbfba 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -280,11 +280,14 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry): @callback def _add_device(event, device_id): """Add a device to config entry.""" + config = DEVICE_DATA_SCHEMA({}) + config[CONF_DEVICE_ID] = device_id + data = entry.data.copy() event_code = binascii.hexlify(event.data).decode("ASCII") - data[CONF_DEVICES][event_code] = device_id + data[CONF_DEVICES][event_code] = config hass.config_entries.async_update_entry(entry=entry, data=data) - devices[device_id] = {} + devices[device_id] = config @callback def _start_rfxtrx(event): diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 0cdaa8146ec..287e1ec4baf 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -16,6 +16,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config=None): """Handle the initial step.""" - await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured(import_config) + entry = await self.async_set_unique_id(DOMAIN) + if entry and import_config.items() != entry.data.items(): + self.hass.config_entries.async_update_entry(entry, data=import_config) + return self.async_abort(reason="already_configured") return self.async_create_entry(title="RFXTRX", data=import_config) From da380d89c21b87a1e40e97933cb6fd450135a507 Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Sat, 25 Jul 2020 12:43:45 -0700 Subject: [PATCH 142/362] Removing gogogate2 emulated cover transitional states. (#38199) --- homeassistant/components/gogogate2/cover.py | 35 +-------------------- tests/components/gogogate2/test_cover.py | 34 +++----------------- 2 files changed, 6 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 05fed7621d4..a26bdbc3c8e 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,5 +1,4 @@ """Support for Gogogate2 garage Doors.""" -from datetime import datetime, timedelta import logging from typing import Callable, List, Optional @@ -13,13 +12,7 @@ from homeassistant.components.cover import ( CoverEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_USERNAME, - STATE_CLOSING, - STATE_OPENING, -) +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -86,8 +79,6 @@ class Gogogate2Cover(CoverEntity): self._api = data_update_coordinator.api self._unique_id = cover_unique_id(config_entry, door) self._is_available = True - self._transition_state: Optional[str] = None - self._transition_state_start: Optional[datetime] = None @property def available(self) -> bool: @@ -119,16 +110,6 @@ class Gogogate2Cover(CoverEntity): return None - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self._transition_state == STATE_OPENING - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self._transition_state == STATE_CLOSING - @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" @@ -142,14 +123,10 @@ class Gogogate2Cover(CoverEntity): async def async_open_cover(self, **kwargs): """Open the door.""" await self.hass.async_add_executor_job(self._api.open_door, self._door.door_id) - self._transition_state = STATE_OPENING - self._transition_state_start = datetime.now() async def async_close_cover(self, **kwargs): """Close the door.""" await self.hass.async_add_executor_job(self._api.close_door, self._door.door_id) - self._transition_state = STATE_CLOSING - self._transition_state_start = datetime.now() @property def state_attributes(self): @@ -168,16 +145,6 @@ class Gogogate2Cover(CoverEntity): door = get_door_by_id(self._door.door_id, self._data_update_coordinator.data) - # Check if the transition state should expire. - if self._transition_state: - is_transition_state_expired = ( - datetime.now() - self._transition_state_start - ) > timedelta(seconds=60) - - if is_transition_state_expired or self._door.status != door.status: - self._transition_state = None - self._transition_state_start = None - # Set the state. self._door = door self._is_available = True diff --git a/tests/components/gogogate2/test_cover.py b/tests/components/gogogate2/test_cover.py index 8cffec47e65..5bc9ed9ebd4 100644 --- a/tests/components/gogogate2/test_cover.py +++ b/tests/components/gogogate2/test_cover.py @@ -1,7 +1,4 @@ """Tests for the GogoGate2 component.""" -from datetime import datetime, timedelta -from unittest.mock import MagicMock, patch - from gogogate2_api import GogoGate2Api from gogogate2_api.common import ( ActivateResponse, @@ -24,15 +21,15 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_USERNAME, STATE_CLOSED, - STATE_CLOSING, STATE_OPEN, - STATE_OPENING, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from .common import ComponentFactory +from tests.async_mock import MagicMock + async def test_import_fail( hass: HomeAssistant, component_factory: ComponentFactory @@ -405,11 +402,6 @@ async def test_open_close( ) await hass.async_block_till_done() component_data.api.close_door.assert_called_with(1) - await hass.services.async_call( - HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_CLOSING component_data.data_update_coordinator.api.info.return_value = closed_door_response await component_data.data_update_coordinator.async_refresh() @@ -422,35 +414,19 @@ async def test_open_close( ) await hass.async_block_till_done() component_data.api.open_door.assert_called_with(1) - await hass.services.async_call( - HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_OPENING # Assert the mid state does not change when the same status is returned. component_data.data_update_coordinator.api.info.return_value = closed_door_response await component_data.data_update_coordinator.async_refresh() component_data.data_update_coordinator.api.info.return_value = closed_door_response + await component_data.data_update_coordinator.async_refresh() + await component_data.data_update_coordinator.async_refresh() await hass.services.async_call( HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, ) await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_OPENING - - # Assert the mid state times out. - with patch("homeassistant.components.gogogate2.cover.datetime") as datetime_mock: - datetime_mock.now.return_value = datetime.now() + timedelta(seconds=60.1) - component_data.data_update_coordinator.api.info.return_value = ( - closed_door_response - ) - await component_data.data_update_coordinator.async_refresh() - await hass.services.async_call( - HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"}, - ) - await hass.async_block_till_done() - assert hass.states.get("cover.door1").state == STATE_CLOSED + assert hass.states.get("cover.door1").state == STATE_CLOSED async def test_availability( From 85c856cfa388e00c51f4050648183947f49f748e Mon Sep 17 00:00:00 2001 From: Emil Stjerneman Date: Sat, 25 Jul 2020 21:48:19 +0200 Subject: [PATCH 143/362] Volvo on call updates (#38142) * Add "doors_tailgate_open" and "average_speed" to resource list * Bump volvooncall from 0.8.7 to 0.8.12 * Bump volvooncall in requirements_all.txt --- homeassistant/components/volvooncall/__init__.py | 2 ++ homeassistant/components/volvooncall/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 232ecf477f7..7b7dffbef18 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -54,6 +54,7 @@ RESOURCES = [ "odometer", "trip_meter1", "trip_meter2", + "average_speed", "fuel_amount", "fuel_amount_level", "average_fuel_consumption", @@ -70,6 +71,7 @@ RESOURCES = [ "last_trip", "is_engine_running", "doors_hood_open", + "doors_tailgate_open", "doors_front_left_door_open", "doors_front_right_door_open", "doors_rear_left_door_open", diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index c16ad0e4858..822e7eef5a8 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -2,6 +2,6 @@ "domain": "volvooncall", "name": "Volvo On Call", "documentation": "https://www.home-assistant.io/integrations/volvooncall", - "requirements": ["volvooncall==0.8.7"], + "requirements": ["volvooncall==0.8.12"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index e49abe8b6f0..8f9d85e1524 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2174,7 +2174,7 @@ vilfo-api-client==0.3.2 volkszaehler==0.1.2 # homeassistant.components.volvooncall -volvooncall==0.8.7 +volvooncall==0.8.12 # homeassistant.components.verisure vsure==1.5.4 From fd11748a1a6c7535f710b415b527032d5b4e1d57 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 25 Jul 2020 22:56:58 +0200 Subject: [PATCH 144/362] Make rfxtrx RfyDevices have sun automation switches (#38210) * RfyDevices have sun automation * We must accept sun automation commands for switch * Add test for Rfy sun automation --- homeassistant/components/rfxtrx/const.py | 2 ++ homeassistant/components/rfxtrx/switch.py | 1 + tests/components/rfxtrx/test_switch.py | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index cd3c4b9a62e..c0436bfcf60 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -7,12 +7,14 @@ COMMAND_ON_LIST = [ "Stop", "Open (inline relay)", "Stop (inline relay)", + "Enable sun automation", ] COMMAND_OFF_LIST = [ "Off", "Down", "Close (inline relay)", + "Disable sun automation", ] ATTR_EVENT = "event" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 5f99c0761f0..6f3cfa5f773 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -37,6 +37,7 @@ async def async_setup_entry( isinstance(event.device, rfxtrxmod.LightingDevice) and not event.device.known_to_be_dimmable and not event.device.known_to_be_rollershutter + or isinstance(event.device, rfxtrxmod.RfyDevice) ) # Add switch from config file diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index c163401142e..3256f303708 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -8,6 +8,9 @@ from homeassistant.setup import async_setup_component from tests.common import mock_restore_cache +EVENT_RFY_ENABLE_SUN_AUTO = "081a00000301010113" +EVENT_RFY_DISABLE_SUN_AUTO = "081a00000301010114" + async def test_one_switch(hass, rfxtrx): """Test with 1 switch.""" @@ -133,3 +136,18 @@ async def test_discover_switch(hass, rfxtrx_automatic): state = hass.states.get("switch.ac_118cdeb_2") assert state assert state.state == "on" + + +async def test_discover_rfy_sun_switch(hass, rfxtrx_automatic): + """Test with discovery of switches.""" + rfxtrx = rfxtrx_automatic + + await rfxtrx.signal(EVENT_RFY_DISABLE_SUN_AUTO) + state = hass.states.get("switch.rfy_030101_1") + assert state + assert state.state == "off" + + await rfxtrx.signal(EVENT_RFY_ENABLE_SUN_AUTO) + state = hass.states.get("switch.rfy_030101_1") + assert state + assert state.state == "on" From dcba45e67d2920fa531ef3521e8d0fa1ddf3dd97 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sat, 25 Jul 2020 23:04:10 +0100 Subject: [PATCH 145/362] Add Azure DevOps Integration (#33765) Co-authored-by: Martin Hjelmare --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/azure_devops/__init__.py | 121 +++++++++ .../components/azure_devops/config_flow.py | 134 +++++++++ .../components/azure_devops/const.py | 11 + .../components/azure_devops/manifest.json | 8 + .../components/azure_devops/sensor.py | 148 ++++++++++ .../components/azure_devops/strings.json | 33 +++ .../azure_devops/translations/en.json | 33 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/azure_devops/__init__.py | 1 + .../azure_devops/test_config_flow.py | 257 ++++++++++++++++++ 14 files changed, 757 insertions(+) create mode 100644 homeassistant/components/azure_devops/__init__.py create mode 100644 homeassistant/components/azure_devops/config_flow.py create mode 100644 homeassistant/components/azure_devops/const.py create mode 100644 homeassistant/components/azure_devops/manifest.json create mode 100644 homeassistant/components/azure_devops/sensor.py create mode 100644 homeassistant/components/azure_devops/strings.json create mode 100644 homeassistant/components/azure_devops/translations/en.json create mode 100644 tests/components/azure_devops/__init__.py create mode 100644 tests/components/azure_devops/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 7384ee23245..eb24287069e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -72,6 +72,9 @@ omit = homeassistant/components/avion/light.py homeassistant/components/avri/const.py homeassistant/components/avri/sensor.py + homeassistant/components/azure_devops/__init__.py + homeassistant/components/azure_devops/const.py + homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py homeassistant/components/beewi_smartclim/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 3b17bca9595..f488eace2bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,7 @@ homeassistant/components/avri/* @timvancann homeassistant/components/awair/* @ahayworth @danielsjf homeassistant/components/aws/* @awarecan homeassistant/components/axis/* @Kane610 +homeassistant/components/azure_devops/* @timmo001 homeassistant/components/azure_event_hub/* @eavanvalkenburg homeassistant/components/azure_service_bus/* @hfurubotten homeassistant/components/beewi_smartclim/* @alemuro diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py new file mode 100644 index 00000000000..00f08496dd3 --- /dev/null +++ b/homeassistant/components/azure_devops/__init__.py @@ -0,0 +1,121 @@ +"""Support for Azure DevOps.""" +import logging +from typing import Any, Dict + +from aioazuredevops.client import DevOpsClient +import aiohttp + +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DATA_AZURE_DEVOPS_CLIENT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Azure DevOps components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up Azure DevOps from a config entry.""" + client = DevOpsClient() + + try: + if entry.data[CONF_PAT] is not None: + await client.authorize(entry.data[CONF_PAT], entry.data[CONF_ORG]) + if not client.authorized: + _LOGGER.warning( + "Could not authorize with Azure DevOps. You may need to update your token" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=entry.data, + ) + ) + return False + await client.get_project(entry.data[CONF_ORG], entry.data[CONF_PROJECT]) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise ConfigEntryNotReady from exception + + instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" + hass.data.setdefault(instance_key, {})[DATA_AZURE_DEVOPS_CLIENT] = client + + # Setup components + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload Azure DevOps config entry.""" + del hass.data[f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}"] + + return await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + +class AzureDevOpsEntity(Entity): + """Defines a base Azure DevOps entity.""" + + def __init__(self, organization: str, project: str, name: str, icon: str) -> None: + """Initialize the Azure DevOps entity.""" + self._name = name + self._icon = icon + self._available = True + self.organization = organization + self.project = project + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update Azure DevOps entity.""" + if await self._azure_devops_update(): + self._available = True + else: + if self._available: + _LOGGER.debug( + "An error occurred while updating Azure DevOps sensor.", + exc_info=True, + ) + self._available = False + + async def _azure_devops_update(self) -> None: + """Update Azure DevOps entity.""" + raise NotImplementedError() + + +class AzureDevOpsDeviceEntity(AzureDevOpsEntity): + """Defines a Azure DevOps device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this Azure DevOps instance.""" + return { + "identifiers": {(DOMAIN, self.organization, self.project,)}, + "manufacturer": self.organization, + "name": self.project, + } diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py new file mode 100644 index 00000000000..69030871b8d --- /dev/null +++ b/homeassistant/components/azure_devops/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow to configure the Azure DevOps integration.""" +import logging + +from aioazuredevops.client import DevOpsClient +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.azure_devops.const import ( # pylint:disable=unused-import + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DOMAIN, +) +from homeassistant.config_entries import ConfigFlow + +_LOGGER = logging.getLogger(__name__) + + +class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Azure DevOps config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize config flow.""" + self._organization = None + self._project = None + self._pat = None + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ORG, default=self._organization): str, + vol.Required(CONF_PROJECT, default=self._project): str, + vol.Optional(CONF_PAT): str, + } + ), + errors=errors or {}, + ) + + async def _show_reauth_form(self, errors=None): + """Show the reauth form to the user.""" + return self.async_show_form( + step_id="reauth", + description_placeholders={ + "project_url": f"{self._organization}/{self._project}" + }, + data_schema=vol.Schema({vol.Required(CONF_PAT): str}), + errors=errors or {}, + ) + + async def _check_setup(self): + """Check the setup of the flow.""" + errors = {} + + client = DevOpsClient() + + try: + if self._pat is not None: + await client.authorize(self._pat, self._organization) + if not client.authorized: + errors["base"] = "authorization_error" + return errors + project_info = await client.get_project(self._organization, self._project) + if project_info is None: + errors["base"] = "project_error" + return errors + except aiohttp.ClientError: + errors["base"] = "connection_error" + return errors + return None + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form(user_input) + + self._organization = user_input[CONF_ORG] + self._project = user_input[CONF_PROJECT] + self._pat = user_input.get(CONF_PAT) + + await self.async_set_unique_id(f"{self._organization}_{self._project}") + self._abort_if_unique_id_configured() + + errors = await self._check_setup() + if errors is not None: + return await self._show_setup_form(errors) + return self._async_create_entry() + + async def async_step_reauth(self, user_input): + """Handle configuration by re-auth.""" + if user_input.get(CONF_ORG) and user_input.get(CONF_PROJECT): + self._organization = user_input[CONF_ORG] + self._project = user_input[CONF_PROJECT] + self._pat = user_input[CONF_PAT] + + # pylint: disable=no-member + self.context["title_placeholders"] = { + "project_url": f"{self._organization}/{self._project}", + } + + await self.async_set_unique_id(f"{self._organization}_{self._project}") + + errors = await self._check_setup() + if errors is not None: + return await self._show_reauth_form(errors) + + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, + data={ + CONF_ORG: self._organization, + CONF_PROJECT: self._project, + CONF_PAT: self._pat, + }, + ) + return self.async_abort(reason="reauth_successful") + + def _async_create_entry(self): + """Handle create entry.""" + return self.async_create_entry( + title=f"{self._organization}/{self._project}", + data={ + CONF_ORG: self._organization, + CONF_PROJECT: self._project, + CONF_PAT: self._pat, + }, + ) diff --git a/homeassistant/components/azure_devops/const.py b/homeassistant/components/azure_devops/const.py new file mode 100644 index 00000000000..40610ba7baa --- /dev/null +++ b/homeassistant/components/azure_devops/const.py @@ -0,0 +1,11 @@ +"""Constants for the Azure DevOps integration.""" +DOMAIN = "azure_devops" + +DATA_AZURE_DEVOPS_CLIENT = "azure_devops_client" +DATA_ORG = "organization" +DATA_PROJECT = "project" +DATA_PAT = "personal_access_token" + +CONF_ORG = "organization" +CONF_PROJECT = "project" +CONF_PAT = "personal_access_token" diff --git a/homeassistant/components/azure_devops/manifest.json b/homeassistant/components/azure_devops/manifest.json new file mode 100644 index 00000000000..be0d2fb0fbe --- /dev/null +++ b/homeassistant/components/azure_devops/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "azure_devops", + "name": "Azure DevOps", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/azure_devops", + "requirements": ["aioazuredevops==1.3.4"], + "codeowners": ["@timmo001"] +} diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py new file mode 100644 index 00000000000..6f259afb9a9 --- /dev/null +++ b/homeassistant/components/azure_devops/sensor.py @@ -0,0 +1,148 @@ +"""Support for Azure DevOps sensors.""" +from datetime import timedelta +import logging +from typing import List + +from aioazuredevops.builds import DevOpsBuild +from aioazuredevops.client import DevOpsClient +import aiohttp + +from homeassistant.components.azure_devops import AzureDevOpsDeviceEntity +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PROJECT, + DATA_AZURE_DEVOPS_CLIENT, + DATA_ORG, + DATA_PROJECT, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + +BUILDS_QUERY = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up Azure DevOps sensor based on a config entry.""" + instance_key = f"{DOMAIN}_{entry.data[CONF_ORG]}_{entry.data[CONF_PROJECT]}" + client = hass.data[instance_key][DATA_AZURE_DEVOPS_CLIENT] + organization = entry.data[DATA_ORG] + project = entry.data[DATA_PROJECT] + sensors = [] + + try: + builds: List[DevOpsBuild] = await client.get_builds( + organization, project, BUILDS_QUERY + ) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise PlatformNotReady from exception + + for build in builds: + sensors.append( + AzureDevOpsLatestBuildSensor(client, organization, project, build) + ) + + async_add_entities(sensors, True) + + +class AzureDevOpsSensor(AzureDevOpsDeviceEntity): + """Defines a Azure DevOps sensor.""" + + def __init__( + self, + client: DevOpsClient, + organization: str, + project: str, + key: str, + name: str, + icon: str, + measurement: str = "", + unit_of_measurement: str = "", + ) -> None: + """Initialize Azure DevOps sensor.""" + self._state = None + self._attributes = None + self._available = False + self._unit_of_measurement = unit_of_measurement + self.measurement = measurement + self.client = client + self.organization = organization + self.project = project + self.key = key + + super().__init__(organization, project, name, icon) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return "_".join([self.organization, self.key]) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + return self._attributes + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): + """Defines a Azure DevOps card count sensor.""" + + def __init__( + self, client: DevOpsClient, organization: str, project: str, build: DevOpsBuild + ): + """Initialize Azure DevOps sensor.""" + self.build: DevOpsBuild = build + super().__init__( + client, + organization, + project, + f"{build.project.id}_{build.definition.id}_latest_build", + f"{build.project.name} {build.definition.name} Latest Build", + "mdi:pipe", + ) + + async def _azure_devops_update(self) -> bool: + """Update Azure DevOps entity.""" + try: + build: DevOpsBuild = await self.client.get_build( + self.organization, self.project, self.build.id + ) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + self._available = False + return False + self._state = build.build_number + self._attributes = { + "definition_id": build.definition.id, + "definition_name": build.definition.name, + "id": build.id, + "reason": build.reason, + "result": build.result, + "source_branch": build.source_branch, + "source_version": build.source_version, + "status": build.status, + "url": build.links.web, + "queue_time": build.queue_time, + "start_time": build.start_time, + "finish_time": build.finish_time, + } + self._available = True + return True diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json new file mode 100644 index 00000000000..2bb53010153 --- /dev/null +++ b/homeassistant/components/azure_devops/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "Azure DevOps: {project_url}", + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." + }, + "step": { + "user": { + "data": { + "organization": "Organization", + "project": "Project", + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + }, + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" + } + }, + "title": "Azure DevOps" +} diff --git a/homeassistant/components/azure_devops/translations/en.json b/homeassistant/components/azure_devops/translations/en.json new file mode 100644 index 00000000000..2bb53010153 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "flow_title": "Azure DevOps: {project_url}", + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." + }, + "step": { + "user": { + "data": { + "organization": "Organization", + "project": "Project", + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + }, + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" + } + }, + "title": "Azure DevOps" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a686116229e..736f16e3581 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -22,6 +22,7 @@ FLOWS = [ "avri", "awair", "axis", + "azure_devops", "blebox", "blink", "bond", diff --git a/requirements_all.txt b/requirements_all.txt index 8f9d85e1524..dcf12d9b274 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,6 +147,9 @@ aioambient==1.1.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 +# homeassistant.components.azure_devops +aioazuredevops==1.3.4 + # homeassistant.components.aws aiobotocore==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e235e38aba..7d8bb75841e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -75,6 +75,9 @@ aioambient==1.1.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 +# homeassistant.components.azure_devops +aioazuredevops==1.3.4 + # homeassistant.components.aws aiobotocore==0.11.1 diff --git a/tests/components/azure_devops/__init__.py b/tests/components/azure_devops/__init__.py new file mode 100644 index 00000000000..da15bc6723d --- /dev/null +++ b/tests/components/azure_devops/__init__.py @@ -0,0 +1 @@ +"""Tests for the Azure DevOps integration.""" diff --git a/tests/components/azure_devops/test_config_flow.py b/tests/components/azure_devops/test_config_flow.py new file mode 100644 index 00000000000..b89c9cb69aa --- /dev/null +++ b/tests/components/azure_devops/test_config_flow.py @@ -0,0 +1,257 @@ +"""Test the Azure DevOps config flow.""" +from aioazuredevops.core import DevOpsProject +import aiohttp + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.azure_devops.const import ( + CONF_ORG, + CONF_PAT, + CONF_PROJECT, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +FIXTURE_REAUTH_INPUT = {CONF_PAT: "abc123"} +FIXTURE_USER_INPUT = {CONF_ORG: "random", CONF_PROJECT: "project", CONF_PAT: "abc123"} + +UNIQUE_ID = "random_project" + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps authorization error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_reauth_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps authorization error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_reauth_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_project_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps connection error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "project_error"} + + +async def test_reauth_project_error(hass: HomeAssistant) -> None: + """Test we show user form on Azure DevOps project error.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "reauth" + assert result2["errors"] == {"base": "project_error"} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test reauth works.""" + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + return_value=False, + ): + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data=FIXTURE_USER_INPUT + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + assert result["errors"] == {"base": "authorization_error"} + + with patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=DevOpsProject( + "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_full_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + with patch( + "homeassistant.components.azure_devops.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.azure_devops.async_setup_entry", return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized", + return_value=True, + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.authorize", + ), patch( + "homeassistant.components.azure_devops.config_flow.DevOpsClient.get_project", + return_value=DevOpsProject( + "abcd-abcd-abcd-abcd", FIXTURE_USER_INPUT[CONF_PROJECT] + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result2["title"] + == f"{FIXTURE_USER_INPUT[CONF_ORG]}/{FIXTURE_USER_INPUT[CONF_PROJECT]}" + ) + assert result2["data"][CONF_ORG] == FIXTURE_USER_INPUT[CONF_ORG] + assert result2["data"][CONF_PROJECT] == FIXTURE_USER_INPUT[CONF_PROJECT] From d1464211a6242c13dcb37e1562cfd5aced84c033 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sun, 26 Jul 2020 00:04:14 +0000 Subject: [PATCH 146/362] [ci skip] Translation update --- .../accuweather/translations/ru.json | 35 +++++++++++ .../accuweather/translations/zh-Hant.json | 35 +++++++++++ .../alarm_control_panel/translations/cs.json | 42 +++++++++----- .../azure_devops/translations/en.json | 58 +++++++++---------- .../components/konnected/translations/cs.json | 10 ++++ 5 files changed, 137 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/ru.json create mode 100644 homeassistant/components/accuweather/translations/zh-Hant.json diff --git a/homeassistant/components/accuweather/translations/ru.json b/homeassistant/components/accuweather/translations/ru.json new file mode 100644 index 00000000000..5c1c5863831 --- /dev/null +++ b/homeassistant/components/accuweather/translations/ru.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API.", + "requests_exceeded": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d\u043e \u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043a API Accuweather. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u043e\u0434\u043e\u0436\u0434\u0430\u0442\u044c \u0438\u043b\u0438 \u0438\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438, \u0435\u0441\u043b\u0438 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439:\nhttps://www.home-assistant.io/integrations/accuweather/ \n\n\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 \u043f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0435\u0433\u043e \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u041f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b" + }, + "description": "\u0412 \u0441\u0432\u044f\u0437\u0438 \u0441 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u044f\u043c\u0438 \u0431\u0435\u0441\u043f\u043b\u0430\u0442\u043d\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u043a\u043b\u044e\u0447\u0430 API AccuWeather, \u043f\u0440\u0438 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430 \u043f\u043e\u0433\u043e\u0434\u044b \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0445 \u0431\u0443\u0434\u0435\u0442 \u043f\u0440\u043e\u0438\u0441\u0445\u043e\u0434\u0438\u0442\u044c \u043a\u0430\u0436\u0434\u044b\u0435 64 \u043c\u0438\u043d\u0443\u0442\u044b, \u0430 \u043d\u0435 \u043a\u0430\u0436\u0434\u044b\u0435 32 \u043c\u0438\u043d\u0443\u0442\u044b.", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/zh-Hant.json b/homeassistant/components/accuweather/translations/zh-Hant.json new file mode 100644 index 00000000000..d5f3acfc81c --- /dev/null +++ b/homeassistant/components/accuweather/translations/zh-Hant.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u3002" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_api_key": "API \u5bc6\u9470\u7121\u6548", + "requests_exceeded": "\u5df2\u8d85\u904e Accuweather API \u5141\u8a31\u7684\u8acb\u6c42\u6b21\u6578\u3002\u5fc5\u9808\u7b49\u5019\u6216\u8b8a\u66f4 API \u5bc6\u9470\u3002" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u6574\u5408\u540d\u7a31" + }, + "description": "\u5047\u5982\u4f60\u9700\u8981\u5354\u52a9\u9032\u884c\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1\uff1ahttps://www.home-assistant.io/integrations/accuweather/\n\n\u5929\u6c23\u9810\u5831\u9810\u8a2d\u672a\u958b\u555f\u3002\u53ef\u4ee5\u65bc\u6574\u5408\u9078\u9805\u4e2d\u958b\u555f\u3002", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u5929\u6c23\u9810\u5831" + }, + "description": "\u7531\u65bc AccuWeather API \u5bc6\u9470\u514d\u8cbb\u7248\u672c\u9650\u5236\uff0c\u7576\u958b\u555f\u5929\u6c23\u9810\u5831\u6642\u3001\u6578\u64da\u6703\u6bcf 64 \u5206\u9418\u66f4\u65b0\u4e00\u6b21\uff0c\u800c\u975e 32 \u5206\u9418\u3002", + "title": "AccuWeather \u9078\u9805" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarm_control_panel/translations/cs.json b/homeassistant/components/alarm_control_panel/translations/cs.json index 0eff1bebaae..40a6fd40338 100644 --- a/homeassistant/components/alarm_control_panel/translations/cs.json +++ b/homeassistant/components/alarm_control_panel/translations/cs.json @@ -1,25 +1,39 @@ { "device_automation": { "action_type": { - "arm_away": "Aktivovat {entity_name} v re\u017eimu mimo domov", - "arm_home": "Aktivovat {entity_name} v re\u017eimu doma", - "arm_night": "Aktivovat {entity_name} v re\u017eimu noc", - "disarm": "Deaktivovat {entity_name}", + "arm_away": "Aktivovat {entity_name} v re\u017eimu nep\u0159\u00edtomnost", + "arm_home": "Aktivovat {entity_name} v re\u017eimu domov", + "arm_night": "Aktivovat {entity_name} v no\u010dn\u00edm re\u017eimu", + "disarm": "Odbezpe\u010dit {entity_name}", "trigger": "Spustit {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} je v re\u017eimu nep\u0159\u00edtomnost", + "is_armed_home": "{entity_name} je v re\u017eimu domov", + "is_armed_night": "{entity_name} je v no\u010dn\u00edm re\u017eimu", + "is_disarmed": "{entity_name} nen\u00ed zabezpe\u010den", + "is_triggered": "{entity_name} je spu\u0161t\u011bn" + }, + "trigger_type": { + "armed_away": "{entity_name} v re\u017eimu nep\u0159\u00edtomnost", + "armed_home": "{entity_name} v re\u017eimu domov", + "armed_night": "{entity_name} v no\u010dn\u00edm re\u017eimu", + "disarmed": "{entity_name} nezabezpe\u010den", + "triggered": "{entity_name} spu\u0161t\u011bn" } }, "state": { "_": { - "armed": "Aktivn\u00ed", - "armed_away": "Aktivn\u00ed re\u017eim mimo domov", - "armed_custom_bypass": "Aktivn\u00ed u\u017eivatelsk\u00fdm obejit\u00edm", - "armed_home": "Aktivn\u00ed re\u017eim doma", - "armed_night": "Aktivn\u00ed no\u010dn\u00ed re\u017eim", - "arming": "Aktivov\u00e1n\u00ed", - "disarmed": "Neaktivn\u00ed", - "disarming": "Deaktivov\u00e1n\u00ed", - "pending": "Nadch\u00e1zej\u00edc\u00ed", - "triggered": "Spu\u0161t\u011bno" + "armed": "Zabezpe\u010deno", + "armed_away": "Re\u017eim nep\u0159\u00edtomnost", + "armed_custom_bypass": "Zabezpe\u010deno u\u017eivatelsk\u00fdm obejit\u00edm", + "armed_home": "Re\u017eim domov", + "armed_night": "No\u010dn\u00ed re\u017eim", + "arming": "Zabezpe\u010dov\u00e1n\u00ed", + "disarmed": "Nezabezpe\u010deno", + "disarming": "Odbezpe\u010dov\u00e1n\u00ed", + "pending": "\u010cekaj\u00edc\u00ed", + "triggered": "Spu\u0161t\u011bn" } }, "title": "Ovl\u00e1dac\u00ed panel alarmu" diff --git a/homeassistant/components/azure_devops/translations/en.json b/homeassistant/components/azure_devops/translations/en.json index 2bb53010153..3adeade6c5f 100644 --- a/homeassistant/components/azure_devops/translations/en.json +++ b/homeassistant/components/azure_devops/translations/en.json @@ -1,33 +1,33 @@ { - "config": { - "flow_title": "Azure DevOps: {project_url}", - "error": { - "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", - "connection_error": "Could not connect to Azure DevOps.", - "project_error": "Could not get project info." - }, - "step": { - "user": { - "data": { - "organization": "Organization", - "project": "Project", - "personal_access_token": "Personal Access Token (PAT)" + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Access Token updated successfully" }, - "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", - "title": "Add Azure DevOps Project" - }, - "reauth": { - "data": { - "personal_access_token": "Personal Access Token (PAT)" + "error": { + "authorization_error": "Authorization error. Check you have access to the project and have the correct credentials.", + "connection_error": "Could not connect to Azure DevOps.", + "project_error": "Could not get project info." }, - "description": "Authentication failed for {project_url}. Please enter your current credentials.", - "title": "Reauthentication" - } + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Personal Access Token (PAT)" + }, + "description": "Authentication failed for {project_url}. Please enter your current credentials.", + "title": "Reauthentication" + }, + "user": { + "data": { + "organization": "Organization", + "personal_access_token": "Personal Access Token (PAT)", + "project": "Project" + }, + "description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.", + "title": "Add Azure DevOps Project" + } + } }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" - } - }, - "title": "Azure DevOps" -} + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/konnected/translations/cs.json b/homeassistant/components/konnected/translations/cs.json index 814e0c63418..df1519035c6 100644 --- a/homeassistant/components/konnected/translations/cs.json +++ b/homeassistant/components/konnected/translations/cs.json @@ -8,5 +8,15 @@ } } } + }, + "options": { + "step": { + "options_io_ext": { + "data": { + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2" + } + } + } } } \ No newline at end of file From 34d01d5e4777e5394c6af91c5eadf99d823ba554 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2020 17:52:48 -1000 Subject: [PATCH 147/362] Mark event tests to run as callbacks (#38212) * Mark event tests to run as callbacks * revert change to same state check that is expected to run in a thread --- tests/helpers/test_event.py | 85 +++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 23 deletions(-) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 0c16e624fc3..aa0a69d1d67 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -434,7 +434,7 @@ async def test_track_same_state_simple_trigger(hass): hass, period, callback_run_callback, - lambda _, _2, to_s: to_s.state == "on", + callback(lambda _, _2, to_s: to_s.state == "on"), entity_ids="light.Bowl", ) @@ -442,7 +442,10 @@ async def test_track_same_state_simple_trigger(hass): coroutine_runs.append(1) async_track_same_state( - hass, period, coroutine_run_callback, lambda _, _2, to_s: to_s.state == "on" + hass, + period, + coroutine_run_callback, + callback(lambda _, _2, to_s: to_s.state == "on"), ) # Adding state to state machine @@ -474,7 +477,7 @@ async def test_track_same_state_simple_no_trigger(hass): hass, period, callback_run_callback, - lambda _, _2, to_s: to_s.state == "on", + callback(lambda _, _2, to_s: to_s.state == "on"), entity_ids="light.Bowl", ) @@ -539,7 +542,7 @@ async def test_track_time_interval(hass): utc_now = dt_util.utcnow() unsub = async_track_time_interval( - hass, lambda x: specific_runs.append(x), timedelta(seconds=10) + hass, callback(lambda x: specific_runs.append(x)), timedelta(seconds=10) ) async_fire_time_changed(hass, utc_now + timedelta(seconds=5)) @@ -590,12 +593,14 @@ async def test_track_sunrise(hass, legacy_patchable_time): # Track sunrise runs = [] with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub = async_track_sunrise(hass, lambda: runs.append(1)) + unsub = async_track_sunrise(hass, callback(lambda: runs.append(1))) offset_runs = [] offset = timedelta(minutes=30) with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub2 = async_track_sunrise(hass, lambda: offset_runs.append(1), offset) + unsub2 = async_track_sunrise( + hass, callback(lambda: offset_runs.append(1)), offset + ) # run tests async_fire_time_changed(hass, next_rising - offset) @@ -648,7 +653,7 @@ async def test_track_sunrise_update_location(hass, legacy_patchable_time): # Track sunrise runs = [] with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - async_track_sunrise(hass, lambda: runs.append(1)) + async_track_sunrise(hass, callback(lambda: runs.append(1))) # Mimic sunrise async_fire_time_changed(hass, next_rising) @@ -711,12 +716,14 @@ async def test_track_sunset(hass, legacy_patchable_time): # Track sunset runs = [] with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub = async_track_sunset(hass, lambda: runs.append(1)) + unsub = async_track_sunset(hass, callback(lambda: runs.append(1))) offset_runs = [] offset = timedelta(minutes=30) with patch("homeassistant.util.dt.utcnow", return_value=utc_now): - unsub2 = async_track_sunset(hass, lambda: offset_runs.append(1), offset) + unsub2 = async_track_sunset( + hass, callback(lambda: offset_runs.append(1)), offset + ) # Run tests async_fire_time_changed(hass, next_setting - offset) @@ -750,14 +757,18 @@ async def test_async_track_time_change(hass): now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 11, 59, 55) + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC + ) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): - unsub = async_track_time_change(hass, lambda x: wildcard_runs.append(x)) + unsub = async_track_time_change( + hass, callback(lambda x: wildcard_runs.append(x)) + ) unsub_utc = async_track_utc_time_change( - hass, lambda x: specific_runs.append(x), second=[0, 30] + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] ) async_fire_time_changed( @@ -798,13 +809,15 @@ async def test_periodic_task_minute(hass): now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 11, 59, 55) + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC + ) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(x), minute="/5", second=0 + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 ) async_fire_time_changed( @@ -840,13 +853,19 @@ async def test_periodic_task_hour(hass): now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 21, 59, 55) + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC + ) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(x), hour="/2", minute=0, second=0 + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) async_fire_time_changed( @@ -896,7 +915,7 @@ async def test_periodic_task_wrong_input(hass): with pytest.raises(ValueError): async_track_utc_time_change( - hass, lambda x: specific_runs.append(x), hour="/two" + hass, callback(lambda x: specific_runs.append(x)), hour="/two" ) async_fire_time_changed( @@ -912,13 +931,19 @@ async def test_periodic_task_clock_rollback(hass): now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 21, 59, 55) + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC + ) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(x), hour="/2", minute=0, second=0 + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) async_fire_time_changed( @@ -970,13 +995,19 @@ async def test_periodic_task_duplicate_time(hass): now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime(now.year + 1, 5, 24, 21, 59, 55) + time_that_will_not_match_right_away = datetime( + now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC + ) with patch( "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_utc_time_change( - hass, lambda x: specific_runs.append(x), hour="/2", minute=0, second=0 + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) async_fire_time_changed( @@ -1015,7 +1046,11 @@ async def test_periodic_task_entering_dst(hass): "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_time_change( - hass, lambda x: specific_runs.append(x), hour=2, minute=30, second=0 + hass, + callback(lambda x: specific_runs.append(x)), + hour=2, + minute=30, + second=0, ) async_fire_time_changed( @@ -1061,7 +1096,11 @@ async def test_periodic_task_leaving_dst(hass): "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away ): unsub = async_track_time_change( - hass, lambda x: specific_runs.append(x), hour=2, minute=30, second=0 + hass, + callback(lambda x: specific_runs.append(x)), + hour=2, + minute=30, + second=0, ) async_fire_time_changed( From a39aec862ea8af649e46942a61feca8cf05da77b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2020 22:26:32 -1000 Subject: [PATCH 148/362] Attempt to fix islamic prayer times tests (#38220) * Attempt to fix islamic_prayer_times tests * adj --- tests/components/islamic_prayer_times/test_init.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index f3d4351ae29..984bbbf7c75 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -20,7 +20,7 @@ from tests.async_mock import patch from tests.common import MockConfigEntry, async_fire_time_changed -async def test_setup_with_config(hass): +async def test_setup_with_config(hass, legacy_patchable_time): """Test that we import the config and setup the client.""" config = { islamic_prayer_times.DOMAIN: {islamic_prayer_times.CONF_CALC_METHOD: "isna"} @@ -33,9 +33,10 @@ async def test_setup_with_config(hass): await async_setup_component(hass, islamic_prayer_times.DOMAIN, config) is True ) + await hass.async_block_till_done() -async def test_successful_config_entry(hass): +async def test_successful_config_entry(hass, legacy_patchable_time): """Test that Islamic Prayer Times is configured successfully.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},) @@ -46,6 +47,7 @@ async def test_successful_config_entry(hass): return_value=PRAYER_TIMES, ): await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.state == config_entries.ENTRY_STATE_LOADED assert entry.options == { @@ -53,7 +55,7 @@ async def test_successful_config_entry(hass): } -async def test_setup_failed(hass): +async def test_setup_failed(hass, legacy_patchable_time): """Test Islamic Prayer Times failed due to an error.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},) @@ -65,10 +67,11 @@ async def test_setup_failed(hass): side_effect=InvalidResponseError(), ): await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY -async def test_unload_entry(hass): +async def test_unload_entry(hass, legacy_patchable_time): """Test removing Islamic Prayer Times.""" entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},) entry.add_to_hass(hass) @@ -95,6 +98,7 @@ async def test_islamic_prayer_times_timestamp_format(hass, legacy_patchable_time return_value=PRAYER_TIMES, ), patch("homeassistant.util.dt.now", return_value=NOW): await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert ( hass.data[islamic_prayer_times.DOMAIN].prayer_times_info From f6b0f8d6de4e82e21fc8a89a34b441bde7e08ba4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jul 2020 22:42:28 -1000 Subject: [PATCH 149/362] Update logbook to use async_add_executor_job (#38217) --- homeassistant/components/logbook/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 37caa3a8533..28f85cf92da 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -212,7 +212,7 @@ class LogbookView(HomeAssistantView): ) ) - return await hass.async_add_job(json_events) + return await hass.async_add_executor_job(json_events) def humanify(hass, events, entity_attr_cache): From 34ac4e78af4f4a931593681073a03da4cec748d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 26 Jul 2020 16:56:00 +0300 Subject: [PATCH 150/362] Fix libav install in Travis CI (#38221) --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index fca06468ddd..29f657d7889 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,7 @@ addons: - libavfilter-dev sources: - sourceline: ppa:savoury1/ffmpeg4 + - sourceline: ppa:savoury1/multimedia python: - "3.7.1" From 2d6eb5c05dd95d9eb2ee1ae3de7d1e7eff69eff6 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Sun, 26 Jul 2020 13:15:21 -0400 Subject: [PATCH 151/362] Refactor bond unit tests to reduce boilerplate (#38177) * Refactor bond unit tests to reduce boilerplate * Refactor bond unit tests to reduce boilerplate (PR feedback) * Refactor bond unit tests to reduce boilerplate (PR feedback, nullcontext) --- tests/components/bond/common.py | 101 ++++++++++++++++------ tests/components/bond/test_config_flow.py | 35 ++++---- tests/components/bond/test_init.py | 66 +++++++------- 3 files changed, 122 insertions(+), 80 deletions(-) diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index b4d22641204..229a9f31dfe 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -1,7 +1,8 @@ """Common methods used across tests for Bond.""" from asyncio import TimeoutError as AsyncIOTimeoutError +from contextlib import nullcontext from datetime import timedelta -from typing import Any, Dict +from typing import Any, Dict, Optional from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN @@ -12,19 +13,35 @@ from homeassistant.util import utcnow from tests.async_mock import patch from tests.common import MockConfigEntry, async_fire_time_changed -MOCK_HUB_VERSION: dict = {"bondid": "test-bond-id"} + +def patch_setup_entry(domain: str, *, enabled: bool = True): + """Patch async_setup_entry for specified domain.""" + if not enabled: + return nullcontext() + + return patch(f"homeassistant.components.bond.{domain}.async_setup_entry") async def setup_bond_entity( - hass: core.HomeAssistant, config_entry: MockConfigEntry, hub_version=None + hass: core.HomeAssistant, + config_entry: MockConfigEntry, + *, + patch_version=False, + patch_device_ids=False, + patch_platforms=False, ): """Set up Bond entity.""" - if hub_version is None: - hub_version = MOCK_HUB_VERSION - config_entry.add_to_hass(hass) - with patch("homeassistant.components.bond.Bond.version", return_value=hub_version): + with patch_bond_version(enabled=patch_version), patch_bond_device_ids( + enabled=patch_device_ids + ), patch_setup_entry("cover", enabled=patch_platforms), patch_setup_entry( + "fan", enabled=patch_platforms + ), patch_setup_entry( + "light", enabled=patch_platforms + ), patch_setup_entry( + "switch", enabled=patch_platforms + ): return await hass.config_entries.async_setup(config_entry.entry_id) @@ -36,47 +53,77 @@ async def setup_platform( props: Dict[str, Any] = None, ): """Set up the specified Bond platform.""" - if not props: - props = {} - mock_entry = MockConfigEntry( domain=BOND_DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, ) mock_entry.add_to_hass(hass) - with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch( - "homeassistant.components.bond.Bond.version", return_value=MOCK_HUB_VERSION - ), patch_bond_device_ids(return_value=[bond_device_id],), patch( - "homeassistant.components.bond.Bond.device", return_value=discovered_device - ), patch_bond_device_state( - return_value={} - ), patch( - "homeassistant.components.bond.Bond.device_properties", return_value=props - ), patch( - "homeassistant.components.bond.Bond.device_state", return_value={} - ): - assert await async_setup_component(hass, BOND_DOMAIN, {}) - await hass.async_block_till_done() + with patch("homeassistant.components.bond.PLATFORMS", [platform]): + with patch_bond_version(), patch_bond_device_ids( + return_value=[bond_device_id] + ), patch_bond_device( + return_value=discovered_device + ), patch_bond_device_state(), patch_bond_device_properties( + return_value=props + ), patch_bond_device_state(): + assert await async_setup_component(hass, BOND_DOMAIN, {}) + await hass.async_block_till_done() return mock_entry -def patch_bond_device_ids(return_value=None): - """Patch Bond API devices command.""" +def patch_bond_version(enabled: bool = True, return_value: Optional[dict] = None): + """Patch Bond API version endpoint.""" + if not enabled: + return nullcontext() + + if return_value is None: + return_value = {"bondid": "test-bond-id"} + + return patch( + "homeassistant.components.bond.Bond.version", return_value=return_value + ) + + +def patch_bond_device_ids(enabled: bool = True, return_value=None, side_effect=None): + """Patch Bond API devices endpoint.""" + if not enabled: + return nullcontext() + if return_value is None: return_value = [] return patch( - "homeassistant.components.bond.Bond.devices", return_value=return_value, + "homeassistant.components.bond.Bond.devices", + return_value=return_value, + side_effect=side_effect, + ) + + +def patch_bond_device(return_value=None): + """Patch Bond API device endpoint.""" + return patch( + "homeassistant.components.bond.Bond.device", return_value=return_value, ) def patch_bond_action(): - """Patch Bond API action command.""" + """Patch Bond API action endpoint.""" return patch("homeassistant.components.bond.Bond.action") +def patch_bond_device_properties(return_value=None): + """Patch Bond API device properties endpoint.""" + if return_value is None: + return_value = {} + + return patch( + "homeassistant.components.bond.Bond.device_properties", + return_value=return_value, + ) + + def patch_bond_device_state(return_value=None, side_effect=None): """Patch Bond API device state endpoint.""" if return_value is None: diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 825215207a0..bc6609d54ec 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -6,6 +6,8 @@ from homeassistant import config_entries, core, setup from homeassistant.components.bond.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from .common import patch_bond_device_ids + from tests.async_mock import Mock, patch @@ -18,21 +20,20 @@ async def test_form(hass: core.HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.bond.config_flow.Bond.devices", return_value=[], - ), patch( + with patch_bond_device_ids(), patch( "homeassistant.components.bond.async_setup", return_value=True ) as mock_setup, patch( "homeassistant.components.bond.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "create_entry" - assert result2["title"] == "1.1.1.1" + assert result2["title"] == "some host" assert result2["data"] == { - CONF_HOST: "1.1.1.1", + CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", } await hass.async_block_till_done() @@ -46,12 +47,12 @@ async def test_form_invalid_auth(hass: core.HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.bond.config_flow.Bond.devices", + with patch_bond_device_ids( side_effect=ClientResponseError(Mock(), Mock(), status=401), ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "form" @@ -64,12 +65,10 @@ async def test_form_cannot_connect(hass: core.HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.bond.config_flow.Bond.devices", - side_effect=ClientConnectionError(), - ): + with patch_bond_device_ids(side_effect=ClientConnectionError()): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "form" @@ -82,12 +81,12 @@ async def test_form_unexpected_error(hass: core.HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.bond.config_flow.Bond.devices", - side_effect=ClientResponseError(Mock(), Mock(), status=500), + with patch_bond_device_ids( + side_effect=ClientResponseError(Mock(), Mock(), status=500) ): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) assert result2["type"] == "form" diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 4d5fd9f4568..b75f1bbac8c 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -6,17 +6,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .common import patch_bond_device_ids, setup_bond_entity +from .common import patch_bond_version, patch_setup_entry, setup_bond_entity -from tests.async_mock import patch from tests.common import MockConfigEntry -def patch_setup_entry(domain: str): - """Patch async_setup_entry for specified domain.""" - return patch(f"homeassistant.components.bond.{domain}.async_setup_entry") - - async def test_async_setup_no_domain_config(hass: HomeAssistant): """Test setup without configuration is noop.""" result = await async_setup_component(hass, DOMAIN, {}) @@ -27,29 +21,28 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant): async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant): """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch_bond_device_ids(), patch_setup_entry( - "cover" - ) as mock_cover_async_setup_entry, patch_setup_entry( - "fan" - ) as mock_fan_async_setup_entry, patch_setup_entry( - "light" - ) as mock_light_async_setup_entry, patch_setup_entry( - "switch" - ) as mock_switch_async_setup_entry: - result = await setup_bond_entity( - hass, - config_entry, - hub_version={ - "bondid": "test-bond-id", - "target": "test-model", - "fw_ver": "test-version", - }, - ) - assert result is True - await hass.async_block_till_done() + with patch_bond_version( + return_value={ + "bondid": "test-bond-id", + "target": "test-model", + "fw_ver": "test-version", + } + ): + with patch_setup_entry( + "cover" + ) as mock_cover_async_setup_entry, patch_setup_entry( + "fan" + ) as mock_fan_async_setup_entry, patch_setup_entry( + "light" + ) as mock_light_async_setup_entry, patch_setup_entry( + "switch" + ) as mock_switch_async_setup_entry: + result = await setup_bond_entity(hass, config_entry, patch_device_ids=True) + assert result is True + await hass.async_block_till_done() assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state == ENTRY_STATE_LOADED @@ -74,15 +67,18 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss async def test_unload_config_entry(hass: HomeAssistant): """Test that configuration entry supports unloading.""" config_entry = MockConfigEntry( - domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) - with patch_bond_device_ids(), patch_setup_entry("cover"), patch_setup_entry( - "fan" - ), patch_setup_entry("light"), patch_setup_entry("switch"): - result = await setup_bond_entity(hass, config_entry) - assert result is True - await hass.async_block_till_done() + result = await setup_bond_entity( + hass, + config_entry, + patch_version=True, + patch_device_ids=True, + patch_platforms=True, + ) + assert result is True + await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() From 093bd863baccd828d1b46dc944c16e6e0c2e211c Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 26 Jul 2020 11:00:07 -0700 Subject: [PATCH 152/362] Add update available binary sensor to Tesla (#37991) * Add update available binary sensor to Tesla * Bump teslajsonpy to 0.10.1 * Add check for DEVICE_CLASS * Change to relative import --- homeassistant/components/tesla/binary_sensor.py | 16 ++++++---------- homeassistant/components/tesla/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py index c1f6fe18b99..c6b63d92bd2 100644 --- a/homeassistant/components/tesla/binary_sensor.py +++ b/homeassistant/components/tesla/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Tesla binary sensor.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import DEVICE_CLASSES, BinarySensorEntity from . import DOMAIN as TESLA_DOMAIN, TeslaDevice @@ -15,7 +15,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): TeslaBinarySensor( device, hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"], - "connectivity", config_entry, ) for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ @@ -29,22 +28,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TeslaBinarySensor(TeslaDevice, BinarySensorEntity): """Implement an Tesla binary sensor for parking and charger.""" - def __init__(self, tesla_device, controller, sensor_type, config_entry): + def __init__(self, tesla_device, controller, config_entry): """Initialise of a Tesla binary sensor.""" super().__init__(tesla_device, controller, config_entry) - self._state = False - self._sensor_type = sensor_type + self._state = None + self._sensor_type = None + if tesla_device.sensor_type in DEVICE_CLASSES: + self._sensor_type = tesla_device.sensor_type @property def device_class(self): """Return the class of this binary sensor.""" return self._sensor_type - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - @property def is_on(self): """Return the state of the binary sensor.""" diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index 1db32bbd61f..fab844eb8eb 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -3,6 +3,6 @@ "name": "Tesla", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.9.3"], + "requirements": ["teslajsonpy==0.10.1"], "codeowners": ["@zabuldon", "@alandtse"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcf12d9b274..64ff88aff11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2101,7 +2101,7 @@ temperusb==1.5.3 tesla-powerwall==0.2.11 # homeassistant.components.tesla -teslajsonpy==0.9.3 +teslajsonpy==0.10.1 # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d8bb75841e..880e1178640 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -927,7 +927,7 @@ tellduslive==0.10.11 tesla-powerwall==0.2.11 # homeassistant.components.tesla -teslajsonpy==0.9.3 +teslajsonpy==0.10.1 # homeassistant.components.toon toonapi==0.1.0 From 36cb818cd0020b0bc060f4500cb36f3c8d412b22 Mon Sep 17 00:00:00 2001 From: shred86 <32663154+shred86@users.noreply.github.com> Date: Sun, 26 Jul 2020 11:59:11 -0700 Subject: [PATCH 153/362] Add Abode camera on and off support (#35164) * Add Abode camera controls * Add tests for camera turn on and off service * Bump abodepy version * Bump abodepy version and updates to reflect changes * Update manifest --- homeassistant/components/abode/__init__.py | 1 + homeassistant/components/abode/camera.py | 13 +++++++++ homeassistant/components/abode/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/abode/test_camera.py | 30 ++++++++++++++++++++ 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 85e05e89cc1..92665bc1890 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -261,6 +261,7 @@ def setup_abode_events(hass): TIMELINE.AUTOMATION_GROUP, TIMELINE.DISARM_GROUP, TIMELINE.ARM_GROUP, + TIMELINE.ARM_FAULT_GROUP, TIMELINE.TEST_GROUP, TIMELINE.CAPTURE_GROUP, TIMELINE.DEVICE_GROUP, diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index b7d5f1dbe4c..99d4fd433a7 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -82,8 +82,21 @@ class AbodeCamera(AbodeDevice, Camera): return None + def turn_on(self): + """Turn on camera.""" + self._device.privacy_mode(False) + + def turn_off(self): + """Turn off camera.""" + self._device.privacy_mode(True) + def _capture_callback(self, capture): """Update the image with the device then refresh device.""" self._device.update_image_location(capture) self.get_image() self.schedule_update_ha_state() + + @property + def is_on(self): + """Return true if on.""" + return self._device.is_on diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index d59ddd6217f..e9a871035e6 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -3,7 +3,7 @@ "name": "Abode", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/abode", - "requirements": ["abodepy==0.19.0"], + "requirements": ["abodepy==1.1.0"], "codeowners": ["@shred86"], "homekit": { "models": ["Abode", "Iota"] diff --git a/requirements_all.txt b/requirements_all.txt index 64ff88aff11..8259f8f4937 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ WazeRouteCalculator==0.12 YesssSMS==0.4.1 # homeassistant.components.abode -abodepy==0.19.0 +abodepy==1.1.0 # homeassistant.components.accuweather accuweather==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 880e1178640..351599ec1a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -43,7 +43,7 @@ WSDiscovery==2.0.0 YesssSMS==0.4.1 # homeassistant.components.abode -abodepy==0.19.0 +abodepy==1.1.0 # homeassistant.components.accuweather accuweather==0.0.9 diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py index 0e843c59023..9db03d90222 100644 --- a/tests/components/abode/test_camera.py +++ b/tests/components/abode/test_camera.py @@ -38,3 +38,33 @@ async def test_capture_image(hass): ) await hass.async_block_till_done() mock_capture.assert_called_once() + + +async def test_camera_on(hass): + """Test the camera turn on service.""" + await setup_platform(hass, CAMERA_DOMAIN) + + with patch("abodepy.AbodeCamera.privacy_mode") as mock_capture: + await hass.services.async_call( + CAMERA_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: "camera.test_cam"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_capture.assert_called_once_with(False) + + +async def test_camera_off(hass): + """Test the camera turn off service.""" + await setup_platform(hass, CAMERA_DOMAIN) + + with patch("abodepy.AbodeCamera.privacy_mode") as mock_capture: + await hass.services.async_call( + CAMERA_DOMAIN, + "turn_off", + {ATTR_ENTITY_ID: "camera.test_cam"}, + blocking=True, + ) + await hass.async_block_till_done() + mock_capture.assert_called_once_with(True) From 455ac1cadfa354f33cd4239aa035fdec6c95b9f1 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 26 Jul 2020 21:13:10 +0100 Subject: [PATCH 154/362] fix issue #34559 (#38241) --- homeassistant/components/evohome/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 9af53981957..f54bba1c58d 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -614,13 +614,12 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> Optional[float]: """Return the current temperature of a Zone.""" - if not self._evo_device.temperatureStatus["isAvailable"]: - return None - if self._evo_broker.temps: - return self._evo_broker.temps[self._evo_device.zoneId] + if self._evo_broker.temps[self._evo_device.zoneId] != 128: + return self._evo_broker.temps[self._evo_device.zoneId] - return self._evo_device.temperatureStatus["temperature"] + if self._evo_device.temperatureStatus["isAvailable"]: + return self._evo_device.temperatureStatus["temperature"] @property def setpoints(self) -> Dict[str, Any]: From 4d73f107c42389d4fb308412ab489a3c082730a8 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Sun, 26 Jul 2020 19:27:18 -0400 Subject: [PATCH 155/362] Implement resilient startup for bond integration with ConfigEntryNotReady support (#38253) --- homeassistant/components/bond/__init__.py | 12 ++++++++++-- tests/components/bond/common.py | 8 ++++++-- tests/components/bond/test_init.py | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 50025adbc1a..b9c1c90e1af 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,18 +1,22 @@ """The Bond integration.""" import asyncio +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging -from aiohttp import ClientTimeout +from aiohttp import ClientError, ClientTimeout from bond_api import Bond from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import SLOW_UPDATE_WARNING from .const import DOMAIN from .utils import BondHub +_LOGGER = logging.getLogger(__name__) PLATFORMS = ["cover", "fan", "light", "switch"] _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 @@ -30,7 +34,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): bond = Bond(host=host, token=token, timeout=ClientTimeout(total=_API_TIMEOUT)) hub = BondHub(bond) - await hub.setup() + try: + await hub.setup() + except (ClientError, AsyncIOTimeoutError, OSError) as error: + raise ConfigEntryNotReady from error + hass.data[DOMAIN][entry.entry_id] = hub device_registry = await dr.async_get_registry(hass) diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 229a9f31dfe..1a37455b338 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -73,7 +73,9 @@ async def setup_platform( return mock_entry -def patch_bond_version(enabled: bool = True, return_value: Optional[dict] = None): +def patch_bond_version( + enabled: bool = True, return_value: Optional[dict] = None, side_effect=None +): """Patch Bond API version endpoint.""" if not enabled: return nullcontext() @@ -82,7 +84,9 @@ def patch_bond_version(enabled: bool = True, return_value: Optional[dict] = None return_value = {"bondid": "test-bond-id"} return patch( - "homeassistant.components.bond.Bond.version", return_value=return_value + "homeassistant.components.bond.Bond.version", + return_value=return_value, + side_effect=side_effect, ) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index b75f1bbac8c..6b0cf2b287e 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -1,8 +1,13 @@ """Tests for the Bond module.""" +from aiohttp import ClientConnectionError +import pytest + +from homeassistant.components.bond import async_setup_entry from homeassistant.components.bond.const import DOMAIN from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -18,6 +23,17 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant): assert result is True +async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + + with patch_bond_version(side_effect=ClientConnectionError()): + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(hass, config_entry) + + async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant): """Test that configuring entry sets up cover domain.""" config_entry = MockConfigEntry( From 8abdc2c9696e703160518af54454432cc3f1b0cf Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Mon, 27 Jul 2020 00:02:58 +0000 Subject: [PATCH 156/362] [ci skip] Translation update --- .../accuweather/translations/ca.json | 18 ++++++++++ .../accuweather/translations/it.json | 35 +++++++++++++++++++ .../accuweather/translations/no.json | 9 +++++ .../accuweather/translations/pl.json | 35 +++++++++++++++++++ .../components/agent_dvr/translations/no.json | 2 +- .../components/arcam_fmj/translations/no.json | 3 ++ .../components/atag/translations/no.json | 2 +- .../components/axis/translations/no.json | 2 +- .../azure_devops/translations/ca.json | 15 ++++++++ .../azure_devops/translations/it.json | 33 +++++++++++++++++ .../azure_devops/translations/zh-Hant.json | 33 +++++++++++++++++ .../components/blebox/translations/no.json | 2 +- .../components/bsblan/translations/no.json | 2 +- .../cert_expiry/translations/no.json | 2 +- .../components/control4/translations/ca.json | 21 +++++++++++ .../components/deconz/translations/no.json | 2 +- .../components/elgato/translations/no.json | 2 +- .../components/esphome/translations/no.json | 2 +- .../forked_daapd/translations/no.json | 2 +- .../components/freebox/translations/no.json | 2 +- .../components/glances/translations/no.json | 2 +- .../components/guardian/translations/no.json | 2 +- .../components/ipp/translations/no.json | 2 +- .../components/mikrotik/translations/no.json | 2 +- .../components/monoprice/translations/no.json | 2 +- .../components/mqtt/translations/no.json | 2 +- .../components/nut/translations/no.json | 2 +- .../components/onvif/translations/no.json | 2 +- .../components/plex/translations/no.json | 5 +-- .../rainmachine/translations/no.json | 2 +- .../simplisafe/translations/ca.json | 8 ++++- .../simplisafe/translations/it.json | 18 ++++++++-- .../components/soma/translations/no.json | 2 +- .../synology_dsm/translations/no.json | 4 +-- .../transmission/translations/no.json | 2 +- .../components/tuya/translations/no.json | 2 +- .../components/unifi/translations/no.json | 2 +- .../components/wolflink/translations/ca.json | 25 +++++++++++++ .../wolflink/translations/sensor.pl.json | 2 +- 39 files changed, 280 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/ca.json create mode 100644 homeassistant/components/accuweather/translations/it.json create mode 100644 homeassistant/components/accuweather/translations/no.json create mode 100644 homeassistant/components/accuweather/translations/pl.json create mode 100644 homeassistant/components/azure_devops/translations/ca.json create mode 100644 homeassistant/components/azure_devops/translations/it.json create mode 100644 homeassistant/components/azure_devops/translations/zh-Hant.json create mode 100644 homeassistant/components/control4/translations/ca.json create mode 100644 homeassistant/components/wolflink/translations/ca.json diff --git a/homeassistant/components/accuweather/translations/ca.json b/homeassistant/components/accuweather/translations/ca.json new file mode 100644 index 00000000000..46279c46f56 --- /dev/null +++ b/homeassistant/components/accuweather/translations/ca.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_api_key": "Clau API inv\u00e0lida" + } + }, + "options": { + "step": { + "user": { + "title": "Opcions d'AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/it.json b/homeassistant/components/accuweather/translations/it.json new file mode 100644 index 00000000000..c091725efb1 --- /dev/null +++ b/homeassistant/components/accuweather/translations/it.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_api_key": "Chiave API non valida", + "requests_exceeded": "\u00c8 stato superato il numero consentito di richieste all'API di Accuweather. \u00c8 necessario attendere o modificare la chiave API." + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome dell'integrazione" + }, + "description": "Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/accuweather/ \n\nLe previsioni meteo non sono abilitate per impostazione predefinita. Puoi abilitarlo nelle opzioni di integrazione.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Previsioni meteo" + }, + "description": "A causa delle limitazioni della versione gratuita della chiave API AccuWeather, quando si abilitano le previsioni del tempo, gli aggiornamenti dei dati verranno eseguiti ogni 64 minuti invece che ogni 32 minuti.", + "title": "Opzioni AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/no.json b/homeassistant/components/accuweather/translations/no.json new file mode 100644 index 00000000000..e99a1d62746 --- /dev/null +++ b/homeassistant/components/accuweather/translations/no.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/pl.json b/homeassistant/components/accuweather/translations/pl.json new file mode 100644 index 00000000000..eb1ea8e5b04 --- /dev/null +++ b/homeassistant/components/accuweather/translations/pl.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.", + "invalid_api_key": "Nieprawid\u0142owy klucz API.", + "requests_exceeded": "Dozwolona liczba zapyta\u0144 do interfejsu API Accuweather zosta\u0142a przekroczona. Musisz poczeka\u0107 lub zmieni\u0107 klucz API." + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "name": "Nazwa integracji" + }, + "description": "Je\u015bli potrzebujesz pomocy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/accuweather/ \n\n Prognoza pogody nie jest domy\u015blnie w\u0142\u0105czona. Mo\u017cesz j\u0105 w\u0142\u0105czy\u0107 w opcjach integracji.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Prognoza pogody" + }, + "description": "Ze wzgl\u0119du na ograniczenia darmowej wersji klucza API AccuWeather po w\u0142\u0105czeniu prognozy pogody aktualizacje danych b\u0119d\u0105 wykonywane co 64 minut zamiast co 32 minut.", + "title": "Opcje AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/agent_dvr/translations/no.json b/homeassistant/components/agent_dvr/translations/no.json index cbb4e3503a0..3fcbb8f1617 100644 --- a/homeassistant/components/agent_dvr/translations/no.json +++ b/homeassistant/components/agent_dvr/translations/no.json @@ -11,7 +11,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "title": "Konfigurere Agent DVR" } diff --git a/homeassistant/components/arcam_fmj/translations/no.json b/homeassistant/components/arcam_fmj/translations/no.json index f52ef426bee..554097b95a5 100644 --- a/homeassistant/components/arcam_fmj/translations/no.json +++ b/homeassistant/components/arcam_fmj/translations/no.json @@ -11,6 +11,9 @@ "description": "Vil du legge Arcam FMJ p\u00e5 ` {host} ` til Home Assistant? " }, "user": { + "data": { + "port": "" + }, "description": "Vennligst skriv inn vertsnavnet eller IP-adressen til enheten." } } diff --git a/homeassistant/components/atag/translations/no.json b/homeassistant/components/atag/translations/no.json index 3f446a5f21b..a0e428f286a 100644 --- a/homeassistant/components/atag/translations/no.json +++ b/homeassistant/components/atag/translations/no.json @@ -12,7 +12,7 @@ "data": { "email": "E-post (valgfritt)", "host": "Vert", - "port": "Port " + "port": "" }, "title": "Koble til enheten" } diff --git a/homeassistant/components/axis/translations/no.json b/homeassistant/components/axis/translations/no.json index 891b4b6d972..039e6138753 100644 --- a/homeassistant/components/axis/translations/no.json +++ b/homeassistant/components/axis/translations/no.json @@ -18,7 +18,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "title": "Sett opp Axis enhet" diff --git a/homeassistant/components/azure_devops/translations/ca.json b/homeassistant/components/azure_devops/translations/ca.json new file mode 100644 index 00000000000..289adf79d9a --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "reauth_successful": "Token d'acc\u00e9s actualitzat correctament" + }, + "step": { + "user": { + "data": { + "organization": "Organitzaci\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/it.json b/homeassistant/components/azure_devops/translations/it.json new file mode 100644 index 00000000000..a62f366e724 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "Token di accesso aggiornato correttamente" + }, + "error": { + "authorization_error": "Errore di autorizzazione. Verificare di avere accesso al progetto e disporre delle credenziali corrette.", + "connection_error": "Impossibile connettersi ad Azure DevOps.", + "project_error": "Non \u00e8 stato possibile ottenere informazioni sul progetto." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token di Accesso Personale (PAT)" + }, + "description": "Autenticazione non riuscita per {project_url}. Si prega di inserire le proprie credenziali attuali.", + "title": "Riautenticazione" + }, + "user": { + "data": { + "organization": "Organizzazione", + "personal_access_token": "Token di Accesso Personale (PAT)", + "project": "Progetto" + }, + "description": "Configurare un'istanza di DevOps di Azure per accedere al progetto. Un Token di Accesso Personale (PAT) \u00e8 richiesto solo per un progetto privato.", + "title": "Aggiungere un progetto Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/zh-Hant.json b/homeassistant/components/azure_devops/translations/zh-Hant.json new file mode 100644 index 00000000000..f632ea947ae --- /dev/null +++ b/homeassistant/components/azure_devops/translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u5b58\u53d6\u5bc6\u9470\u5df2\u6210\u529f\u66f4\u65b0" + }, + "error": { + "authorization_error": "\u8a8d\u8b49\u932f\u8aa4\u3002\u8acb\u78ba\u8a8d\u64c1\u6709\u5c08\u6848\u5b58\u53d6\u6b0a\u8207\u6b63\u78ba\u7684\u8b49\u66f8\u3002", + "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 Azure DevOps\u3002", + "project_error": "\u7121\u6cd5\u53d6\u5f97\u5c08\u6848\u8cc7\u8a0a\u3002" + }, + "flow_title": "Azure DevOps\uff1a{project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff08PAT\uff09" + }, + "description": "{project_url}\u8a8d\u8b49\u5931\u6557\u3002\u8acb\u8f38\u5165\u76ee\u524d\u8b49\u66f8\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49" + }, + "user": { + "data": { + "organization": "\u7d44\u7e54", + "personal_access_token": "\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff08PAT\uff09", + "project": "\u5c08\u6848" + }, + "description": "\u8a2d\u5b9a Azure DevOps \u4ee5\u5b58\u53d6\u5c08\u6848\u3002\u79c1\u4eba\u5c08\u6848\u5247\u9700\u8981\u8f38\u5165\u300c\u500b\u4eba\u5b58\u53d6\u5bc6\u9470\uff09\u3002", + "title": "\u65b0\u589e Azure DevOps \u5c08\u6848" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/blebox/translations/no.json b/homeassistant/components/blebox/translations/no.json index 03f054687ae..239d1fb03c6 100644 --- a/homeassistant/components/blebox/translations/no.json +++ b/homeassistant/components/blebox/translations/no.json @@ -14,7 +14,7 @@ "user": { "data": { "host": "IP adresse", - "port": "Port" + "port": "" }, "description": "Konfigurer BleBox-en til \u00e5 integreres med Home Assistant.", "title": "Konfigurere BleBox-enheten" diff --git a/homeassistant/components/bsblan/translations/no.json b/homeassistant/components/bsblan/translations/no.json index 393024d2eff..040349997f4 100644 --- a/homeassistant/components/bsblan/translations/no.json +++ b/homeassistant/components/bsblan/translations/no.json @@ -12,7 +12,7 @@ "data": { "host": "Vert", "passkey": "Tilgangsn\u00f8kkel streng", - "port": "Port" + "port": "" }, "description": "Konfigurer din BSB-Lan-enhet for \u00e5 integrere med Home Assistant.", "title": "Koble til BSB-Lan-enheten" diff --git a/homeassistant/components/cert_expiry/translations/no.json b/homeassistant/components/cert_expiry/translations/no.json index 3786900bbbd..a7aa3d1ab13 100644 --- a/homeassistant/components/cert_expiry/translations/no.json +++ b/homeassistant/components/cert_expiry/translations/no.json @@ -14,7 +14,7 @@ "data": { "host": "Vert", "name": "Sertifikatets navn", - "port": "Port" + "port": "" }, "title": "Definer sertifikatet som skal testes" } diff --git a/homeassistant/components/control4/translations/ca.json b/homeassistant/components/control4/translations/ca.json new file mode 100644 index 00000000000..8e1b6c4788c --- /dev/null +++ b/homeassistant/components/control4/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Adre\u00e7a IP", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/no.json b/homeassistant/components/deconz/translations/no.json index f25ad1d5886..231901c4cd4 100644 --- a/homeassistant/components/deconz/translations/no.json +++ b/homeassistant/components/deconz/translations/no.json @@ -24,7 +24,7 @@ "manual_input": { "data": { "host": "Vert", - "port": "Port" + "port": "" } }, "user": { diff --git a/homeassistant/components/elgato/translations/no.json b/homeassistant/components/elgato/translations/no.json index 54b84966cdc..bb7e56211de 100644 --- a/homeassistant/components/elgato/translations/no.json +++ b/homeassistant/components/elgato/translations/no.json @@ -12,7 +12,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "description": "Sett opp Elgato Key Light for \u00e5 integrere med Home Assistant." }, diff --git a/homeassistant/components/esphome/translations/no.json b/homeassistant/components/esphome/translations/no.json index 200481cc7b4..3c2dafff34d 100644 --- a/homeassistant/components/esphome/translations/no.json +++ b/homeassistant/components/esphome/translations/no.json @@ -24,7 +24,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "description": "Vennligst fyll inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node." } diff --git a/homeassistant/components/forked_daapd/translations/no.json b/homeassistant/components/forked_daapd/translations/no.json index 8cb7ec812ae..ac58ddf9639 100644 --- a/homeassistant/components/forked_daapd/translations/no.json +++ b/homeassistant/components/forked_daapd/translations/no.json @@ -18,7 +18,7 @@ "host": "Vert", "name": "Vennlig navn", "password": "API-passord (la st\u00e5 tomt hvis ingen passord)", - "port": "API-port" + "port": "" }, "title": "Konfigurere forked-daapd-enhet" } diff --git a/homeassistant/components/freebox/translations/no.json b/homeassistant/components/freebox/translations/no.json index 36152c9a815..0ec9bf70ecd 100644 --- a/homeassistant/components/freebox/translations/no.json +++ b/homeassistant/components/freebox/translations/no.json @@ -16,7 +16,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "title": "" } diff --git a/homeassistant/components/glances/translations/no.json b/homeassistant/components/glances/translations/no.json index 1a03d8f1e4c..dd593c4add6 100644 --- a/homeassistant/components/glances/translations/no.json +++ b/homeassistant/components/glances/translations/no.json @@ -13,7 +13,7 @@ "host": "Vert", "name": "Navn", "password": "Passord", - "port": "Port", + "port": "", "ssl": "Bruk SSL / TLS for \u00e5 koble til Glances-systemet", "username": "Brukernavn", "verify_ssl": "Bekreft sertifiseringen av systemet", diff --git a/homeassistant/components/guardian/translations/no.json b/homeassistant/components/guardian/translations/no.json index b398079cc36..fbe5f881124 100644 --- a/homeassistant/components/guardian/translations/no.json +++ b/homeassistant/components/guardian/translations/no.json @@ -9,7 +9,7 @@ "user": { "data": { "ip_address": "IP adresse", - "port": "Port" + "port": "" }, "description": "Konfigurer en lokal Elexa Guardian-enhet." }, diff --git a/homeassistant/components/ipp/translations/no.json b/homeassistant/components/ipp/translations/no.json index 543deee14fa..4e94efe71c1 100644 --- a/homeassistant/components/ipp/translations/no.json +++ b/homeassistant/components/ipp/translations/no.json @@ -19,7 +19,7 @@ "data": { "base_path": "Relativ bane til skriveren", "host": "Vert", - "port": "Port", + "port": "", "ssl": "Skriveren st\u00f8tter kommunikasjon over SSL/TLS", "verify_ssl": "Skriveren bruker et riktig SSL-sertifikat" }, diff --git a/homeassistant/components/mikrotik/translations/no.json b/homeassistant/components/mikrotik/translations/no.json index 894b70cbbe5..1e528fa4986 100644 --- a/homeassistant/components/mikrotik/translations/no.json +++ b/homeassistant/components/mikrotik/translations/no.json @@ -14,7 +14,7 @@ "host": "Vert", "name": "Navn", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn", "verify_ssl": "Bruk ssl" }, diff --git a/homeassistant/components/monoprice/translations/no.json b/homeassistant/components/monoprice/translations/no.json index 3de551f073a..acd4bde8774 100644 --- a/homeassistant/components/monoprice/translations/no.json +++ b/homeassistant/components/monoprice/translations/no.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "port": "Port", + "port": "", "source_1": "Navn p\u00e5 kilden #1", "source_2": "Navn p\u00e5 kilden #2", "source_3": "Navn p\u00e5 kilden #3", diff --git a/homeassistant/components/mqtt/translations/no.json b/homeassistant/components/mqtt/translations/no.json index e48f70d5bd5..84e4f23a7d0 100644 --- a/homeassistant/components/mqtt/translations/no.json +++ b/homeassistant/components/mqtt/translations/no.json @@ -12,7 +12,7 @@ "broker": "Megler", "discovery": "Aktiver oppdagelse", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "description": "Vennligst fyll ut tilkoblingsinformasjonen for din MQTT megler." diff --git a/homeassistant/components/nut/translations/no.json b/homeassistant/components/nut/translations/no.json index de43f9ead89..6fd749442c3 100644 --- a/homeassistant/components/nut/translations/no.json +++ b/homeassistant/components/nut/translations/no.json @@ -25,7 +25,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "title": "Koble til NUT-serveren" diff --git a/homeassistant/components/onvif/translations/no.json b/homeassistant/components/onvif/translations/no.json index 5f55b264117..4f605a518d7 100644 --- a/homeassistant/components/onvif/translations/no.json +++ b/homeassistant/components/onvif/translations/no.json @@ -35,7 +35,7 @@ "data": { "host": "Vert", "name": "Navn", - "port": "Port" + "port": "" }, "title": "Konfigurere ONVIF-enhet" }, diff --git a/homeassistant/components/plex/translations/no.json b/homeassistant/components/plex/translations/no.json index ab72275070a..45b2d451f2f 100644 --- a/homeassistant/components/plex/translations/no.json +++ b/homeassistant/components/plex/translations/no.json @@ -20,6 +20,7 @@ "step": { "manual_setup": { "data": { + "port": "", "ssl": "Bruk SSL", "token": "Token (valgfritt)", "verify_ssl": "Verifisere SSL-sertifikat" @@ -35,13 +36,13 @@ }, "user": { "description": "Fortsett til [plex.tv] (https://plex.tv) for \u00e5 koble en Plex-server.", - "title": "Plex Media Server" + "title": "" }, "user_advanced": { "data": { "setup_method": "Oppsettmetode" }, - "title": "Plex Media Server" + "title": "" } } }, diff --git a/homeassistant/components/rainmachine/translations/no.json b/homeassistant/components/rainmachine/translations/no.json index cf031e13f10..bc80cdedb31 100644 --- a/homeassistant/components/rainmachine/translations/no.json +++ b/homeassistant/components/rainmachine/translations/no.json @@ -12,7 +12,7 @@ "data": { "ip_address": "Vertsnavn eller IP-adresse", "password": "Passord", - "port": "Port" + "port": "" }, "title": "Fyll ut informasjonen din" } diff --git a/homeassistant/components/simplisafe/translations/ca.json b/homeassistant/components/simplisafe/translations/ca.json index 82cedbbca9f..96dff658906 100644 --- a/homeassistant/components/simplisafe/translations/ca.json +++ b/homeassistant/components/simplisafe/translations/ca.json @@ -5,9 +5,15 @@ }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", - "invalid_credentials": "Credencials inv\u00e0lides" + "invalid_credentials": "Credencials inv\u00e0lides", + "unknown": "Error inesperat" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + } + }, "user": { "data": { "code": "Codi (utilitzat a la UI de Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/it.json b/homeassistant/components/simplisafe/translations/it.json index c970cd6d48b..2d293ab1bb3 100644 --- a/homeassistant/components/simplisafe/translations/it.json +++ b/homeassistant/components/simplisafe/translations/it.json @@ -1,13 +1,27 @@ { "config": { "abort": { - "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso." + "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso.", + "reauth_successful": "SimpliSafe riautenticato correttamente." }, "error": { "identifier_exists": "Account gi\u00e0 registrato", - "invalid_credentials": "Credenziali non valide" + "invalid_credentials": "Credenziali non valide", + "still_awaiting_mfa": "Ancora in attesa del clic sull'email MFA", + "unknown": "Errore imprevisto" }, "step": { + "mfa": { + "description": "Controlla la tua e-mail per trovare un link da SimpliSafe. Dopo aver verificato il link, torna qui per completare l'installazione dell'integrazione.", + "title": "Autenticazione a pi\u00f9 fattori (MFA) SimpliSafe " + }, + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "Il token di accesso \u00e8 scaduto o \u00e8 stato revocato. Inserisci la tua password per ricollegare il tuo account.", + "title": "Ricollegare l'account SimpliSafe" + }, "user": { "data": { "code": "Codice (utilizzato nell'Interfaccia Utente di Home Assistant)", diff --git a/homeassistant/components/soma/translations/no.json b/homeassistant/components/soma/translations/no.json index 4b9fe3b564d..5c2c01ca7a6 100644 --- a/homeassistant/components/soma/translations/no.json +++ b/homeassistant/components/soma/translations/no.json @@ -14,7 +14,7 @@ "user": { "data": { "host": "Vert", - "port": "Port" + "port": "" }, "description": "Vennligst fyll inn tilkoblingsinnstillingene for din SOMA Connect.", "title": "" diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json index 43c3c450f93..f8d7add4dc2 100644 --- a/homeassistant/components/synology_dsm/translations/no.json +++ b/homeassistant/components/synology_dsm/translations/no.json @@ -21,7 +21,7 @@ "link": { "data": { "password": "Passord", - "port": "Port", + "port": "", "ssl": "Bruk SSL/TLS til \u00e5 koble til NAS-en", "username": "Brukernavn" }, @@ -32,7 +32,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "ssl": "Bruk SSL/TLS til \u00e5 koble til NAS-en", "username": "Brukernavn" }, diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json index f88e7c55ea4..a04c32f471a 100644 --- a/homeassistant/components/transmission/translations/no.json +++ b/homeassistant/components/transmission/translations/no.json @@ -14,7 +14,7 @@ "host": "Vert", "name": "Navn", "password": "Passord", - "port": "Port", + "port": "", "username": "Brukernavn" }, "title": "Oppsett av Transmission-klient" diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json index 7b336c3f9c6..11be0aecaeb 100644 --- a/homeassistant/components/tuya/translations/no.json +++ b/homeassistant/components/tuya/translations/no.json @@ -8,7 +8,7 @@ "platform": "Appen der kontoen din registreres" }, "description": "Skriv inn din Tuya-legitimasjon.", - "title": "Tuya" + "title": "" } } } diff --git a/homeassistant/components/unifi/translations/no.json b/homeassistant/components/unifi/translations/no.json index 6e149217f11..a0d207974ec 100644 --- a/homeassistant/components/unifi/translations/no.json +++ b/homeassistant/components/unifi/translations/no.json @@ -13,7 +13,7 @@ "data": { "host": "Vert", "password": "Passord", - "port": "Port", + "port": "", "site": "Nettsted-ID", "username": "Brukernavn", "verify_ssl": "Kontroller bruker riktig sertifikat" diff --git a/homeassistant/components/wolflink/translations/ca.json b/homeassistant/components/wolflink/translations/ca.json new file mode 100644 index 00000000000..ab0c791dbd9 --- /dev/null +++ b/homeassistant/components/wolflink/translations/ca.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "device": { + "data": { + "device_name": "Dispositiu" + } + }, + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.pl.json b/homeassistant/components/wolflink/translations/sensor.pl.json index d06189d4ae4..ba65f3ab992 100644 --- a/homeassistant/components/wolflink/translations/sensor.pl.json +++ b/homeassistant/components/wolflink/translations/sensor.pl.json @@ -73,7 +73,7 @@ "telefonfernschalter": "Zdalne sterowanie za pomoc\u0105 telefonu", "test": "Test", "tpw": "TPW", - "urlaubsmodus": "Tryb urlopowy", + "urlaubsmodus": "Tryb wakacyjny", "ventilprufung": "Kontrola zawor\u00f3w", "vorspulen": "P\u0142ukanie wst\u0119pne", "warmwasser": "CWU", From 2b0914994de62333965ba346883c56f2b336ffb2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 27 Jul 2020 03:00:47 +0200 Subject: [PATCH 157/362] Add changes from comments after merging AccuWeather (#38227) * Fix documentation url * Return None instead STATE_UNKNOWN * Invert forecast check * Patch async_setup_entry in test_create entry * Improve test name, docstring and add comment --- .../components/accuweather/manifest.json | 2 +- .../components/accuweather/weather.py | 58 +++++++++---------- .../accuweather/test_config_flow.py | 10 +++- 3 files changed, 36 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 3b74087a61d..4e54d937dee 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -1,7 +1,7 @@ { "domain": "accuweather", "name": "AccuWeather", - "documentation": "https://github.com/bieniu/ha-accuweather", + "documentation": "https://www.home-assistant.io/integrations/accuweather/", "requirements": ["accuweather==0.0.9"], "codeowners": ["@bieniu"], "config_flow": true diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 866f4821b02..234c03d9e97 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -12,7 +12,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) -from homeassistant.const import CONF_NAME, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.util.dt import utc_from_timestamp from .const import ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, COORDINATOR, DOMAIN @@ -74,7 +74,7 @@ class AccuWeatherEntity(WeatherEntity): if self.coordinator.data["WeatherIcon"] in v ][0] except IndexError: - return STATE_UNKNOWN + return None @property def temperature(self): @@ -124,34 +124,32 @@ class AccuWeatherEntity(WeatherEntity): @property def forecast(self): """Return the forecast array.""" - if self.coordinator.forecast: - # remap keys from library to keys understood by the weather component - forecast = [ - { - ATTR_FORECAST_TIME: utc_from_timestamp( - item["EpochDate"] - ).isoformat(), - ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], - ATTR_FORECAST_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(item), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( - mean( - [ - item["PrecipitationProbabilityDay"], - item["PrecipitationProbabilityNight"], - ] - ) - ), - ATTR_FORECAST_WIND_SPEED: item["WindDay"]["Speed"]["Value"], - ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], - ATTR_FORECAST_CONDITION: [ - k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v - ][0], - } - for item in self.coordinator.data[ATTR_FORECAST] - ] - return forecast - return None + if not self.coordinator.forecast: + return None + # remap keys from library to keys understood by the weather component + forecast = [ + { + ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), + ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], + ATTR_FORECAST_TEMP_LOW: item["TemperatureMin"]["Value"], + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(item), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( + mean( + [ + item["PrecipitationProbabilityDay"], + item["PrecipitationProbabilityNight"], + ] + ) + ), + ATTR_FORECAST_WIND_SPEED: item["WindDay"]["Speed"]["Value"], + ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], + ATTR_FORECAST_CONDITION: [ + k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v + ][0], + } + for item in self.coordinator.data[ATTR_FORECAST] + ] + return forecast async def async_added_to_hass(self): """Connect to dispatcher listening for entity data notifications.""" diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 399a69902e1..6b7430e524d 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -29,8 +29,10 @@ async def test_show_form(hass): assert result["step_id"] == SOURCE_USER -async def test_invalid_api_key_1(hass): - """Test that errors are shown when API key is invalid.""" +async def test_api_key_too_short(hass): + """Test that errors are shown when API key is too short.""" + # The API key length check is done by the library without polling the AccuWeather + # server so we don't need to patch the library method. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -45,7 +47,7 @@ async def test_invalid_api_key_1(hass): assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} -async def test_invalid_api_key_2(hass): +async def test_invalid_api_key(hass): """Test that errors are shown when API key is invalid.""" with patch( "accuweather.AccuWeather._async_get_data", @@ -112,6 +114,8 @@ async def test_create_entry(hass): with patch( "accuweather.AccuWeather._async_get_data", return_value=json.loads(load_fixture("accuweather/location_data.json")), + ), patch( + "homeassistant.components.accuweather.async_setup_entry", return_value=True ): result = await hass.config_entries.flow.async_init( From 56186a3d755379da92cc7afe670736bc1f408bb1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2020 17:01:29 -1000 Subject: [PATCH 158/362] Improve setup retry logic to handle inconsistent powerview hub availability (#38249) --- .../hunterdouglas_powerview/__init__.py | 26 ++++++++++++------- .../hunterdouglas_powerview/const.py | 3 ++- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 3df895c94ce..3f78726bf30 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -112,22 +112,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): try: async with async_timeout.timeout(10): device_info = await async_get_device_info(pv_request) + + async with async_timeout.timeout(10): + rooms = Rooms(pv_request) + room_data = _async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) + + async with async_timeout.timeout(10): + scenes = Scenes(pv_request) + scene_data = _async_map_data_by_id( + (await scenes.get_resources())[SCENE_DATA] + ) + + async with async_timeout.timeout(10): + shades = Shades(pv_request) + shade_data = _async_map_data_by_id( + (await shades.get_resources())[SHADE_DATA] + ) except HUB_EXCEPTIONS: _LOGGER.error("Connection error to PowerView hub: %s", hub_address) raise ConfigEntryNotReady + if not device_info: _LOGGER.error("Unable to initialize PowerView hub: %s", hub_address) raise ConfigEntryNotReady - rooms = Rooms(pv_request) - room_data = _async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - - scenes = Scenes(pv_request) - scene_data = _async_map_data_by_id((await scenes.get_resources())[SCENE_DATA]) - - shades = Shades(pv_request) - shade_data = _async_map_data_by_id((await shades.get_resources())[SHADE_DATA]) - async def async_update_data(): """Fetch data from shade endpoint.""" async with async_timeout.timeout(10): diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index e69fe319c0f..e83a9d8945b 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -2,6 +2,7 @@ import asyncio +from aiohttp.client_exceptions import ServerDisconnectedError from aiopvapi.helpers.aiorequest import PvApiConnectionError DOMAIN = "hunterdouglas_powerview" @@ -64,7 +65,7 @@ PV_SHADE_DATA = "pv_shade_data" PV_ROOM_DATA = "pv_room_data" COORDINATOR = "coordinator" -HUB_EXCEPTIONS = (asyncio.TimeoutError, PvApiConnectionError) +HUB_EXCEPTIONS = (ServerDisconnectedError, asyncio.TimeoutError, PvApiConnectionError) LEGACY_DEVICE_SUB_REVISION = 1 LEGACY_DEVICE_REVISION = 0 From 8fec0da5be4ba2a786480cc19334e863d0d36494 Mon Sep 17 00:00:00 2001 From: Mister Wil <1091741+MisterWil@users.noreply.github.com> Date: Sun, 26 Jul 2020 23:08:01 -0700 Subject: [PATCH 159/362] Fix Skybell useragent (#38245) --- homeassistant/components/skybell/__init__.py | 15 +++++++++++++-- homeassistant/components/skybell/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index a4e4263d360..9606d9bcf12 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -5,7 +5,12 @@ from requests.exceptions import ConnectTimeout, HTTPError from skybellpy import Skybell import voluptuous as vol -from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_PASSWORD, + CONF_USERNAME, + __version__, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -20,6 +25,8 @@ DOMAIN = "skybell" DEFAULT_CACHEDB = "./skybell_cache.pickle" DEFAULT_ENTITY_NAMESPACE = "skybell" +AGENT_IDENTIFIER = f"HomeAssistant/{__version__}" + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -42,7 +49,11 @@ def setup(hass, config): try: cache = hass.config.path(DEFAULT_CACHEDB) skybell = Skybell( - username=username, password=password, get_devices=True, cache_path=cache + username=username, + password=password, + get_devices=True, + cache_path=cache, + agent_identifier=AGENT_IDENTIFIER, ) hass.data[DOMAIN] = skybell diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index 9e0a0be8905..1b97b800956 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -2,6 +2,6 @@ "domain": "skybell", "name": "SkyBell", "documentation": "https://www.home-assistant.io/integrations/skybell", - "requirements": ["skybellpy==0.4.0"], + "requirements": ["skybellpy==0.6.1"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 8259f8f4937..a135e511937 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ simplisafe-python==9.2.1 sisyphus-control==2.2.1 # homeassistant.components.skybell -skybellpy==0.4.0 +skybellpy==0.6.1 # homeassistant.components.slack slackclient==2.5.0 From da30ed06d82ee532a69232c6bf089e8cef088d8f Mon Sep 17 00:00:00 2001 From: MikeTsenatek <10235033+MikeTsenatek@users.noreply.github.com> Date: Mon, 27 Jul 2020 08:17:40 +0200 Subject: [PATCH 160/362] Update holidays to 0.10.3 (#38246) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 393a251bb7f..70d66053209 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.10.2"], + "requirements": ["holidays==0.10.3"], "codeowners": ["@fabaff"], "quality_scale": "internal" } diff --git a/requirements_all.txt b/requirements_all.txt index a135e511937..5246467e758 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -730,7 +730,7 @@ hlk-sw16==0.0.8 hole==0.5.1 # homeassistant.components.workday -holidays==0.10.2 +holidays==0.10.3 # homeassistant.components.frontend home-assistant-frontend==20200716.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 351599ec1a3..5730fbff9c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -356,7 +356,7 @@ herepy==2.0.0 hole==0.5.1 # homeassistant.components.workday -holidays==0.10.2 +holidays==0.10.3 # homeassistant.components.frontend home-assistant-frontend==20200716.0 From 8b06d1d4bd1f65e8b4c8018909d86f7e42f8d620 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jul 2020 20:51:53 -1000 Subject: [PATCH 161/362] Prevent onvif from blocking startup (#38256) --- homeassistant/components/onvif/event.py | 43 +++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 9084a06e7db..60a92e56ac0 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -6,7 +6,7 @@ from aiohttp.client_exceptions import ServerDisconnectedError from onvif import ONVIFCamera, ONVIFService from zeep.exceptions import Fault -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util @@ -114,30 +114,31 @@ class EventManager: async def async_pull_messages(self, _now: dt = None) -> None: """Pull messages from device.""" - try: - pullpoint = self.device.create_pullpoint_service() - req = pullpoint.create_type("PullMessages") - req.MessageLimit = 100 - req.Timeout = dt.timedelta(seconds=60) - response = await pullpoint.PullMessages(req) + if self.hass.state == CoreState.running: + try: + pullpoint = self.device.create_pullpoint_service() + req = pullpoint.create_type("PullMessages") + req.MessageLimit = 100 + req.Timeout = dt.timedelta(seconds=60) + response = await pullpoint.PullMessages(req) - # Renew subscription if less than two hours is left - if ( - dt_util.as_utc(response.TerminationTime) - dt_util.utcnow() - ).total_seconds() < 7200: - await self.async_renew() + # Renew subscription if less than two hours is left + if ( + dt_util.as_utc(response.TerminationTime) - dt_util.utcnow() + ).total_seconds() < 7200: + await self.async_renew() - # Parse response - await self.async_parse_messages(response.NotificationMessage) + # Parse response + await self.async_parse_messages(response.NotificationMessage) - except ServerDisconnectedError: - pass - except Fault: - pass + except ServerDisconnectedError: + pass + except Fault: + pass - # Update entities - for update_callback in self._listeners: - update_callback() + # Update entities + for update_callback in self._listeners: + update_callback() # Reschedule another pull if self._listeners: From b226a7183f6056d4a5a1b1f6af76b521ef11620a Mon Sep 17 00:00:00 2001 From: On Freund Date: Mon, 27 Jul 2020 10:19:19 +0300 Subject: [PATCH 162/362] Add config flow to Volumio (#38252) --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/discovery/__init__.py | 2 +- homeassistant/components/volumio/__init__.py | 60 ++++- .../components/volumio/config_flow.py | 122 +++++++++ homeassistant/components/volumio/const.py | 6 + .../components/volumio/manifest.json | 7 +- .../components/volumio/media_player.py | 190 ++++--------- homeassistant/components/volumio/strings.json | 24 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 3 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/volumio/__init__.py | 1 + tests/components/volumio/test_config_flow.py | 252 ++++++++++++++++++ 15 files changed, 539 insertions(+), 137 deletions(-) create mode 100644 homeassistant/components/volumio/config_flow.py create mode 100644 homeassistant/components/volumio/const.py create mode 100644 homeassistant/components/volumio/strings.json create mode 100644 tests/components/volumio/__init__.py create mode 100644 tests/components/volumio/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index eb24287069e..90ce03476a8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -926,6 +926,7 @@ omit = homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/media_player.py homeassistant/components/volkszaehler/sensor.py + homeassistant/components/volumio/__init__.py homeassistant/components/volumio/media_player.py homeassistant/components/volvooncall/* homeassistant/components/w800rf32/* diff --git a/CODEOWNERS b/CODEOWNERS index f488eace2bd..f5ece6fedf2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -454,6 +454,7 @@ homeassistant/components/vilfo/* @ManneW homeassistant/components/vivotek/* @HarlemSquirrel homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf +homeassistant/components/volumio/* @OnFreund homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 65392fa767a..921b76168ca 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -71,7 +71,6 @@ SERVICE_HANDLERS = { "bose_soundtouch": ("media_player", "soundtouch"), "bluesound": ("media_player", "bluesound"), "kodi": ("media_player", "kodi"), - "volumio": ("media_player", "volumio"), "lg_smart_device": ("media_player", "lg_soundbar"), "nanoleaf_aurora": ("light", "nanoleaf"), } @@ -93,6 +92,7 @@ MIGRATED_SERVICE_HANDLERS = [ "songpal", SERVICE_WEMO, SERVICE_XIAOMI_GW, + "volumio", ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/volumio/__init__.py b/homeassistant/components/volumio/__init__.py index 823533336ba..8d171cab9d2 100644 --- a/homeassistant/components/volumio/__init__.py +++ b/homeassistant/components/volumio/__init__.py @@ -1 +1,59 @@ -"""The volumio component.""" +"""The Volumio integration.""" +import asyncio + +from pyvolumio import CannotConnectError, Volumio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN + +PLATFORMS = ["media_player"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Volumio component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Volumio from a config entry.""" + + volumio = Volumio( + entry.data[CONF_HOST], entry.data[CONF_PORT], async_get_clientsession(hass) + ) + try: + info = await volumio.get_system_version() + except CannotConnectError as error: + raise ConfigEntryNotReady from error + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + DATA_VOLUMIO: volumio, + DATA_INFO: info, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py new file mode 100644 index 00000000000..8b68a4d38de --- /dev/null +++ b/homeassistant/components/volumio/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for Volumio integration.""" +import logging +from typing import Optional + +from pyvolumio import CannotConnectError, Volumio +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import DiscoveryInfoType + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_HOST): str, vol.Required(CONF_PORT, default=3000): int} +) + + +async def validate_input(hass, host, port): + """Validate the user input allows us to connect.""" + volumio = Volumio(host, port, async_get_clientsession(hass)) + + try: + return await volumio.get_system_info() + except CannotConnectError as error: + raise CannotConnect from error + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Volumio.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize flow.""" + self._host: Optional[str] = None + self._port: Optional[int] = None + self._name: Optional[str] = None + self._uuid: Optional[str] = None + + @callback + def _async_get_entry(self): + return self.async_create_entry( + title=self._name, + data={ + CONF_NAME: self._name, + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_ID: self._uuid, + }, + ) + + async def _set_uid_and_abort(self): + await self.async_set_unique_id(self._uuid) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._host, + CONF_PORT: self._port, + CONF_NAME: self._name, + } + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + info = None + try: + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] + info = await validate_input(self.hass, self._host, self._port) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if info is not None: + self._name = info.get("name", self._host) + self._uuid = info.get("id", None) + if self._uuid is not None: + await self._set_uid_and_abort() + + return self._async_get_entry() + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + """Handle zeroconf discovery.""" + self._host = discovery_info["host"] + self._port = int(discovery_info["port"]) + self._name = discovery_info["properties"]["volumioName"] + self._uuid = discovery_info["properties"]["UUID"] + + await self._set_uid_and_abort() + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm(self, user_input=None): + """Handle user-confirmation of discovered node.""" + if user_input is not None: + try: + await validate_input(self.hass, self._host, self._port) + return self._async_get_entry() + except CannotConnect: + return self.async_abort(reason="cannot_connect") + + return self.async_show_form( + step_id="discovery_confirm", description_placeholders={"name": self._name} + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/volumio/const.py b/homeassistant/components/volumio/const.py new file mode 100644 index 00000000000..608c029a85e --- /dev/null +++ b/homeassistant/components/volumio/const.py @@ -0,0 +1,6 @@ +"""Constants for the Volumio integration.""" + +DOMAIN = "volumio" + +DATA_INFO = "info" +DATA_VOLUMIO = "volumio" diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json index 7fed8811600..95d84fd7ee6 100644 --- a/homeassistant/components/volumio/manifest.json +++ b/homeassistant/components/volumio/manifest.json @@ -2,5 +2,8 @@ "domain": "volumio", "name": "Volumio", "documentation": "https://www.home-assistant.io/integrations/volumio", - "codeowners": [] -} + "codeowners": ["@OnFreund"], + "config_flow": true, + "zeroconf": ["_Volumio._tcp.local."], + "requirements": ["pyvolumio==0.1"] +} \ No newline at end of file diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 58b3b4a04ba..0fadb5b51ed 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -3,15 +3,10 @@ Volumio Platform. Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ -import asyncio from datetime import timedelta import logging -import socket -import aiohttp -import voluptuous as vol - -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST, @@ -28,29 +23,19 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_STEP, ) from homeassistant.const import ( - CONF_HOST, + CONF_ID, CONF_NAME, - CONF_PORT, - HTTP_OK, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +from .const import DATA_INFO, DATA_VOLUMIO, DOMAIN + _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "Volumio" -DEFAULT_PORT = 3000 - -DATA_VOLUMIO = "volumio" - -TIMEOUT = 10 - SUPPORT_VOLUMIO = ( SUPPORT_PAUSE | SUPPORT_VOLUME_SET @@ -68,91 +53,59 @@ SUPPORT_VOLUMIO = ( PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Volumio media player platform.""" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Volumio platform.""" - if DATA_VOLUMIO not in hass.data: - hass.data[DATA_VOLUMIO] = {} + data = hass.data[DOMAIN][config_entry.entry_id] + volumio = data[DATA_VOLUMIO] + info = data[DATA_INFO] + uid = config_entry.data[CONF_ID] + name = config_entry.data[CONF_NAME] - # This is a manual configuration? - if discovery_info is None: - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - else: - name = "{} ({})".format(DEFAULT_NAME, discovery_info.get("hostname")) - host = discovery_info.get("host") - port = discovery_info.get("port") - - # Only add a device once, so discovered devices do not override manual - # config. - ip_addr = socket.gethostbyname(host) - if ip_addr in hass.data[DATA_VOLUMIO]: - return - - entity = Volumio(name, host, port, hass) - - hass.data[DATA_VOLUMIO][ip_addr] = entity + entity = Volumio(hass, volumio, uid, name, info) async_add_entities([entity]) class Volumio(MediaPlayerEntity): """Volumio Player Object.""" - def __init__(self, name, host, port, hass): + def __init__(self, hass, volumio, uid, name, info): """Initialize the media player.""" - self.host = host - self.port = port - self.hass = hass - self._url = "{}:{}".format(host, str(port)) + self._hass = hass + self._volumio = volumio + self._uid = uid self._name = name + self._info = info self._state = {} - self._lastvol = self._state.get("volume", 0) self._playlists = [] self._currentplaylist = None - async def send_volumio_msg(self, method, params=None): - """Send message.""" - url = f"http://{self.host}:{self.port}/api/v1/{method}/" - - _LOGGER.debug("URL: %s params: %s", url, params) - - try: - websession = async_get_clientsession(self.hass) - response = await websession.get(url, params=params) - if response.status == HTTP_OK: - data = await response.json() - else: - _LOGGER.error( - "Query failed, response code: %s Full message: %s", - response.status, - response, - ) - return False - - except (asyncio.TimeoutError, aiohttp.ClientError) as error: - _LOGGER.error( - "Failed communicating with Volumio '%s': %s", self._name, type(error) - ) - return False - - return data - async def async_update(self): """Update state.""" - resp = await self.send_volumio_msg("getState") + self._state = await self._volumio.get_state() await self._async_update_playlists() - if resp is False: - return - self._state = resp.copy() + + @property + def unique_id(self): + """Return the unique id for the entity.""" + return self._uid + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def device_info(self): + """Return device info for this device.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Volumio", + "sw_version": self._info["systemversion"], + "model": self._info["hardware"], + } @property def media_content_type(self): @@ -189,13 +142,7 @@ class Volumio(MediaPlayerEntity): def media_image_url(self): """Image url of current playing media.""" url = self._state.get("albumart", None) - if url is None: - return - if str(url[0:2]).lower() == "ht": - mediaurl = url - else: - mediaurl = f"http://{self.host}:{self.port}{url}" - return mediaurl + return self._volumio.canonic_url(url) @property def media_seek_position(self): @@ -220,11 +167,6 @@ class Volumio(MediaPlayerEntity): """Boolean if volume is currently muted.""" return self._state.get("mute", None) - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def shuffle(self): """Boolean if shuffle is enabled.""" @@ -247,79 +189,61 @@ class Volumio(MediaPlayerEntity): async def async_media_next_track(self): """Send media_next command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "next"}) + await self._volumio.next() async def async_media_previous_track(self): """Send media_previous command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "prev"}) + await self._volumio.previous() async def async_media_play(self): """Send media_play command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "play"}) + await self._volumio.play() async def async_media_pause(self): """Send media_pause command to media player.""" if self._state["trackType"] == "webradio": - await self.send_volumio_msg("commands", params={"cmd": "stop"}) + await self._volumio.stop() else: - await self.send_volumio_msg("commands", params={"cmd": "pause"}) + await self._volumio.pause() async def async_media_stop(self): """Send media_stop command to media player.""" - await self.send_volumio_msg("commands", params={"cmd": "stop"}) + await self._volumio.stop() async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": int(volume * 100)} - ) + await self._volumio.set_volume_level(int(volume * 100)) async def async_volume_up(self): """Service to send the Volumio the command for volume up.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": "plus"} - ) + await self._volumio.volume_up() async def async_volume_down(self): """Service to send the Volumio the command for volume down.""" - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": "minus"} - ) + await self._volumio.volume_down() async def async_mute_volume(self, mute): """Send mute command to media player.""" - mutecmd = "mute" if mute else "unmute" if mute: - # mute is implemented as 0 volume, do save last volume level - self._lastvol = self._state["volume"] - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": mutecmd} - ) - return - - await self.send_volumio_msg( - "commands", params={"cmd": "volume", "volume": self._lastvol} - ) + await self._volumio.mute() + else: + await self._volumio.unmute() async def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - await self.send_volumio_msg( - "commands", params={"cmd": "random", "value": str(shuffle).lower()} - ) + await self._volumio.set_shuffle(shuffle) async def async_select_source(self, source): - """Choose a different available playlist and play it.""" + """Choose an available playlist and play it.""" + await self._volumio.play_playlist(source) self._currentplaylist = source - await self.send_volumio_msg( - "commands", params={"cmd": "playplaylist", "name": source} - ) async def async_clear_playlist(self): """Clear players playlist.""" + await self._volumio.clear_playlist() self._currentplaylist = None - await self.send_volumio_msg("commands", params={"cmd": "clearQueue"}) @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = await self.send_volumio_msg("listplaylists") + self._playlists = await self._volumio.get_playlists() diff --git a/homeassistant/components/volumio/strings.json b/homeassistant/components/volumio/strings.json new file mode 100644 index 00000000000..ffa53b2c438 --- /dev/null +++ b/homeassistant/components/volumio/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "discovery_confirm": { + "description": "Do you want to add Volumio (`{name}`) to Home Assistant?", + "title": "Discovered Volumio" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect to discovered Volumio" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 736f16e3581..df56e071923 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -184,6 +184,7 @@ FLOWS = [ "vesync", "vilfo", "vizio", + "volumio", "wemo", "wiffi", "withings", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index dc2bd289930..872b07f5c6a 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -6,6 +6,9 @@ To update, run python3 -m script.hassfest # fmt: off ZEROCONF = { + "_Volumio._tcp.local.": [ + "volumio" + ], "_api._udp.local.": [ "guardian" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5246467e758..96adaf77fad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1827,6 +1827,9 @@ pyvizio==0.1.49 # homeassistant.components.velux pyvlx==0.2.16 +# homeassistant.components.volumio +pyvolumio==0.1 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5730fbff9c4..1ce16d7e51f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -826,6 +826,9 @@ pyvesync==1.1.0 # homeassistant.components.vizio pyvizio==0.1.49 +# homeassistant.components.volumio +pyvolumio==0.1 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/volumio/__init__.py b/tests/components/volumio/__init__.py new file mode 100644 index 00000000000..7d8a443aaf8 --- /dev/null +++ b/tests/components/volumio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volumio integration.""" diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py new file mode 100644 index 00000000000..a7ed4773142 --- /dev/null +++ b/tests/components/volumio/test_config_flow.py @@ -0,0 +1,252 @@ +"""Test the Volumio config flow.""" +from homeassistant import config_entries +from homeassistant.components.volumio.config_flow import CannotConnectError +from homeassistant.components.volumio.const import DOMAIN + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +TEST_SYSTEM_INFO = {"id": "1111-1111-1111-1111", "name": "TestVolumio"} + + +TEST_CONNECTION = { + "host": "1.1.1.1", + "port": 3000, +} + + +TEST_DISCOVERY = { + "host": "1.1.1.1", + "port": 3000, + "properties": {"volumioName": "discovered", "UUID": "2222-2222-2222-2222"}, +} + +TEST_DISCOVERY_RESULT = { + "host": TEST_DISCOVERY["host"], + "port": TEST_DISCOVERY["port"], + "id": TEST_DISCOVERY["properties"]["UUID"], + "name": TEST_DISCOVERY["properties"]["volumioName"], +} + + +async def test_form(hass): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "TestVolumio" + assert result2["data"] == {**TEST_SYSTEM_INFO, **TEST_CONNECTION} + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_updates_unique_id(hass): + """Test a duplicate id aborts and updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SYSTEM_INFO["id"], + data={ + "host": "dummy", + "port": 11, + "name": "dummy", + "id": TEST_SYSTEM_INFO["id"], + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch("homeassistant.components.volumio.async_setup", return_value=True), patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + assert entry.data == {**TEST_SYSTEM_INFO, **TEST_CONNECTION} + + +async def test_empty_system_info(hass): + """Test old volumio versions with empty system info.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value={}, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_CONNECTION["host"] + assert result2["data"] == { + "host": TEST_CONNECTION["host"], + "port": TEST_CONNECTION["port"], + "name": TEST_CONNECTION["host"], + "id": None, + } + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=CannotConnectError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_exception(hass): + """Test we handle generic error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_CONNECTION, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_discovery(hass): + """Test discovery flow works.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + return_value=TEST_SYSTEM_INFO, + ), patch( + "homeassistant.components.volumio.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.volumio.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_DISCOVERY_RESULT["name"] + assert result2["data"] == TEST_DISCOVERY_RESULT + + assert result2["result"] + assert result2["result"].unique_id == TEST_DISCOVERY_RESULT["id"] + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_discovery_cannot_connect(hass): + """Test discovery aborts if cannot connect.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + with patch( + "homeassistant.components.volumio.config_flow.Volumio.get_system_info", + side_effect=CannotConnectError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "cannot_connect" + + +async def test_discovery_duplicate_data(hass): + """Test discovery aborts if same mDNS packet arrives.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + assert result["type"] == "form" + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + assert result["type"] == "abort" + assert result["reason"] == "already_in_progress" + + +async def test_discovery_updates_unique_id(hass): + """Test a duplicate discovery id aborts and updates existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_DISCOVERY_RESULT["id"], + data={ + "host": "dummy", + "port": 11, + "name": "dummy", + "id": TEST_DISCOVERY_RESULT["id"], + }, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + assert entry.data == TEST_DISCOVERY_RESULT From 569caf9e402c22b11f5624a025b9529bc2ed26e3 Mon Sep 17 00:00:00 2001 From: Markus Bong Date: Mon, 27 Jul 2020 10:17:45 +0200 Subject: [PATCH 163/362] Change devolo Home Control entity naming (#38275) * adding suffix after the entity name just in case the device class is not known * remove if else --- .../devolo_home_control/binary_sensor.py | 28 ++++++++++--------- .../components/devolo_home_control/sensor.py | 8 +++++- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index 3f88212646d..26c27f59ae8 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -50,10 +50,21 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): def __init__(self, homecontrol, device_instance, element_uid): """Initialize a devolo binary sensor.""" - if device_instance.binary_sensor_property.get(element_uid).sub_type != "": - name = f"{device_instance.itemName} {device_instance.binary_sensor_property.get(element_uid).sub_type}" - else: - name = f"{device_instance.itemName} {device_instance.binary_sensor_property.get(element_uid).sensor_type}" + self._binary_sensor_property = device_instance.binary_sensor_property.get( + element_uid + ) + + self._device_class = DEVICE_CLASS_MAPPING.get( + self._binary_sensor_property.sub_type + or self._binary_sensor_property.sensor_type + ) + name = device_instance.itemName + + if self._device_class is None: + if device_instance.binary_sensor_property.get(element_uid).sub_type != "": + name += f" {device_instance.binary_sensor_property.get(element_uid).sub_type}" + else: + name += f" {device_instance.binary_sensor_property.get(element_uid).sensor_type}" super().__init__( homecontrol=homecontrol, @@ -63,15 +74,6 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): sync=self._sync, ) - self._binary_sensor_property = self._device_instance.binary_sensor_property.get( - self._unique_id - ) - - self._device_class = DEVICE_CLASS_MAPPING.get( - self._binary_sensor_property.sub_type - or self._binary_sensor_property.sensor_type - ) - self._state = self._binary_sensor_property.state self._subscriber = None diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 0f02f6d0dd0..31bee42e4a2 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -53,13 +53,19 @@ class DevoloMultiLevelDeviceEntity(DevoloDeviceEntity): self._device_class = DEVICE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) + + name = device_instance.itemName + + if self._device_class is None: + name += f" {self._multi_level_sensor_property.sensor_type}" + self._unit = self._multi_level_sensor_property.unit super().__init__( homecontrol=homecontrol, device_instance=device_instance, element_uid=element_uid, - name=f"{device_instance.itemName} {self._multi_level_sensor_property.sensor_type}", + name=name, sync=self._sync, ) From 1a760c63d0f8296f7151fcbd452d0909dbceb4cf Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 27 Jul 2020 03:43:58 -0500 Subject: [PATCH 164/362] Fix parallel script containing repeat or choose action with max_runs > 10 (#38243) --- homeassistant/helpers/script.py | 3 +++ tests/helpers/test_script.py | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index bc6e4bdbd36..cad0a272e47 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -809,6 +809,7 @@ class Script: action[CONF_REPEAT][CONF_SEQUENCE], f"{self.name}: {step_name}", script_mode=SCRIPT_MODE_PARALLEL, + max_runs=self.max_runs, logger=self._logger, top_level=False, ) @@ -836,6 +837,7 @@ class Script: choice[CONF_SEQUENCE], f"{self.name}: {step_name}: choice {idx}", script_mode=SCRIPT_MODE_PARALLEL, + max_runs=self.max_runs, logger=self._logger, top_level=False, ) @@ -850,6 +852,7 @@ class Script: action[CONF_DEFAULT], f"{self.name}: {step_name}: default", script_mode=SCRIPT_MODE_PARALLEL, + max_runs=self.max_runs, logger=self._logger, top_level=False, ) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index ab30b0457c5..8b61a5db64b 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1005,6 +1005,31 @@ async def test_choose(hass, var, result): assert events[0].data["choice"] == result +@pytest.mark.parametrize( + "action", + [ + {"repeat": {"count": 1, "sequence": {"event": "abc"}}}, + {"choose": {"conditions": [], "sequence": {"event": "abc"}}}, + {"choose": [], "default": {"event": "abc"}}, + ], +) +async def test_multiple_runs_repeat_choose(hass, caplog, action): + """Test parallel runs with repeat & choose actions & max_runs > default.""" + max_runs = script.DEFAULT_MAX + 1 + script_obj = script.Script( + hass, cv.SCRIPT_SCHEMA(action), script_mode="parallel", max_runs=max_runs + ) + + events = async_capture_events(hass, "abc") + for _ in range(max_runs): + hass.async_create_task(script_obj.async_run()) + await hass.async_block_till_done() + + assert "WARNING" not in caplog.text + assert "ERROR" not in caplog.text + assert len(events) == max_runs + + async def test_last_triggered(hass): """Test the last_triggered.""" event = "test_event" From 818949abd9529734aceac397a00cefb9c9fd34c2 Mon Sep 17 00:00:00 2001 From: James Callaghan Date: Mon, 27 Jul 2020 14:19:10 +0100 Subject: [PATCH 165/362] Corrected typo (#38278) --- homeassistant/components/tado/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/services.yaml b/homeassistant/components/tado/services.yaml index 5a0fd78d26e..864511982a3 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -1,5 +1,5 @@ set_climate_timer: - description: Turn on cliate entities for a set time. + description: Turn on climate entities for a set time. fields: entity_id: description: Entity ID for the tado component to turn on for a set time. From 26bb6042431ee532b329cbdbbb6e73c5fb412b61 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 27 Jul 2020 14:20:18 +0100 Subject: [PATCH 166/362] Remove evohome hvac_action as it is inaccurate (#38244) --- homeassistant/components/evohome/climate.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index c6edb4aa1dc..e99cae5e22e 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -5,9 +5,6 @@ from typing import List, Optional from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, HVAC_MODE_AUTO, HVAC_MODE_HEAT, HVAC_MODE_OFF, @@ -200,19 +197,6 @@ class EvoZone(EvoChild, EvoClimateEntity): is_off = self.target_temperature <= self.min_temp return HVAC_MODE_OFF if is_off else HVAC_MODE_HEAT - @property - def hvac_action(self) -> Optional[str]: - """Return the current running hvac operation if supported.""" - if self._evo_tcs.systemModeStatus["mode"] == EVO_HEATOFF: - return CURRENT_HVAC_OFF - if self.target_temperature <= self.min_temp: - return CURRENT_HVAC_OFF - if not self._evo_device.temperatureStatus["isAvailable"]: - return None - if self.target_temperature <= self.current_temperature: - return CURRENT_HVAC_IDLE - return CURRENT_HVAC_HEAT - @property def target_temperature(self) -> float: """Return the target temperature of a Zone.""" From f38e1ae2c0caf17c4606dc72786f59e60b0a22ed Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Mon, 27 Jul 2020 16:15:28 +0100 Subject: [PATCH 167/362] Don't set up callbacks until entity is created. (#38251) --- homeassistant/components/vera/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index b636477b16d..263f5f0025b 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -194,7 +194,9 @@ class VeraDevice(Entity): slugify(vera_device.name), vera_device.device_id ) - self.controller.register(vera_device, self._update_callback) + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.controller.register(self.vera_device, self._update_callback) def _update_callback(self, _device): """Update the state.""" From ddf7ceecd40167585cdc243f538b4aac1f7ef404 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2020 06:31:30 -1000 Subject: [PATCH 168/362] Prevent harmony from resetting state with multiple turn ons (#38183) Automations or HomeKit may turn the device on multiple times when the current activity is already active which will cause harmony to lose state. This behavior is unexpected as turning the device on when its already on isn't expected to reset state. --- homeassistant/components/harmony/remote.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 7b49321c7b0..badd1cc9508 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -136,7 +136,7 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): self._name = name self.host = host self._state = None - self._current_activity = None + self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._client = HarmonyClient(ip_address=host) self._config_path = out_path @@ -340,19 +340,33 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): if activity: activity_id = None + activity_name = None + if activity.isdigit() or activity == "-1": _LOGGER.debug("%s: Activity is numeric", self.name) - if self._client.get_activity_name(int(activity)): + activity_name = self._client.get_activity_name(int(activity)) + if activity_name: activity_id = activity if activity_id is None: _LOGGER.debug("%s: Find activity ID based on name", self.name) - activity_id = self._client.get_activity_id(str(activity)) + activity_name = str(activity) + activity_id = self._client.get_activity_id(activity_name) if activity_id is None: _LOGGER.error("%s: Activity %s is invalid", self.name, activity) return + if self._current_activity == activity_name: + # Automations or HomeKit may turn the device on multiple times + # when the current activity is already active which will cause + # harmony to loose state. This behavior is unexpected as turning + # the device on when its already on isn't expected to reset state. + _LOGGER.debug( + "%s: Current activity is already %s", self.name, activity_name + ) + return + try: await self._client.start_activity(activity_id) except aioexc.TimeOut: From 561e4b537a54d514f6d94096fda8e174ac2fbf83 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Mon, 27 Jul 2020 15:56:39 -0300 Subject: [PATCH 169/362] Fix #38289 issue with xboxapi lib (#38293) --- homeassistant/components/xbox_live/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json index 3ebffc425ad..937f33bd009 100644 --- a/homeassistant/components/xbox_live/manifest.json +++ b/homeassistant/components/xbox_live/manifest.json @@ -2,6 +2,6 @@ "domain": "xbox_live", "name": "Xbox Live", "documentation": "https://www.home-assistant.io/integrations/xbox_live", - "requirements": ["xboxapi==2.0.0"], + "requirements": ["xboxapi==2.0.1"], "codeowners": ["@MartinHjelmare"] } diff --git a/requirements_all.txt b/requirements_all.txt index 96adaf77fad..b5d79daba24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2228,7 +2228,7 @@ wolf_smartset==0.1.4 xbee-helper==0.0.7 # homeassistant.components.xbox_live -xboxapi==2.0.0 +xboxapi==2.0.1 # homeassistant.components.xfinity xfinity-gateway==0.0.4 From bea1570354b58c08f26577ad473340cc3c9421f6 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 27 Jul 2020 22:17:07 +0100 Subject: [PATCH 170/362] Delint recent change to evohome (#38294) --- homeassistant/components/evohome/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f54bba1c58d..f429f5bd2f1 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -614,9 +614,11 @@ class EvoChild(EvoDevice): @property def current_temperature(self) -> Optional[float]: """Return the current temperature of a Zone.""" - if self._evo_broker.temps: - if self._evo_broker.temps[self._evo_device.zoneId] != 128: - return self._evo_broker.temps[self._evo_device.zoneId] + if ( + self._evo_broker.temps + and self._evo_broker.temps[self._evo_device.zoneId] != 128 + ): + return self._evo_broker.temps[self._evo_device.zoneId] if self._evo_device.temperatureStatus["isAvailable"]: return self._evo_device.temperatureStatus["temperature"] From 1158925b53850696706a305a9b5b436f331f6464 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 27 Jul 2020 16:51:34 -0500 Subject: [PATCH 171/362] Fix repeat action when variables present (#38237) --- homeassistant/helpers/script.py | 38 +++++++++++++++++---- tests/helpers/test_script.py | 58 +++++++++++++++++++++------------ 2 files changed, 69 insertions(+), 27 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index cad0a272e47..1f9963e184b 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -4,7 +4,19 @@ from datetime import datetime from functools import partial import itertools import logging -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple +from types import MappingProxyType +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Sequence, + Set, + Tuple, + Union, + cast, +) from async_timeout import timeout import voluptuous as vol @@ -49,7 +61,7 @@ from homeassistant.helpers.service import ( CONF_SERVICE_DATA, async_prepare_call_from_config, ) -from homeassistant.helpers.typing import ConfigType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify from homeassistant.util.dt import utcnow @@ -134,13 +146,13 @@ class _ScriptRun: self, hass: HomeAssistant, script: "Script", - variables: TemplateVarsType, + variables: Dict[str, Any], context: Optional[Context], log_exceptions: bool, ) -> None: self._hass = hass self._script = script - self._variables = variables or {} + self._variables = variables self._context = context self._log_exceptions = log_exceptions self._step = -1 @@ -595,6 +607,9 @@ async def _async_stop_scripts_at_shutdown(hass, event): ) +_VarsType = Union[Dict[str, Any], MappingProxyType] + + class Script: """Representation of a script.""" @@ -617,6 +632,7 @@ class Script: hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, partial(_async_stop_scripts_at_shutdown, hass) ) + self._top_level = top_level if top_level: all_scripts.append( {"instance": self, "started_before_shutdown": not hass.is_stopping} @@ -745,7 +761,7 @@ class Script: return referenced def run( - self, variables: TemplateVarsType = None, context: Optional[Context] = None + self, variables: Optional[_VarsType] = None, context: Optional[Context] = None ) -> None: """Run script.""" asyncio.run_coroutine_threadsafe( @@ -753,7 +769,7 @@ class Script: ).result() async def async_run( - self, variables: TemplateVarsType = None, context: Optional[Context] = None + self, variables: Optional[_VarsType] = None, context: Optional[Context] = None ) -> None: """Run script.""" if self.is_running: @@ -767,11 +783,19 @@ class Script: self._log("Maximum number of runs exceeded", level=logging.WARNING) return + # If this is a top level Script then make a copy of the variables in case they + # are read-only, but more importantly, so as not to leak any variables created + # during the run back to the caller. + if self._top_level: + variables = dict(variables) if variables is not None else {} + if self.script_mode != SCRIPT_MODE_QUEUED: cls = _ScriptRun else: cls = _QueuedScriptRun - run = cls(self._hass, self, variables, context, self._log_exceptions) + run = cls( + self._hass, self, cast(dict, variables), context, self._log_exceptions + ) self._runs.append(run) try: diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8b61a5db64b..28761c0ba17 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4,6 +4,7 @@ import asyncio from contextlib import contextmanager from datetime import timedelta import logging +from types import MappingProxyType from unittest import mock import pytest @@ -122,7 +123,7 @@ async def test_firing_event_template(hass): ) script_obj = script.Script(hass, sequence) - await script_obj.async_run({"is_world": "yes"}, context=context) + await script_obj.async_run(MappingProxyType({"is_world": "yes"}), context=context) await hass.async_block_till_done() assert len(events) == 1 @@ -175,7 +176,7 @@ async def test_calling_service_template(hass): ) script_obj = script.Script(hass, sequence) - await script_obj.async_run({"is_world": "yes"}, context=context) + await script_obj.async_run(MappingProxyType({"is_world": "yes"}), context=context) await hass.async_block_till_done() assert len(calls) == 1 @@ -235,7 +236,9 @@ async def test_multiple_runs_no_wait(hass): logger.debug("starting 1st script") hass.async_create_task( script_obj.async_run( - {"fire1": "1", "listen1": "2", "fire2": "3", "listen2": "4"} + MappingProxyType( + {"fire1": "1", "listen1": "2", "fire2": "3", "listen2": "4"} + ) ) ) await asyncio.wait_for(heard_event.wait(), 1) @@ -243,7 +246,7 @@ async def test_multiple_runs_no_wait(hass): logger.debug("starting 2nd script") await script_obj.async_run( - {"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"} + MappingProxyType({"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"}) ) await hass.async_block_till_done() @@ -670,7 +673,9 @@ async def test_wait_template_variables(hass): try: hass.states.async_set("switch.test", "on") - hass.async_create_task(script_obj.async_run({"data": "switch.test"})) + hass.async_create_task( + script_obj.async_run(MappingProxyType({"data": "switch.test"})) + ) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running @@ -882,7 +887,14 @@ async def test_repeat_var_in_condition(hass, condition): assert len(events) == 2 -async def test_repeat_nested(hass): +@pytest.mark.parametrize( + "variables,first_last,inside_x", + [ + (None, {"repeat": "None", "x": "None"}, "None"), + (MappingProxyType({"x": 1}), {"repeat": "None", "x": "1"}, "1"), + ], +) +async def test_repeat_nested(hass, variables, first_last, inside_x): """Test nested repeats.""" event = "test_event" events = async_capture_events(hass, event) @@ -892,7 +904,8 @@ async def test_repeat_nested(hass): { "event": event, "event_data_template": { - "repeat": "{{ None if repeat is not defined else repeat }}" + "repeat": "{{ None if repeat is not defined else repeat }}", + "x": "{{ None if x is not defined else x }}", }, }, { @@ -905,6 +918,7 @@ async def test_repeat_nested(hass): "first": "{{ repeat.first }}", "index": "{{ repeat.index }}", "last": "{{ repeat.last }}", + "x": "{{ None if x is not defined else x }}", }, }, { @@ -916,6 +930,7 @@ async def test_repeat_nested(hass): "first": "{{ repeat.first }}", "index": "{{ repeat.index }}", "last": "{{ repeat.last }}", + "x": "{{ None if x is not defined else x }}", }, }, } @@ -926,6 +941,7 @@ async def test_repeat_nested(hass): "first": "{{ repeat.first }}", "index": "{{ repeat.index }}", "last": "{{ repeat.last }}", + "x": "{{ None if x is not defined else x }}", }, }, ], @@ -934,7 +950,8 @@ async def test_repeat_nested(hass): { "event": event, "event_data_template": { - "repeat": "{{ None if repeat is not defined else repeat }}" + "repeat": "{{ None if repeat is not defined else repeat }}", + "x": "{{ None if x is not defined else x }}", }, }, ] @@ -945,21 +962,21 @@ async def test_repeat_nested(hass): "homeassistant.helpers.condition._LOGGER.error", side_effect=AssertionError("Template Error"), ): - await script_obj.async_run() + await script_obj.async_run(variables) assert len(events) == 10 - assert events[0].data == {"repeat": "None"} - assert events[-1].data == {"repeat": "None"} + assert events[0].data == first_last + assert events[-1].data == first_last for index, result in enumerate( ( - ("True", "1", "False"), - ("True", "1", "False"), - ("False", "2", "True"), - ("True", "1", "False"), - ("False", "2", "True"), - ("True", "1", "False"), - ("False", "2", "True"), - ("False", "2", "True"), + ("True", "1", "False", inside_x), + ("True", "1", "False", inside_x), + ("False", "2", "True", inside_x), + ("True", "1", "False", inside_x), + ("False", "2", "True", inside_x), + ("True", "1", "False", inside_x), + ("False", "2", "True", inside_x), + ("False", "2", "True", inside_x), ), 1, ): @@ -967,6 +984,7 @@ async def test_repeat_nested(hass): "first": result[0], "index": result[1], "last": result[2], + "x": result[3], } @@ -998,7 +1016,7 @@ async def test_choose(hass, var, result): ) script_obj = script.Script(hass, sequence) - await script_obj.async_run({"var": var}) + await script_obj.async_run(MappingProxyType({"var": var})) await hass.async_block_till_done() assert len(events) == 1 From c3966a5ef27efb8493720f7f0824406caa1797a6 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 28 Jul 2020 00:29:35 +0200 Subject: [PATCH 172/362] Setup rfxtrx event listener directly (#38298) --- homeassistant/components/rfxtrx/__init__.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index b1b196dbfba..5f350376ec0 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -18,7 +18,6 @@ from homeassistant.const import ( CONF_DEVICES, CONF_HOST, CONF_PORT, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, POWER_WATT, TEMP_CELSIUS, @@ -289,23 +288,17 @@ async def async_setup_internal(hass, entry: config_entries.ConfigEntry): hass.config_entries.async_update_entry(entry=entry, data=data) devices[device_id] = config - @callback - def _start_rfxtrx(event): - """Start receiving events.""" - rfx_object.event_callback = lambda event: hass.add_job( - async_handle_receive, event - ) - def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" rfx_object.close_connection() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) listener = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) hass.data[DOMAIN][DATA_LISTENER] = listener hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object + rfx_object.event_callback = lambda event: hass.add_job(async_handle_receive, event) + def send(call): event = call.data[ATTR_EVENT] rfx_object.transport.send(event) From c93fc8af4ab389df7d95c30aa00cb3629c2ead2d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 28 Jul 2020 00:44:30 +0200 Subject: [PATCH 173/362] Clean up commands generation for rfxtrx (#38236) --- homeassistant/components/rfxtrx/__init__.py | 37 ++------------------- homeassistant/components/rfxtrx/cover.py | 18 ++++++---- homeassistant/components/rfxtrx/light.py | 18 ++++++---- homeassistant/components/rfxtrx/switch.py | 14 ++++---- 4 files changed, 34 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 5f350376ec0..04d2078e182 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -487,38 +487,7 @@ class RfxtrxCommandEntity(RfxtrxEntity): self.signal_repetitions = signal_repetitions self._state = None - def _send_command(self, command, brightness=0): + async def _async_send(self, fun, *args): rfx_object = self.hass.data[DOMAIN][DATA_RFXOBJECT] - - if command == "turn_on": - for _ in range(self.signal_repetitions): - self._device.send_on(rfx_object.transport) - self._state = True - - elif command == "dim": - for _ in range(self.signal_repetitions): - self._device.send_dim(rfx_object.transport, brightness) - self._state = True - - elif command == "turn_off": - for _ in range(self.signal_repetitions): - self._device.send_off(rfx_object.transport) - self._state = False - - elif command == "roll_up": - for _ in range(self.signal_repetitions): - self._device.send_open(rfx_object.transport) - self._state = True - - elif command == "roll_down": - for _ in range(self.signal_repetitions): - self._device.send_close(rfx_object.transport) - self._state = False - - elif command == "stop_roll": - for _ in range(self.signal_repetitions): - self._device.send_stop(rfx_object.transport) - self._state = True - - if self.hass: - self.schedule_update_ha_state() + for _ in range(self.signal_repetitions): + await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 58bc27d95bb..8b5886191a5 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -98,17 +98,23 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): """Return if the cover is closed.""" return not self._state - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" - self._send_command("roll_up") + await self._async_send(self._device.send_open) + self._state = True + self.async_write_ha_state() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" - self._send_command("roll_down") + await self._async_send(self._device.send_close) + self._state = False + self.async_write_ha_state() - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - self._send_command("stop_roll") + await self._async_send(self._device.send_stop) + self._state = True + self.async_write_ha_state() def _apply_event(self, event): """Apply command from rfxtrx.""" diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index ab24520e485..44472b6c33c 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -125,21 +125,25 @@ class RfxtrxLight(RfxtrxCommandEntity, LightEntity): """Return true if device is on.""" return self._state - def turn_on(self, **kwargs): - """Turn the light on.""" + async def async_turn_on(self, **kwargs): + """Turn the device on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) + self._state = True if brightness is None: + await self._async_send(self._device.send_on) self._brightness = 255 - self._send_command("turn_on") else: + await self._async_send(self._device.send_dim, brightness * 100 // 255) self._brightness = brightness - _brightness = brightness * 100 // 255 - self._send_command("dim", _brightness) - def turn_off(self, **kwargs): + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): """Turn the device off.""" + await self._async_send(self._device.send_off) + self._state = False self._brightness = 0 - self._send_command("turn_off") + self.async_write_ha_state() def _apply_event(self, event): """Apply command from rfxtrx.""" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 6f3cfa5f773..9b2c3c60539 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -127,12 +127,14 @@ class RfxtrxSwitch(RfxtrxCommandEntity, SwitchEntity): """Return true if device is on.""" return self._state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - self._send_command("turn_on") - self.schedule_update_ha_state() + await self._async_send(self._device.send_on) + self._state = True + self.async_write_ha_state() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - self._send_command("turn_off") - self.schedule_update_ha_state() + await self._async_send(self._device.send_off) + self._state = False + self.async_write_ha_state() From a1e2bce1b98e67c6d3d3fb2dff914e8644b9c103 Mon Sep 17 00:00:00 2001 From: Jeroen Van den Keybus Date: Tue, 28 Jul 2020 01:40:21 +0200 Subject: [PATCH 174/362] Fix detection of zones 2 and 3 in Onkyo/Pioneer amplifiers (#38234) --- homeassistant/components/onkyo/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 30f4ae0800a..da33ff5f018 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -120,7 +120,7 @@ def determine_zones(receiver): out = {"zone2": False, "zone3": False} try: _LOGGER.debug("Checking for zone 2 capability") - receiver.raw("ZPW") + receiver.raw("ZPWQSTN") out["zone2"] = True except ValueError as error: if str(error) != TIMEOUT_MESSAGE: @@ -128,7 +128,7 @@ def determine_zones(receiver): _LOGGER.debug("Zone 2 timed out, assuming no functionality") try: _LOGGER.debug("Checking for zone 3 capability") - receiver.raw("PW3") + receiver.raw("PW3QSTN") out["zone3"] = True except ValueError as error: if str(error) != TIMEOUT_MESSAGE: From 0bcee213338df162de00f0f269bffb3024602b6f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 28 Jul 2020 01:45:41 +0200 Subject: [PATCH 175/362] Restore rfxtrx state to off when delay off is in effect (#38239) --- .../components/rfxtrx/binary_sensor.py | 15 ++++----- tests/components/rfxtrx/test_binary_sensor.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index b8976454d68..8f7229299ee 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_DEVICE_CLASS, CONF_DEVICES, + STATE_ON, ) from homeassistant.core import callback from homeassistant.helpers import event as evt @@ -28,12 +29,7 @@ from . import ( get_pt2262_cmd, get_rfx_object, ) -from .const import ( - ATTR_EVENT, - COMMAND_OFF_LIST, - COMMAND_ON_LIST, - DEVICE_PACKET_TYPE_LIGHTING4, -) +from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST, DEVICE_PACKET_TYPE_LIGHTING4 _LOGGER = logging.getLogger(__name__) @@ -184,9 +180,10 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): if self._event is None: old_state = await self.async_get_last_state() if old_state is not None: - event = old_state.attributes.get(ATTR_EVENT) - if event: - self._apply_event(get_rfx_object(event)) + self._state = old_state.state == STATE_ON + + if self._state and self._off_delay is not None: + self._state = False @property def force_update(self) -> bool: diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 8fbc06f6ddb..56aa7126a5b 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -16,6 +16,8 @@ EVENT_MOTION_DETECTOR_NO_MOTION = "08200100a109000570" EVENT_LIGHT_DETECTOR_LIGHT = "08200100a109001570" EVENT_LIGHT_DETECTOR_DARK = "08200100a109001470" +EVENT_AC_118CDEA_2_ON = "0b1100100118cdea02010f70" + async def test_one(hass, rfxtrx): """Test with 1 sensor.""" @@ -137,6 +139,37 @@ async def test_discover(hass, rfxtrx_automatic): assert state.state == "on" +async def test_off_delay_restore(hass, rfxtrx): + """Make sure binary sensor restore as off, if off delay is active.""" + mock_restore_cache( + hass, + [ + State( + "binary_sensor.ac_118cdea_2", + "on", + attributes={ATTR_EVENT: EVENT_AC_118CDEA_2_ON}, + ) + ], + ) + + assert await async_setup_component( + hass, + "rfxtrx", + { + "rfxtrx": { + "device": "abcd", + "devices": {EVENT_AC_118CDEA_2_ON: {"off_delay": 5}}, + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "off" + + async def test_off_delay(hass, rfxtrx, timestep): """Test with discovery.""" assert await async_setup_component( From e6e3517a946de4e93a2a98c90c47e410c7fc3644 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Tue, 28 Jul 2020 00:04:53 +0000 Subject: [PATCH 176/362] [ci skip] Translation update --- .../accuweather/translations/ca.json | 19 +++- .../accuweather/translations/es.json | 32 +++++++ .../accuweather/translations/no.json | 20 +++++ .../azure_devops/translations/ca.json | 24 ++++- .../azure_devops/translations/es.json | 17 ++++ .../azure_devops/translations/no.json | 32 +++++++ .../azure_devops/translations/ru.json | 33 +++++++ .../components/control4/translations/ca.json | 10 +++ .../components/demo/translations/no.json | 1 + .../components/dexcom/translations/no.json | 11 +++ .../components/hue/translations/no.json | 6 +- .../simplisafe/translations/ca.json | 12 ++- .../simplisafe/translations/es.json | 8 +- .../simplisafe/translations/no.json | 14 ++- .../components/smarthab/translations/no.json | 13 +++ .../components/syncthru/translations/no.json | 6 ++ .../transmission/translations/no.json | 4 + .../components/vizio/translations/no.json | 6 +- .../components/volumio/translations/ca.json | 24 +++++ .../components/volumio/translations/en.json | 24 +++++ .../components/volumio/translations/es.json | 24 +++++ .../components/volumio/translations/no.json | 13 +++ .../components/volumio/translations/ru.json | 18 ++++ .../components/wolflink/translations/ca.json | 6 +- .../components/wolflink/translations/no.json | 9 ++ .../wolflink/translations/sensor.ca.json | 87 +++++++++++++++++++ .../wolflink/translations/sensor.no.json | 69 +++++++++++++++ 27 files changed, 529 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/es.json create mode 100644 homeassistant/components/azure_devops/translations/es.json create mode 100644 homeassistant/components/azure_devops/translations/no.json create mode 100644 homeassistant/components/azure_devops/translations/ru.json create mode 100644 homeassistant/components/smarthab/translations/no.json create mode 100644 homeassistant/components/volumio/translations/ca.json create mode 100644 homeassistant/components/volumio/translations/en.json create mode 100644 homeassistant/components/volumio/translations/es.json create mode 100644 homeassistant/components/volumio/translations/no.json create mode 100644 homeassistant/components/volumio/translations/ru.json create mode 100644 homeassistant/components/wolflink/translations/no.json create mode 100644 homeassistant/components/wolflink/translations/sensor.ca.json create mode 100644 homeassistant/components/wolflink/translations/sensor.no.json diff --git a/homeassistant/components/accuweather/translations/ca.json b/homeassistant/components/accuweather/translations/ca.json index 46279c46f56..72eea7f65cc 100644 --- a/homeassistant/components/accuweather/translations/ca.json +++ b/homeassistant/components/accuweather/translations/ca.json @@ -5,12 +5,29 @@ }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_api_key": "Clau API inv\u00e0lida" + "invalid_api_key": "Clau API inv\u00e0lida", + "requests_exceeded": "S'ha superat el nombre m\u00e0xim de sol\u00b7licituds permeses a l'API d'AccuWeather. Has d'esperar-te o canviar la clau API." + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom de la integraci\u00f3" + }, + "description": "Si necessites ajuda amb la configuraci\u00f3, consulta: https://www.home-assistant.io/integrations/accuweather/ \n\n La previsi\u00f3 meteorol\u00f2gica no est\u00e0 habilitada de manera predeterminada. Pots activar-la en les opcions de la integraci\u00f3.", + "title": "AccuWeather" + } } }, "options": { "step": { "user": { + "data": { + "forecast": "Previsi\u00f3 meteorol\u00f2gica" + }, + "description": "Per culpa de les limitacions de la versi\u00f3 gratu\u00efta l'API d'AccuWeather, quan habilitis la previsi\u00f3 meteorol\u00f2gica, les actualitzacions es realitzaran cada 64 minuts en comptes de 32.", "title": "Opcions d'AccuWeather" } } diff --git a/homeassistant/components/accuweather/translations/es.json b/homeassistant/components/accuweather/translations/es.json new file mode 100644 index 00000000000..88094786c51 --- /dev/null +++ b/homeassistant/components/accuweather/translations/es.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave API no v\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre de la integraci\u00f3n" + }, + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Pron\u00f3stico del tiempo" + }, + "title": "Opciones de AccuWeather" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/no.json b/homeassistant/components/accuweather/translations/no.json index e99a1d62746..e0197581db6 100644 --- a/homeassistant/components/accuweather/translations/no.json +++ b/homeassistant/components/accuweather/translations/no.json @@ -1,9 +1,29 @@ { "config": { + "error": { + "requests_exceeded": "Det tillatte antallet foresp\u00f8rsler til Accuweather API er overskredet. Du m\u00e5 vente eller endre API-n\u00f8kkel." + }, "step": { "user": { + "data": { + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "name": "Navn p\u00e5 integrasjon" + }, + "description": "Hvis du trenger hjelp med konfigurasjonen, kan du se her: https://www.home-assistant.io/integrations/accuweather/ \n\n V\u00e6rmelding er ikke aktivert som standard. Du kan aktivere det i integrasjonsalternativene.", "title": "" } } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "V\u00e6rmelding" + }, + "description": "P\u00e5 grunn av begrensningene i gratisversjonen av AccuWeather API-n\u00f8kkelen, n\u00e5r du aktiverer v\u00e6rmelding, vil dataoppdateringer bli utf\u00f8rt hvert 64. minutt i stedet for hvert 32. minutt.", + "title": "AccuWeather-alternativer" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/ca.json b/homeassistant/components/azure_devops/translations/ca.json index 289adf79d9a..df8fe8c04b3 100644 --- a/homeassistant/components/azure_devops/translations/ca.json +++ b/homeassistant/components/azure_devops/translations/ca.json @@ -4,12 +4,30 @@ "already_configured": "El compte ja ha estat configurat", "reauth_successful": "Token d'acc\u00e9s actualitzat correctament" }, + "error": { + "authorization_error": "Error d'autoritzaci\u00f3. Comprova que tens acc\u00e9s al projecte i tens les credencials correctes.", + "connection_error": "No s'ha pogut connectar a Azure DevOps.", + "project_error": "No s'ha pogut obtenir la informaci\u00f3 del projecte." + }, + "flow_title": "Azure DevOps: {project_url}", "step": { + "reauth": { + "data": { + "personal_access_token": "Token d'Acc\u00e9s Personal (PAT)" + }, + "description": "L'autenticaci\u00f3 de {project_url} ha fallat. Si us plau, introdueix les teves credencials actuals.", + "title": "Reautenticaci\u00f3" + }, "user": { "data": { - "organization": "Organitzaci\u00f3" - } + "organization": "Organitzaci\u00f3", + "personal_access_token": "Token d'Acc\u00e9s Personal (PAT)", + "project": "Projecte" + }, + "description": "Configura una inst\u00e0ncia d'Azure DevOps per accedir al teu projecte. El token d'acc\u00e9s personal nom\u00e9s \u00e9s necessari per a projectes privats.", + "title": "Afegeix un projecte Azure DevOps" } } - } + }, + "title": "Azure DevOps" } \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/es.json b/homeassistant/components/azure_devops/translations/es.json new file mode 100644 index 00000000000..7fc78e8f88e --- /dev/null +++ b/homeassistant/components/azure_devops/translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "reauth": { + "title": "Reautenticaci\u00f3n" + }, + "user": { + "data": { + "organization": "Organizaci\u00f3n", + "project": "Proyecto" + }, + "title": "A\u00f1adir Proyecto Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/no.json b/homeassistant/components/azure_devops/translations/no.json new file mode 100644 index 00000000000..2723f8e08c7 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "reauth_successful": "Tilgangstoken oppdatert" + }, + "error": { + "authorization_error": "Autoriseringsfeil. Sjekk at du har tilgang til prosjektet og har riktig legitimasjon.", + "connection_error": "Kunne ikke koble til Azure DevOps.", + "project_error": "Kunne ikke f\u00e5 prosjektinformasjon." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Token for personlig tilgang (PAT)" + }, + "description": "Autentiseringen mislyktes for {project_url} . Vennligst skriv inn gjeldende legitimasjon.", + "title": "reautentisering" + }, + "user": { + "data": { + "organization": "Organisasjon", + "personal_access_token": "Token for personlig tilgang (PAT)", + "project": "Prosjekt" + }, + "description": "Sett opp en Azure DevOps-forekomst for \u00e5 f\u00e5 tilgang til prosjektet ditt. En personlig tilgangstoken er bare n\u00f8dvendig for et privat prosjekt.", + "title": "Legg til Azure DevOps Project" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/ru.json b/homeassistant/components/azure_devops/translations/ru.json new file mode 100644 index 00000000000..08e07d68560 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.", + "reauth_successful": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d." + }, + "error": { + "authorization_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443 \u0412\u0430\u0441 \u0435\u0441\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u043f\u0440\u043e\u0435\u043a\u0442\u0443, \u0430 \u0442\u0430\u043a \u0436\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a Azure DevOps.", + "project_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0435." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (PAT)" + }, + "description": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 {project_url}. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + }, + "user": { + "data": { + "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f", + "personal_access_token": "\u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (PAT)", + "project": "\u041f\u0440\u043e\u0435\u043a\u0442" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u043c Azure DevOps. \u041f\u0435\u0440\u0441\u043e\u043d\u0430\u043b\u044c\u043d\u044b\u0439 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043f\u0440\u043e\u0435\u043a\u0442\u043e\u0432.", + "title": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u043e\u0435\u043a\u0442 Azure DevOps" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/ca.json b/homeassistant/components/control4/translations/ca.json index 8e1b6c4788c..69702272d43 100644 --- a/homeassistant/components/control4/translations/ca.json +++ b/homeassistant/components/control4/translations/ca.json @@ -14,6 +14,16 @@ "host": "Adre\u00e7a IP", "password": "Contrasenya", "username": "Nom d'usuari" + }, + "description": "Introdueix els detalls del teu compte Control4 i l'adre\u00e7a IP del teu controlador local." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Segons entre actualitzacions" } } } diff --git a/homeassistant/components/demo/translations/no.json b/homeassistant/components/demo/translations/no.json index e85f5b067a0..5003b9da568 100644 --- a/homeassistant/components/demo/translations/no.json +++ b/homeassistant/components/demo/translations/no.json @@ -4,6 +4,7 @@ "options_1": { "data": { "bool": "Valgfri boolean", + "constant": "Konstant", "int": "Numerisk inndata" } }, diff --git a/homeassistant/components/dexcom/translations/no.json b/homeassistant/components/dexcom/translations/no.json index bd32458f907..09ec0002470 100644 --- a/homeassistant/components/dexcom/translations/no.json +++ b/homeassistant/components/dexcom/translations/no.json @@ -4,6 +4,17 @@ "user": { "data": { "server": "" + }, + "description": "Angi Dexcom Share-legitimasjon", + "title": "Setup Dexcom integrasjon" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "unit_of_measurement": "M\u00e5leenhet" } } } diff --git a/homeassistant/components/hue/translations/no.json b/homeassistant/components/hue/translations/no.json index 6e2b78a2ead..a632a0584da 100644 --- a/homeassistant/components/hue/translations/no.json +++ b/homeassistant/components/hue/translations/no.json @@ -24,6 +24,9 @@ "link": { "description": "Trykk p\u00e5 knappen p\u00e5 Bridgen for \u00e5 registrere Philips Hue med Home Assistant. \n\n ![Knappens plassering p\u00e5 Bridgen](/static/images/config_philips_hue.jpg)", "title": "" + }, + "manual": { + "title": "Manuell konfigurere en Hue-bro" } } }, @@ -53,7 +56,8 @@ "init": { "data": { "allow_how_groups": "Tillat Hue-grupper", - "allow_hue_groups": "Tillat Hue-grupper" + "allow_hue_groups": "Tillat Hue-grupper", + "allow_unreachable": "Tillat uoppn\u00e5elige p\u00e6rer \u00e5 rapportere sin tilstand riktig" } } } diff --git a/homeassistant/components/simplisafe/translations/ca.json b/homeassistant/components/simplisafe/translations/ca.json index 96dff658906..98727fe4c57 100644 --- a/homeassistant/components/simplisafe/translations/ca.json +++ b/homeassistant/components/simplisafe/translations/ca.json @@ -1,18 +1,26 @@ { "config": { "abort": { - "already_configured": "Aquest compte SimpliSafe ja est\u00e0 en \u00fas." + "already_configured": "Aquest compte SimpliSafe ja est\u00e0 en \u00fas.", + "reauth_successful": "Reautenticaci\u00f3 amb SimpliSafe exitosa." }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", "invalid_credentials": "Credencials inv\u00e0lides", + "still_awaiting_mfa": "Esperant clic de l'enlla\u00e7 del correu MFA", "unknown": "Error inesperat" }, "step": { + "mfa": { + "description": "Consulta el correu i busca-hi un missatge amb un enlla\u00e7 de SimpliSafe. Despr\u00e9s de verificar l'enlla\u00e7, torneu aqu\u00ed per completar la instal\u00b7laci\u00f3 de la integraci\u00f3.", + "title": "Autenticaci\u00f3 multi-factor SimpliSafe" + }, "reauth_confirm": { "data": { "password": "Contrasenya" - } + }, + "description": "El token d'acc\u00e9s ha caducat o ha estat revocat. Introdueix la teva contrasenya per tornar a vincular el compte.", + "title": "Torna a vincular un compte SimpliSafe" }, "user": { "data": { diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json index 7ed09529ccc..96badf7b10c 100644 --- a/homeassistant/components/simplisafe/translations/es.json +++ b/homeassistant/components/simplisafe/translations/es.json @@ -5,9 +5,15 @@ }, "error": { "identifier_exists": "Cuenta ya registrada", - "invalid_credentials": "Credenciales no v\u00e1lidas" + "invalid_credentials": "Credenciales no v\u00e1lidas", + "unknown": "Error inesperado" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + } + }, "user": { "data": { "code": "C\u00f3digo (utilizado en el interfaz de usuario de Home Assistant)", diff --git a/homeassistant/components/simplisafe/translations/no.json b/homeassistant/components/simplisafe/translations/no.json index 3a253f2e5a1..6dd8ff68b5d 100644 --- a/homeassistant/components/simplisafe/translations/no.json +++ b/homeassistant/components/simplisafe/translations/no.json @@ -1,13 +1,23 @@ { "config": { "abort": { - "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk." + "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk.", + "reauth_successful": "SimpliSafe gjenautentisering er vellykket." }, "error": { "identifier_exists": "Konto er allerede registrert", - "invalid_credentials": "Ugyldig legitimasjon" + "invalid_credentials": "Ugyldig legitimasjon", + "still_awaiting_mfa": "Forventer fortsatt MFA-e-postklikk" }, "step": { + "mfa": { + "description": "Sjekk e-posten din for en lenke fra SimpliSafe. Etter \u00e5 ha bekreftet lenken, g\u00e5 tilbake hit for \u00e5 fullf\u00f8re installasjonen av integrasjonen.", + "title": "SimpliSafe Multi-Factor Autentisering" + }, + "reauth_confirm": { + "description": "Adgangstokenet ditt har utl\u00f8pt eller blitt opphevet. Skriv inn passordet ditt for \u00e5 koble kontoen din p\u00e5 nytt.", + "title": "Koble SimpliSafe-kontoen p\u00e5 nytt" + }, "user": { "data": { "code": "Kode (brukt i Home Assistant brukergrensesnittet)", diff --git a/homeassistant/components/smarthab/translations/no.json b/homeassistant/components/smarthab/translations/no.json new file mode 100644 index 00000000000..15e6962cbe0 --- /dev/null +++ b/homeassistant/components/smarthab/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "error": { + "service": "Feil under fors\u00f8k p\u00e5 \u00e5 n\u00e5 SmartHab. Tjenesten kan v\u00e6re nede. Sjekk tilkoblingen din." + }, + "step": { + "user": { + "description": "Av tekniske \u00e5rsaker m\u00e5 du s\u00f8rge for \u00e5 bruke en sekund\u00e6r konto som er spesifikk for oppsettet i Home Assistant. Du kan opprette en fra SmartHab-programmet.", + "title": "Oppsett av SmartHab" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/no.json b/homeassistant/components/syncthru/translations/no.json index 01ea5b65fb1..f5d626c44ff 100644 --- a/homeassistant/components/syncthru/translations/no.json +++ b/homeassistant/components/syncthru/translations/no.json @@ -1,5 +1,11 @@ { "config": { + "error": { + "invalid_url": "Ugyldig URL-adresse", + "syncthru_not_supported": "Enheten st\u00f8tter ikke SyncThru", + "unknown_state": "Skrivertilstand ukjent, kontroller URL-adresse og nettverkstilkobling" + }, + "flow_title": "Samsung SyncThru-skriver: {name}", "step": { "user": { "data": { diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json index a04c32f471a..89150467222 100644 --- a/homeassistant/components/transmission/translations/no.json +++ b/homeassistant/components/transmission/translations/no.json @@ -12,7 +12,9 @@ "user": { "data": { "host": "Vert", + "limit": "Grense", "name": "Navn", + "order": "Rekkef\u00f8lge", "password": "Passord", "port": "", "username": "Brukernavn" @@ -25,6 +27,8 @@ "step": { "init": { "data": { + "limit": "Grense", + "order": "Rekkef\u00f8lge", "scan_interval": "Oppdater frekvens" }, "title": "Konfigurer alternativer for Transmission" diff --git a/homeassistant/components/vizio/translations/no.json b/homeassistant/components/vizio/translations/no.json index ab585dcdcf3..d7e43f56bb1 100644 --- a/homeassistant/components/vizio/translations/no.json +++ b/homeassistant/components/vizio/translations/no.json @@ -17,9 +17,11 @@ "title": "Fullf\u00f8r sammenkoblingsprosess" }, "pairing_complete": { + "description": "VIZIO SmartCast-enhet er n\u00e5 koblet til Home Assistant.", "title": "Sammenkoblingen fullf\u00f8rt" }, "pairing_complete_import": { + "description": "Din VIZIO SmartCast-enhet er n\u00e5 koblet til VIZIO SmartCast-enhet . \n\n Tilgangstoken er '** {access_token} **'.", "title": "Sammenkoblingen fullf\u00f8rt" }, "user": { @@ -29,6 +31,7 @@ "host": "Vert", "name": "Navn" }, + "description": "En Tilgangstoken er bare n\u00f8dvendig for TV-er. Hvis du konfigurerer en TV og ikke har en Tilgangstoken enn\u00e5, la den st\u00e5 tom for \u00e5 g\u00e5 gjennom en sammenkoblingsprosess.", "title": "VIZIO SmartCast-enhet" } } @@ -41,7 +44,8 @@ "include_or_exclude": "Inkluder eller ekskludere apper?", "volume_step": "St\u00f8rrelse p\u00e5 volum trinn" }, - "description": "Hvis du har en Smart-TV, kan du eventuelt filtrere kildelisten ved \u00e5 velge hvilke apper som skal inkluderes eller utelates i kildelisten." + "description": "Hvis du har en Smart-TV, kan du eventuelt filtrere kildelisten ved \u00e5 velge hvilke apper som skal inkluderes eller utelates i kildelisten.", + "title": "Oppdater VIZIO SmartCast-enhet Alternativer" } } } diff --git a/homeassistant/components/volumio/translations/ca.json b/homeassistant/components/volumio/translations/ca.json new file mode 100644 index 00000000000..df63d99095d --- /dev/null +++ b/homeassistant/components/volumio/translations/ca.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "No es pot connectar amb el Volumio descobert" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "discovery_confirm": { + "description": "Vols afegir el Volumio (`{name}`) a Home Assistant?", + "title": "Volumio descobert" + }, + "user": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/en.json b/homeassistant/components/volumio/translations/en.json new file mode 100644 index 00000000000..5c70f8d4df8 --- /dev/null +++ b/homeassistant/components/volumio/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Cannot connect to discovered Volumio" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "discovery_confirm": { + "description": "Do you want to add Volumio (`{name}`) to Home Assistant?", + "title": "Discovered Volumio" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/es.json b/homeassistant/components/volumio/translations/es.json new file mode 100644 index 00000000000..8aa4a870a36 --- /dev/null +++ b/homeassistant/components/volumio/translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se puede conectar con el Volumio descubierto" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "unknown": "Error inesperado" + }, + "step": { + "discovery_confirm": { + "description": "\u00bfQuieres a\u00f1adir Volumio (`{name}`) a Home Assistant?", + "title": "Volumio descubierto" + }, + "user": { + "data": { + "host": "Host", + "port": "Puerto" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/no.json b/homeassistant/components/volumio/translations/no.json new file mode 100644 index 00000000000..48c763af2b1 --- /dev/null +++ b/homeassistant/components/volumio/translations/no.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "cannot_connect": "Kan ikke koble til oppdaget Volumio" + }, + "step": { + "discovery_confirm": { + "description": "Vil du legge Volumio (` {name} `) til Home Assistant?", + "title": "Oppdaget Volumio" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/ru.json b/homeassistant/components/volumio/translations/ru.json new file mode 100644 index 00000000000..82e8fd9c9d2 --- /dev/null +++ b/homeassistant/components/volumio/translations/ru.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" + }, + "step": { + "user": { + "data": { + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/ca.json b/homeassistant/components/wolflink/translations/ca.json index ab0c791dbd9..80f8a793309 100644 --- a/homeassistant/components/wolflink/translations/ca.json +++ b/homeassistant/components/wolflink/translations/ca.json @@ -12,13 +12,15 @@ "device": { "data": { "device_name": "Dispositiu" - } + }, + "title": "Selecci\u00f3 de dispositiu WOLF" }, "user": { "data": { "password": "Contrasenya", "username": "Nom d'usuari" - } + }, + "title": "Connexi\u00f3 WOLF SmartSet" } } } diff --git a/homeassistant/components/wolflink/translations/no.json b/homeassistant/components/wolflink/translations/no.json new file mode 100644 index 00000000000..f6d1302012e --- /dev/null +++ b/homeassistant/components/wolflink/translations/no.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "WOLF SmartSet-tilkobling" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ca.json b/homeassistant/components/wolflink/translations/sensor.ca.json new file mode 100644 index 00000000000..a671f608879 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ca.json @@ -0,0 +1,87 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x ACS", + "abgasklappe": "Amortidor de gasos", + "absenkbetrieb": "Mode de retroc\u00e9s", + "absenkstop": "Retroc\u00e9s aturat", + "aktiviert": "Activat", + "antilegionellenfunktion": "Funci\u00f3 anti-legionel\u00b7la", + "at_abschaltung": "Aturada OT", + "at_frostschutz": "Protecci\u00f3 contra gelades OT", + "aus": "Desactivat", + "auto": "Autom\u00e0tic", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Apagada autom\u00e0tica", + "automatik_ein": "Engegada autom\u00e0tica", + "bereit_keine_ladung": "Llest, no carregant-se", + "betrieb_ohne_brenner": "Funcionant sense cremador", + "cooling": "Refredant", + "deaktiviert": "Inactiu", + "dhw_prior": "DHWPrior", + "eco": "Eco", + "ein": "Habilitat", + "estrichtrocknung": "Assecant superf\u00edcie", + "externe_deaktivierung": "Desactivaci\u00f3 externa", + "fernschalter_ein": "Control remot activat", + "frost_heizkreis": "Congelaci\u00f3 circuit calentador", + "frost_warmwasser": "Congelaci\u00f3 ACS", + "frostschutz": "Protecci\u00f3 contra gelades", + "gasdruck": "Pressi\u00f3 del gas", + "glt_betrieb": "Mode BMS", + "gradienten_uberwachung": "Monitoritzaci\u00f3 de gradient", + "heizbetrieb": "Mode de calefacci\u00f3", + "heizgerat_mit_speicher": "Caldera amb cilindre", + "heizung": "Escalfant", + "initialisierung": "Inicialitzaci\u00f3", + "kalibration": "Calibraci\u00f3", + "kalibration_heizbetrieb": "Calibraci\u00f3 mode de calefacci\u00f3", + "kalibration_kombibetrieb": "Calibraci\u00f3 mode de combi", + "kalibration_warmwasserbetrieb": "Calibraci\u00f3 ACS", + "kaskadenbetrieb": "Funcionament en cascada", + "kombibetrieb": "Mode combi", + "kombigerat": "Caldera combi", + "kombigerat_mit_solareinbindung": "Caldera combi amb integraci\u00f3 solar", + "mindest_kombizeit": "Temps combi m\u00ednim", + "nachlauf_heizkreispumpe": "Bomba del circuit calentador en marxa", + "nachspulen": "Post-desc\u00e0rrega", + "nur_heizgerat": "Nom\u00e9s caldera", + "parallelbetrieb": "Mode paral\u00b7lel", + "partymodus": "Mode festa", + "perm_cooling": "PermCooling", + "permanent": "Permanent", + "permanentbetrieb": "Mode permanent", + "reduzierter_betrieb": "Mode limitat", + "rt_abschaltung": "Aturada RT", + "rt_frostschutz": "Protecci\u00f3 contra gelades RT", + "ruhekontakt": "Contacte de rep\u00f2s", + "schornsteinfeger": "Prova d'emissions", + "smart_grid": "SmartGrid", + "smart_home": "SmartHome", + "softstart": "Inici suau", + "solarbetrieb": "Mode solar", + "sparbetrieb": "Mode econ\u00f2mic", + "sparen": "Economia", + "spreizung_hoch": "dT massa ample", + "spreizung_kf": "Escampant KF", + "stabilisierung": "Estabilitzaci\u00f3", + "standby": "En espera", + "start": "Inici", + "storung": "Error", + "taktsperre": "Anti-cicle", + "telefonfernschalter": "Interruptor remot telef\u00f2nic", + "test": "Prova", + "tpw": "TPW", + "urlaubsmodus": "Mode de vacances", + "ventilprufung": "Test de v\u00e0lvula", + "vorspulen": "Entrada esbandit", + "warmwasser": "ACS", + "warmwasser_schnellstart": "Inici r\u00e0pid d'ACS", + "warmwasserbetrieb": "Mode ACS", + "warmwassernachlauf": "ACS en marxa", + "warmwasservorrang": "Prioritat ACS", + "zunden": "Ignici\u00f3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.no.json b/homeassistant/components/wolflink/translations/sensor.no.json new file mode 100644 index 00000000000..7a5692a28ff --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.no.json @@ -0,0 +1,69 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "R\u00f8ykgassspjeld", + "absenkbetrieb": "Tilbakeslag-modus", + "absenkstop": "Tilbakeslag stopp", + "aktiviert": "Aktivert", + "antilegionellenfunktion": "Anti-legionella-funksjon", + "at_abschaltung": "OT-avstengning", + "at_frostschutz": "OT frostbeskyttelse", + "aus": "Deaktivert", + "auto": "Auto", + "auto_off_cool": "AutoOffCool", + "auto_on_cool": "AutoOnCool", + "automatik_aus": "Automatisk AV", + "automatik_ein": "Automatisk P\u00c5", + "bereit_keine_ladung": "Klar, laster ikke", + "betrieb_ohne_brenner": "Arbeide uten brenner", + "cooling": "Kj\u00f8ling", + "deaktiviert": "inaktiv", + "dhw_prior": "DHWPrior", + "eco": "\u00d8ko", + "ein": "Aktivert", + "estrichtrocknung": "Screed t\u00f8rking", + "externe_deaktivierung": "Ekstern deaktivering", + "fernschalter_ein": "Fjernkontroll aktivert", + "frost_heizkreis": "Frost for varmekrets", + "frost_warmwasser": "DHW frost", + "frostschutz": "Frostbeskyttelse", + "gasdruck": "Gasstrykk", + "parallelbetrieb": "Parallell modus", + "partymodus": "Festmodus", + "perm_cooling": "PermKj\u00f8ling", + "permanent": "permament", + "permanentbetrieb": "Permanent modus", + "reduzierter_betrieb": "Begrenset modus", + "rt_abschaltung": "RT-avstengning", + "rt_frostschutz": "RT frostsikring", + "ruhekontakt": "Hvilekontakt", + "schornsteinfeger": "Utslippstest", + "smart_grid": "Smartgrid", + "smart_home": "SmartHome", + "softstart": "Myk start", + "solarbetrieb": "Solmodus", + "sparbetrieb": "\u00d8konomimodus", + "sparen": "\u00d8konomi", + "spreizung_hoch": "dT for bred", + "spreizung_kf": "Spre KF", + "stabilisierung": "Stablisering", + "standby": "Avventer", + "start": "Start", + "storung": "Feil", + "taktsperre": "Anti-syklus", + "telefonfernschalter": "Fjernkontroll for telefon", + "test": "Test", + "tpw": "TPW", + "urlaubsmodus": "Feriemodus", + "ventilprufung": "Ventiltest", + "vorspulen": "Inngangsskylling", + "warmwasser": "DHW", + "warmwasser_schnellstart": "DHW hurtigstart", + "warmwasserbetrieb": "DHW-modus", + "warmwassernachlauf": "DHW run-on", + "warmwasservorrang": "DHW-prioritet", + "zunden": "Tenning" + } + } +} \ No newline at end of file From 02e2c40c48b9ea6399c4b392ccf12dd5e99c3960 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Mon, 27 Jul 2020 22:39:23 -0300 Subject: [PATCH 177/362] Bond - Make assumed state conditional (#38209) --- homeassistant/components/bond/entity.py | 2 +- homeassistant/components/bond/utils.py | 10 ++++++ tests/components/bond/common.py | 3 +- tests/components/bond/test_light.py | 48 ++++++++++++++++++++++++- 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 8c3b9c638f5..6743bc979ec 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -46,7 +46,7 @@ class BondEntity(Entity): @property def assumed_state(self) -> bool: """Let HA know this entity relies on an assumed state tracked by Bond.""" - return True + return self._hub.is_bridge and not self._device.trust_state @property def available(self) -> bool: diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 545360b25fd..a4df306b429 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -24,6 +24,11 @@ class BondDevice: """Get the type of this device.""" return self._attrs["type"] + @property + def trust_state(self) -> bool: + """Check if Trust State is turned on.""" + return self.props.get("trust_state", False) + def supports_speed(self) -> bool: """Return True if this device supports any of the speed related commands.""" actions: List[str] = self._attrs["actions"] @@ -89,3 +94,8 @@ class BondHub: def devices(self) -> List[BondDevice]: """Return a list of all devices controlled by this hub.""" return self._devices + + @property + def is_bridge(self) -> bool: + """Return if the Bond is a Bond Bridge. If False, it means that it is a Smart by Bond product. Assumes that it is if the model is not available.""" + return self._version.get("model", "BD-").startswith("BD-") diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 1a37455b338..181fe3eaf07 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -51,6 +51,7 @@ async def setup_platform( discovered_device: Dict[str, Any], bond_device_id: str = "bond-device-id", props: Dict[str, Any] = None, + bond_version: Dict[str, Any] = None, ): """Set up the specified Bond platform.""" mock_entry = MockConfigEntry( @@ -60,7 +61,7 @@ async def setup_platform( mock_entry.add_to_hass(hass) with patch("homeassistant.components.bond.PLATFORMS", [platform]): - with patch_bond_version(), patch_bond_device_ids( + with patch_bond_version(return_value=bond_version), patch_bond_device_ids( return_value=[bond_device_id] ), patch_bond_device( return_value=discovered_device diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index b507395dab3..555da5e707f 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -6,7 +6,12 @@ from bond_api import Action, DeviceType from homeassistant import core from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow @@ -44,6 +49,47 @@ async def test_entity_registry(hass: core.HomeAssistant): assert [key for key in registry.entities] == ["light.name_1"] +async def test_sbb_trust_state(hass: core.HomeAssistant): + """Assumed state should be False if device is a Smart by Bond.""" + version = { + "model": "MR123A", + "bondid": "test-bond-id", + } + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_version=version + ) + + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is not True + + +async def test_trust_state_not_specified(hass: core.HomeAssistant): + """Assumed state should be True if Trust State is not specified.""" + await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is True + + +async def test_trust_state(hass: core.HomeAssistant): + """Assumed state should be True if Trust State is False.""" + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), props={"trust_state": False} + ) + + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is True + + +async def test_no_trust_state(hass: core.HomeAssistant): + """Assumed state should be False if Trust State is True.""" + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), props={"trust_state": True} + ) + device = hass.states.get("light.name_1") + assert device.attributes.get(ATTR_ASSUMED_STATE) is not True + + async def test_turn_on_light(hass: core.HomeAssistant): """Tests that turn on command delegates to API.""" await setup_platform( From 020dd39c08b6b9f931b3f006276eeeb9231bfff5 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Mon, 27 Jul 2020 22:18:24 -0400 Subject: [PATCH 178/362] Apply changes from bond code review (#38303) --- tests/components/bond/test_init.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 6b0cf2b287e..78e60f93d53 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -1,13 +1,14 @@ """Tests for the Bond module.""" from aiohttp import ClientConnectionError -import pytest -from homeassistant.components.bond import async_setup_entry from homeassistant.components.bond.const import DOMAIN -from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -28,10 +29,11 @@ async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant): config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) + config_entry.add_to_hass(hass) with patch_bond_version(side_effect=ClientConnectionError()): - with pytest.raises(ConfigEntryNotReady): - await async_setup_entry(hass, config_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_SETUP_RETRY async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant): From 213496095f6effc3f2cd8c87709c32318db76483 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 28 Jul 2020 04:26:29 +0200 Subject: [PATCH 179/362] Bump python-miio to 0.5.3 (#38300) --- homeassistant/components/xiaomi_miio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 471dc7290df..09719d720c0 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Miio", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.9.45", "python-miio==0.5.2.1"], + "requirements": ["construct==2.9.45", "python-miio==0.5.3"], "codeowners": ["@rytilahti", "@syssi"], "zeroconf": ["_miio._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b5d79daba24..85d3dd5983b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1719,7 +1719,7 @@ python-juicenet==1.0.1 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.5.2.1 +python-miio==0.5.3 # homeassistant.components.mpd python-mpd2==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ce16d7e51f..c342a6b8aea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -782,7 +782,7 @@ python-izone==1.1.2 python-juicenet==1.0.1 # homeassistant.components.xiaomi_miio -python-miio==0.5.2.1 +python-miio==0.5.3 # homeassistant.components.nest python-nest==4.1.0 From c29f412a70325d5b53676ec82ec0b41f2dae1f46 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Mon, 27 Jul 2020 22:53:56 -0400 Subject: [PATCH 180/362] Add debug logging for bond (#38304) --- homeassistant/components/bond/entity.py | 1 + homeassistant/components/bond/utils.py | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 6743bc979ec..aa5a7564b3f 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -64,6 +64,7 @@ class BondEntity(Entity): ) self._available = False else: + _LOGGER.debug("Device state for %s is:\n%s", self.entity_id, state) if not self._available: _LOGGER.info("Entity %s has come back", self.entity_id) self._available = True diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index a4df306b429..416d5c8eb32 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -1,9 +1,11 @@ """Reusable utilities for the Bond component.""" - +import logging from typing import List, Optional from bond_api import Action, Bond +_LOGGER = logging.getLogger(__name__) + class BondDevice: """Helper device class to hold ID and attributes together.""" @@ -14,6 +16,14 @@ class BondDevice: self.props = props self._attrs = attrs + def __repr__(self): + """Return readable representation of a bond device.""" + return { + "device_id": self.device_id, + "props": self.props, + "attrs": self._attrs, + }.__repr__() + @property def name(self) -> str: """Get the name of this device.""" @@ -75,6 +85,8 @@ class BondHub: for device_id in device_ids ] + _LOGGER.debug("Discovered Bond devices: %s", self._devices) + @property def bond_id(self) -> str: """Return unique Bond ID for this hub.""" @@ -97,5 +109,6 @@ class BondHub: @property def is_bridge(self) -> bool: - """Return if the Bond is a Bond Bridge. If False, it means that it is a Smart by Bond product. Assumes that it is if the model is not available.""" + """Return if the Bond is a Bond Bridge.""" + # If False, it means that it is a Smart by Bond product. Assumes that it is if the model is not available. return self._version.get("model", "BD-").startswith("BD-") From 5fef9653a8cd383337a96722f4eb7835517ae38b Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 27 Jul 2020 22:37:09 -0700 Subject: [PATCH 181/362] Fix ozw dimming duration (#38254) * Dimming duration fix Fixes #38068 - allows dimming duration to 7620 (default of 7621) * Forgot to commit my test updates * Added backwards compatibility with pre-150+ builds Added tests for backwards compatibility * Upped the build number cut off * Add check for major.minor version as well * Fix major.minor detection * Adjust variable name * Adjust version checking logic * Math is hard * Rename files, adjust test names * Update doc string --- homeassistant/components/ozw/light.py | 44 +++++---- tests/components/ozw/conftest.py | 6 ++ tests/components/ozw/test_light.py | 90 +++++++++++++++++++ .../ozw/light_new_ozw_network_dump.csv | 55 ++++++++++++ tests/fixtures/ozw/light_wc_network_dump.csv | 2 +- 5 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/ozw/light_new_ozw_network_dump.csv diff --git a/homeassistant/components/ozw/light.py b/homeassistant/components/ozw/light.py index 1fd2c2fec07..ae2750618c4 100644 --- a/homeassistant/components/ozw/light.py +++ b/homeassistant/components/ozw/light.py @@ -140,29 +140,41 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): def async_set_duration(self, **kwargs): """Set the transition time for the brightness value. - Zwave Dimming Duration values: - 0 = instant - 0-127 = 1 second to 127 seconds - 128-254 = 1 minute to 127 minutes - 255 = factory default + Zwave Dimming Duration values now use seconds as an + integer (max: 7620 seconds or 127 mins) + Build 1205 https://github.com/OpenZWave/open-zwave/commit/f81bc04 """ if self.values.dimming_duration is None: return + ozw_version = tuple( + int(x) + for x in self.values.primary.ozw_instance.get_status().openzwave_version.split( + "." + ) + ) + if ATTR_TRANSITION not in kwargs: # no transition specified by user, use defaults - new_value = 255 + new_value = 7621 # anything over 7620 uses the factory default + if ozw_version < (1, 6, 1205): + new_value = 255 # default for older version + else: - # transition specified by user, convert to zwave value - transition = kwargs[ATTR_TRANSITION] - if transition <= 127: - new_value = int(transition) - else: - minutes = int(transition / 60) - _LOGGER.debug( - "Transition rounded to %d minutes for %s", minutes, self.entity_id - ) - new_value = minutes + 128 + # transition specified by user + new_value = max(0, min(7620, kwargs[ATTR_TRANSITION])) + if ozw_version < (1, 6, 1205): + transition = kwargs[ATTR_TRANSITION] + if transition <= 127: + new_value = int(transition) + else: + minutes = int(transition / 60) + _LOGGER.debug( + "Transition rounded to %d minutes for %s", + minutes, + self.entity_id, + ) + new_value = minutes + 128 # only send value if it differs from current # this prevents a command for nothing diff --git a/tests/components/ozw/conftest.py b/tests/components/ozw/conftest.py index ec5610713c5..42947064c1e 100644 --- a/tests/components/ozw/conftest.py +++ b/tests/components/ozw/conftest.py @@ -27,6 +27,12 @@ def light_data_fixture(): return load_fixture("ozw/light_network_dump.csv") +@pytest.fixture(name="light_new_ozw_data", scope="session") +def light_new_ozw_data_fixture(): + """Load light dimmer MQTT data and return it.""" + return load_fixture("ozw/light_new_ozw_network_dump.csv") + + @pytest.fixture(name="light_no_rgb_data", scope="session") def light_no_rgb_data_fixture(): """Load light dimmer MQTT data and return it.""" diff --git a/tests/components/ozw/test_light.py b/tests/components/ozw/test_light.py index c1d92688825..0217ca5adf8 100644 --- a/tests/components/ozw/test_light.py +++ b/tests/components/ozw/test_light.py @@ -514,3 +514,93 @@ async def test_wc_light(hass, light_wc_data, light_msg, light_rgb_msg, sent_mess assert state is not None assert state.state == "on" assert state.attributes["color_temp"] == 191 + + +async def test_new_ozw_light(hass, light_new_ozw_data, light_msg, sent_messages): + """Test setting up config entry.""" + receive_message = await setup_ozw(hass, fixture=light_new_ozw_data) + + # Test loaded only white LED support + state = hass.states.get("light.led_bulb_6_multi_colour_level") + assert state is not None + assert state.state == "off" + + # Test turning on with new duration (newer openzwave) + new_transition = 4180 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "transition": new_transition, + }, + blocking=True, + ) + assert len(sent_messages) == 2 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 4180, "ValueIDKey": 1407375551070225} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = 255 + light_msg.encode() + receive_message(light_msg) + await hass.async_block_till_done() + + # Test turning off with new duration (newer openzwave)(new max) + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": "light.led_bulb_6_multi_colour_level"}, + blocking=True, + ) + assert len(sent_messages) == 4 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 7621, "ValueIDKey": 1407375551070225} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 0, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = 0 + light_msg.encode() + receive_message(light_msg) + await hass.async_block_till_done() + + # Test turning on with new duration (newer openzwave)(factory default) + new_transition = 8000 + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": "light.led_bulb_6_multi_colour_level", + "transition": new_transition, + }, + blocking=True, + ) + assert len(sent_messages) == 6 + + msg = sent_messages[-2] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 6553, "ValueIDKey": 1407375551070225} + + msg = sent_messages[-1] + assert msg["topic"] == "OpenZWave/1/command/setvalue/" + assert msg["payload"] == {"Value": 255, "ValueIDKey": 659128337} + + # Feedback on state + light_msg.decode() + light_msg.payload["Value"] = 255 + light_msg.encode() + receive_message(light_msg) + await hass.async_block_till_done() diff --git a/tests/fixtures/ozw/light_new_ozw_network_dump.csv b/tests/fixtures/ozw/light_new_ozw_network_dump.csv new file mode 100644 index 00000000000..df810f64102 --- /dev/null +++ b/tests/fixtures/ozw/light_new_ozw_network_dump.csv @@ -0,0 +1,55 @@ +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1214", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} +OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1407375551070225/,{ "Label": "Dimming Duration", "Value": 255, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "Duration taken when changing the Level of a Device", "ValueIDKey": 1407375551070225, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/659128337/,{ "Label": "Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 0, "Node": 39, "Genre": "User", "Help": "The Current Level of the Device", "ValueIDKey": 659128337, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/281475635839000/,{ "Label": "Bright", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 1, "Node": 39, "Genre": "User", "Help": "Increase the Brightness of the Device", "ValueIDKey": 281475635839000, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/562950612549656/,{ "Label": "Dim", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 2, "Node": 39, "Genre": "User", "Help": "Decrease the Brightness of the Device", "ValueIDKey": 562950612549656, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/844425597648912/,{ "Label": "Ignore Start Level", "Value": true, "Units": "", "Min": 0, "Max": 0, "Type": "Bool", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Ignore the Start Level of the Device when increasing/decreasing brightness", "ValueIDKey": 844425597648912, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/38/value/1125900574359569/,{ "Label": "Start Level", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "Start Level when Changing the Brightness of a Device", "ValueIDKey": 1125900574359569, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/,{ "Instance": 1, "CommandClassId": 39, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/39/value/667533332/,{ "Label": "Switch All", "Value": { "List": [ { "Value": 0, "Label": "Disabled" }, { "Value": 1, "Label": "Off Enabled" }, { "Value": 2, "Label": "On Enabled" }, { "Value": 255, "Label": "On and Off Enabled" } ], "Selected": "On and Off Enabled" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_SWITCH_ALL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Switch All Devices On/Off", "ValueIDKey": 667533332, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/,{ "Instance": 1, "CommandClassId": 51, "CommandClass": "COMMAND_CLASS_COLOR", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/562950621151251/,{ "Label": "Color Channels", "Value": 31, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 2, "Node": 39, "Genre": "System", "Help": "Color Capabilities of the device", "ValueIDKey": 562950621151251, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/659341335/,{ "Label": "Color", "Value": "#000000FF00", "Units": "#RRGGBBWWCW", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 0, "Node": 39, "Genre": "User", "Help": "Color (in RGB format)", "ValueIDKey": 659341335, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/51/value/281475636051988/,{ "Label": "Color Index", "Value": { "List": [ { "Value": 0, "Label": "Off" }, { "Value": 1, "Label": "Cool White" }, { "Value": 2, "Label": "Warm White" }, { "Value": 3, "Label": "Red" }, { "Value": 4, "Label": "Lime" }, { "Value": 5, "Label": "Blue" }, { "Value": 6, "Label": "Yellow" }, { "Value": 7, "Label": "Cyan" }, { "Value": 8, "Label": "Magenta" }, { "Value": 9, "Label": "Silver" }, { "Value": 10, "Label": "Gray" }, { "Value": 11, "Label": "Maroon" }, { "Value": 12, "Label": "Olive" }, { "Value": 13, "Label": "Green" }, { "Value": 14, "Label": "Purple" }, { "Value": 15, "Label": "Teal" }, { "Value": 16, "Label": "Navy" }, { "Value": 17, "Label": "Custom" } ], "Selected": "Warm White" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_COLOR", "Index": 1, "Node": 39, "Genre": "User", "Help": "Preset Color", "ValueIDKey": 281475636051988, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/,{ "Instance": 1, "CommandClassId": 94, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/668434449/,{ "Label": "ZWave+ Version", "Value": 1, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 0, "Node": 39, "Genre": "System", "Help": "ZWave+ Version Supported on the Device", "ValueIDKey": 668434449, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/281475645145110/,{ "Label": "InstallerIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 1, "Node": 39, "Genre": "System", "Help": "Icon File to use for the Installer Application", "ValueIDKey": 281475645145110, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/94/value/562950621855766/,{ "Label": "UserIcon", "Value": 1536, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_ZWAVEPLUS_INFO", "Index": 2, "Node": 39, "Genre": "System", "Help": "Icon File to use for the User Application", "ValueIDKey": 562950621855766, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/,{ "Instance": 1, "CommandClassId": 112, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/281475641245716/,{ "Label": "User custom mode LED animations", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Blink Colors in order mode" }, { "Value": 2, "Label": "Randomized blink color mode" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 2, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 1, "Node": 39, "Genre": "Config", "Help": "User custom mode for LED animations", "ValueIDKey": 281475641245716, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/562950617956372/,{ "Label": "Strobe over Custom Color", "Value": { "List": [ { "Value": 0, "Label": "Disable" }, { "Value": 1, "Label": "Enable" } ], "Selected": "Disable" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 2, "Node": 39, "Genre": "Config", "Help": "Enable/Disable Strobe over Custom Color.", "ValueIDKey": 562950617956372, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/844425594667027/,{ "Label": "Set the rate of change to next color in Custom Mode", "Value": 50, "Units": "ms", "Min": 5, "Max": 8640000, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 3, "Node": 39, "Genre": "Config", "Help": "Set the rate of change to next color in Custom Mode.", "ValueIDKey": 844425594667027, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/1125900571377681/,{ "Label": "Set color that LED Bulb blinks", "Value": 1, "Units": "", "Min": 1, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 4, "Node": 39, "Genre": "Config", "Help": "Set color that LED Bulb blinks in Blink Mode.", "ValueIDKey": 1125900571377681, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/4503600291905553/,{ "Label": "Ramp rate when dimming using Multilevel Switch", "Value": 20, "Units": "100ms", "Min": 0, "Max": 100, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 16, "Node": 39, "Genre": "Config", "Help": "Specifying the ramp rate when dimming using Multilevel Switch V1 CC in 100ms.", "ValueIDKey": 4503600291905553, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22517998801387540/,{ "Label": "Notification", "Value": { "List": [ { "Value": 0, "Label": "Nothing" }, { "Value": 1, "Label": "Basic CC report" } ], "Selected": "Basic CC report" }, "Units": "", "Min": 0, "Max": 1, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 80, "Node": 39, "Genre": "Config", "Help": "Enable to send notifications to associated devices (Group 1) when the state of LED Bulb is changed.", "ValueIDKey": 22517998801387540, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/22799473778098198/,{ "Label": "Warm White temperature", "Value": 2700, "Units": "k", "Min": 2700, "Max": 4999, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 81, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in warm white color component. available value: 2700k to 4999k", "ValueIDKey": 22799473778098198, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/112/value/23080948754808854/,{ "Label": "cold white temperature", "Value": 6500, "Units": "k", "Min": 5000, "Max": 6500, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_CONFIGURATION", "Index": 82, "Node": 39, "Genre": "Config", "Help": "Adjusting the color temperature in cold white color component. available value:5000k to 6500k", "ValueIDKey": 23080948754808854, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/,{ "Instance": 1, "CommandClassId": 114, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/668762131/,{ "Label": "Loaded Config Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 0, "Node": 39, "Genre": "System", "Help": "Revision of the Config file currently loaded", "ValueIDKey": 668762131, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/281475645472787/,{ "Label": "Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 1, "Node": 39, "Genre": "System", "Help": "Revision of the Config file on the File System", "ValueIDKey": 281475645472787, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/562950622183443/,{ "Label": "Latest Available Config File Revision", "Value": 3, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 2, "Node": 39, "Genre": "System", "Help": "Latest Revision of the Config file available for download", "ValueIDKey": 562950622183443, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/844425598894103/,{ "Label": "Device ID", "Value": "", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 3, "Node": 39, "Genre": "System", "Help": "Manufacturer Specific Device ID/Model", "ValueIDKey": 844425598894103, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/114/value/1125900575604759/,{ "Label": "Serial Number", "Value": "00001cd6bda18c83", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_MANUFACTURER_SPECIFIC", "Index": 4, "Node": 39, "Genre": "System", "Help": "Device Serial Number", "ValueIDKey": 1125900575604759, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/,{ "Instance": 1, "CommandClassId": 115, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/668778516/,{ "Label": "Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 0, "Node": 39, "Genre": "System", "Help": "Output RF PowerLevel", "ValueIDKey": 668778516, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/281475645489169/,{ "Label": "Timeout", "Value": 0, "Units": "seconds", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 1, "Node": 39, "Genre": "System", "Help": "Timeout till the PowerLevel is reset to Normal", "ValueIDKey": 281475645489169, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/562950622199832/,{ "Label": "Set Powerlevel", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 2, "Node": 39, "Genre": "System", "Help": "Apply the Output PowerLevel and Timeout Values", "ValueIDKey": 562950622199832, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/844425598910481/,{ "Label": "Test Node", "Value": 0, "Units": "", "Min": 0, "Max": 255, "Type": "Byte", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 3, "Node": 39, "Genre": "System", "Help": "Node to Perform a test against", "ValueIDKey": 844425598910481, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1125900575621140/,{ "Label": "Test Powerlevel", "Value": { "List": [ { "Value": 0, "Label": "Normal" }, { "Value": 1, "Label": "-1dB" }, { "Value": 2, "Label": "-2dB" }, { "Value": 3, "Label": "-3dB" }, { "Value": 4, "Label": "-4dB" }, { "Value": 5, "Label": "-5dB" }, { "Value": 6, "Label": "-6dB" }, { "Value": 7, "Label": "-7dB" }, { "Value": 8, "Label": "-8dB" }, { "Value": 9, "Label": "-9dB" } ], "Selected": "Normal" }, "Units": "dB", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 4, "Node": 39, "Genre": "System", "Help": "PowerLevel to use for the Test", "ValueIDKey": 1125900575621140, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1407375552331798/,{ "Label": "Frame Count", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 5, "Node": 39, "Genre": "System", "Help": "How Many Messages to send to the Note for the Test", "ValueIDKey": 1407375552331798, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1688850529042456/,{ "Label": "Test", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 6, "Node": 39, "Genre": "System", "Help": "Perform a PowerLevel Test against the a Node", "ValueIDKey": 1688850529042456, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/1970325505753112/,{ "Label": "Report", "Value": false, "Units": "", "Min": 0, "Max": 0, "Type": "Button", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 7, "Node": 39, "Genre": "System", "Help": "Get the results of the latest PowerLevel Test against a Node", "ValueIDKey": 1970325505753112, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2251800482463764/,{ "Label": "Test Status", "Value": { "List": [ { "Value": 0, "Label": "Failed" }, { "Value": 1, "Label": "Success" }, { "Value": 2, "Label": "In Progress" } ], "Selected": "Failed" }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 8, "Node": 39, "Genre": "System", "Help": "The Current Status of the last PowerNode Test Executed", "ValueIDKey": 2251800482463764, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/115/value/2533275459174422/,{ "Label": "Acked Frames", "Value": 0, "Units": "", "Min": -32768, "Max": 32767, "Type": "Short", "Instance": 1, "CommandClass": "COMMAND_CLASS_POWERLEVEL", "Index": 9, "Node": 39, "Genre": "System", "Help": "Number of Messages successfully Acked by the Target Node", "ValueIDKey": 2533275459174422, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/,{ "Instance": 1, "CommandClassId": 134, "CommandClass": "COMMAND_CLASS_VERSION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/669089815/,{ "Label": "Library Version", "Value": "3", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 0, "Node": 39, "Genre": "System", "Help": "Z-Wave Library Version", "ValueIDKey": 669089815, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/281475645800471/,{ "Label": "Protocol Version", "Value": "4.38", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 1, "Node": 39, "Genre": "System", "Help": "Z-Wave Protocol Version", "ValueIDKey": 281475645800471, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/134/value/562950622511127/,{ "Label": "Application Version", "Value": "2.00", "Units": "", "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_VERSION", "Index": 2, "Node": 39, "Genre": "System", "Help": "Application Version", "ValueIDKey": 562950622511127, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueAdded", "TimeStamp": 1579566891} +OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "MaxAssociations": 1, "Members": [ "1.0" ], "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} +OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} +OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file diff --git a/tests/fixtures/ozw/light_wc_network_dump.csv b/tests/fixtures/ozw/light_wc_network_dump.csv index 8d3031b0873..7af15f9926a 100644 --- a/tests/fixtures/ozw/light_wc_network_dump.csv +++ b/tests/fixtures/ozw/light_wc_network_dump.csv @@ -1,4 +1,4 @@ -OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1008", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} +OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1214", "OZWDeamon_Version": "0.1", "QTOpenZWave_Version": "1.0.0", "QT_Version": "5.12.5", "Status": "driverAllNodesQueried", "TimeStamp": 1579566933, "ManufacturerSpecificDBReady": true, "homeID": 3245146787, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"} OpenZWave/1/node/39/,{ "NodeID": 39, "NodeQueryStage": "CacheLoad", "isListening": true, "isFlirs": false, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": false, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0371:0002:0103", "ZWAProductURL": "", "ProductPic": "images/aeotec/zwa002.png", "Description": "✓ Standard form factor and appearance of the light bulb with 800 lm output ✓ RGBW: dimmable from 5% to 100%, tunable from 1800K to 6500K, and 16 million colors ✓ Possible to be included in groups, scenes, or schedules ✓ Suitable for indoor lighting: Corridors, Bedroom, Living Room, etc.", "ProductManualURL": "https://Products.Z-WaveAlliance.org/ProductManual/File?folder=&filename=Manuals/2881/AA LED Bulb 6 说明书(RGBW-AL001)_转曲-2dd.pdf", "ProductPageURL": "", "InclusionHelp": "Add for inclusion 1. Ensure the led bulb has been excluded outside the network. 2. Triggered by OFF ->ON (between 0.5-2 seconds each time) 3. LED solid yellow Color (0xFFFF00) during the pairing(Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to a Warm White LED at 100%  Success: Blinks between 100% White and Green 0x00FF00 color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ExclusionHelp": "Remove for exclusion 1. Assuming led bulb was added to controller. 2. Triggered by OFF -> ON -> OFF -> ON -> OFF -> ON (between 0.5-2 seconds each time). 3. LED Solid Purple/Violet Color (0xEE82EE) during the unpairing process. (Timeout is 10 seconds).  Failure: Blinks between 100% White and Red 0x0000FF color for 3 seconds (at a rate of 200ms per flash), Once 3 seconds have passed, the LED should return to the last color ( memory status(color cc set)) of LED Bulb.  Success: Blinks between 100% White and Blue 0x0000FF color for 3 seconds (at a rate of 200ms per flash). Once 3 seconds have passed, the LED should return to a Warm White LED at 100%.", "ResetHelp": "Reset the Device. 1. Assuming led bulb was added to controller and was power on. 2. RGBW bulb re-power 6 times (between 0.5-2 seconds each time). Note: ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON -> OFF -> ON 3. If the 6th power on, the led bulb change to Yellow color(into pairing process ), which means that the reset factory settings are successf. Using this action in case of the primary controller is missing or inoperable.", "WakeupHelp": "", "ProductSupportURL": "", "Frequency": "", "Name": "LED Bulb 6:Multi-Colour", "ProductPicBase64": "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" }, "Event": "nodeNaming", "TimeStamp": 1579566891, "NodeManufacturerName": "Aeotec Limited", "NodeProductName": "ZWA002 LED Bulb 6 Multi-Color", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Multilevel Switch", "NodeGeneric": 17, "NodeSpecificString": "Multilevel Power Switch", "NodeSpecific": 1, "NodeManufacturerID": "0x0371", "NodeProductType": "0x0103", "NodeProductID": "0x0002", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1} OpenZWave/1/node/39/instance/1/,{ "Instance": 1, "TimeStamp": 1579566891} OpenZWave/1/node/39/instance/1/commandclass/38/,{ "Instance": 1, "CommandClassId": 38, "CommandClass": "COMMAND_CLASS_SWITCH_MULTILEVEL", "TimeStamp": 1579566891} From f06ae1fa95a7e6a7f5d8172bd6e365b2a4ef34e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2020 19:43:42 -1000 Subject: [PATCH 182/362] Prevent kodi from blocking startup (#38257) * Prevent kodi from blocking startup * Update homeassistant/components/kodi/media_player.py * isort * ignore args * adjustments per review * asyncio --- homeassistant/components/kodi/media_player.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index ac31716b887..81f8696a31a 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -1,5 +1,7 @@ """Support for interfacing with the XBMC/Kodi JSON-RPC API.""" +import asyncio from collections import OrderedDict +from datetime import timedelta from functools import wraps import logging import re @@ -53,6 +55,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template import homeassistant.util.dt as dt_util from homeassistant.util.yaml import dump @@ -82,6 +85,8 @@ DEPRECATED_TURN_OFF_ACTIONS = { "shutdown": "System.Shutdown", } +WEBSOCKET_WATCHDOG_INTERVAL = timedelta(minutes=3) + # https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h MEDIA_TYPES = { "music": MEDIA_TYPE_MUSIC, @@ -435,6 +440,26 @@ class KodiDevice(MediaPlayerEntity): # run until the websocket connection is closed. self.hass.loop.create_task(ws_loop_wrapper()) + async def async_added_to_hass(self): + """Connect the websocket if needed.""" + if not self._enable_websocket: + return + + asyncio.create_task(self.async_ws_connect()) + + self.async_on_remove( + async_track_time_interval( + self.hass, + self._async_connect_websocket_if_disconnected, + WEBSOCKET_WATCHDOG_INTERVAL, + ) + ) + + async def _async_connect_websocket_if_disconnected(self, *_): + """Reconnect the websocket if it fails.""" + if not self._ws_server.connected: + await self.async_ws_connect() + async def async_update(self): """Retrieve latest state.""" self._players = await self._get_players() @@ -445,9 +470,6 @@ class KodiDevice(MediaPlayerEntity): self._app_properties = {} return - if self._enable_websocket and not self._ws_server.connected: - self.hass.async_create_task(self.async_ws_connect()) - self._app_properties = await self.server.Application.GetProperties( ["volume", "muted"] ) From 77b6f8c9f2348991bfe4a5ca5c671ba69e7a3453 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jul 2020 19:57:36 -1000 Subject: [PATCH 183/362] Prevent speedtest from blocking startup or causing other intergations to fail setup (#38305) When speedtest starts up, it would saturate the network interface and cause other integrations to randomly fail to setup. We now wait to do the first speed test until after the started event is fired. --- .../components/speedtestdotnet/__init__.py | 34 +++++++++++++------ .../components/speedtestdotnet/sensor.py | 8 ++--- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 2ef49877031..6fd2dec5efd 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -6,7 +6,12 @@ import speedtest import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import CoreState from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -70,10 +75,25 @@ async def async_setup_entry(hass, config_entry): coordinator = SpeedTestDataCoordinator(hass, config_entry) await coordinator.async_setup() - if not config_entry.options[CONF_MANUAL]: + async def _enable_scheduled_speedtests(*_): + """Activate the data update coordinator.""" + coordinator.update_interval = timedelta( + minutes=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) await coordinator.async_refresh() - if not coordinator.last_update_success: - raise ConfigEntryNotReady + + if not config_entry.options[CONF_MANUAL]: + if hass.state == CoreState.running: + await _enable_scheduled_speedtests() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + else: + # Running a speed test during startup can prevent + # integrations from being able to setup because it + # can saturate the network interface. + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STARTED, _enable_scheduled_speedtests + ) hass.data[DOMAIN] = coordinator @@ -107,12 +127,6 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): super().__init__( self.hass, _LOGGER, name=DOMAIN, update_method=self.async_update, ) - if not self.config_entry.options.get(CONF_MANUAL): - self.update_interval = timedelta( - minutes=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - ) def update_servers(self): """Update list of test servers.""" diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 0889d7da5b2..d071a226a05 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -12,7 +12,6 @@ from .const import ( ATTR_SERVER_ID, ATTR_SERVER_NAME, ATTRIBUTION, - CONF_MANUAL, DEFAULT_NAME, DOMAIN, ICON, @@ -97,10 +96,9 @@ class SpeedtestSensor(RestoreEntity): async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() - if self.coordinator.config_entry.options[CONF_MANUAL]: - state = await self.async_get_last_state() - if state: - self._state = state.state + state = await self.async_get_last_state() + if state: + self._state = state.state @callback def update(): From ae8a38757aa368d55a1748962c874ffe29ab11eb Mon Sep 17 00:00:00 2001 From: Chris Mandich Date: Tue, 28 Jul 2020 00:30:38 -0700 Subject: [PATCH 184/362] Update PyFlume version, support for multiple state attributes (#38138) * Update PyFlume version, support for multiple state attributes * Update PyFlume to resolve issue https://github.com/ChrisMandich/PyFlume/issues/7 * Update PyFlume package to 0.5.2, flatten values in sensor * Delete setup * Remove 'current_interval' from attributes and round values to 1 decimal place. * Add missing brackets to remove 'current_interval' from attributes * Set attribute keys explicitly, check attribute format. * Breakout intervals into separate sensors. * Update 'unit_of_measurement' for each sensor, update sensor 'available', remove unusued variables * Update "Device unique ID." Co-authored-by: Martin Hjelmare * Update PyFlume, resolve API query update for request. * Cleanup debug logging Co-authored-by: Martin Hjelmare --- homeassistant/components/flume/const.py | 10 +++ homeassistant/components/flume/manifest.json | 2 +- homeassistant/components/flume/sensor.py | 90 +++++++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 80 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 4f05b93ea22..a7bb9fbd3c8 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -6,6 +6,15 @@ PLATFORMS = ["sensor"] DEFAULT_NAME = "Flume Sensor" FLUME_TYPE_SENSOR = 2 +FLUME_QUERIES_SENSOR = { + "current_interval": {"friendly_name": "Current", "unit_of_measurement": "gal/m"}, + "month_to_date": {"friendly_name": "Current Month", "unit_of_measurement": "gal"}, + "week_to_date": {"friendly_name": "Current Week", "unit_of_measurement": "gal"}, + "today": {"friendly_name": "Current Day", "unit_of_measurement": "gal"}, + "last_60_min": {"friendly_name": "60 Minutes", "unit_of_measurement": "gal/h"}, + "last_24_hrs": {"friendly_name": "24 Hours", "unit_of_measurement": "gal/d"}, + "last_30_days": {"friendly_name": "30 Days", "unit_of_measurement": "gal/mo"}, +} FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" @@ -20,3 +29,4 @@ KEY_DEVICE_TYPE = "type" KEY_DEVICE_ID = "id" KEY_DEVICE_LOCATION = "location" KEY_DEVICE_LOCATION_NAME = "name" +KEY_DEVICE_LOCATION_TIMEZONE = "tz" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index f801eedf73b..3698df3c269 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -2,7 +2,7 @@ "domain": "flume", "name": "Flume", "documentation": "https://www.home-assistant.io/integrations/flume/", - "requirements": ["pyflume==0.4.0"], + "requirements": ["pyflume==0.5.5"], "dependencies": [], "codeowners": ["@ChrisMandich", "@bdraco"], "config_flow": true diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 9ec54ca1a8c..596bf5a0b8b 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -1,6 +1,7 @@ """Sensor for displaying the number of result from Flume.""" from datetime import timedelta import logging +from numbers import Number from pyflume import FlumeData import voluptuous as vol @@ -24,10 +25,12 @@ from .const import ( FLUME_AUTH, FLUME_DEVICES, FLUME_HTTP_SESSION, + FLUME_QUERIES_SENSOR, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, KEY_DEVICE_LOCATION, KEY_DEVICE_LOCATION_NAME, + KEY_DEVICE_LOCATION_TIMEZONE, KEY_DEVICE_TYPE, ) @@ -49,7 +52,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Import the platform into a config entry.""" - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=config @@ -59,7 +61,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Flume sensor.""" - flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] flume_auth = flume_domain_data[FLUME_AUTH] @@ -76,17 +77,28 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device_id = device[KEY_DEVICE_ID] device_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME] + device_timezone = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_TIMEZONE] device_friendly_name = f"{name} {device_name}" flume_device = FlumeData( flume_auth, device_id, + device_timezone, SCAN_INTERVAL, update_on_init=False, http_session=http_session, ) - flume_entity_list.append( - FlumeSensor(flume_device, device_friendly_name, device_id) - ) + + flume_data = FlumeSensorData(flume_device) + + for flume_query_sensor in FLUME_QUERIES_SENSOR.items(): + flume_entity_list.append( + FlumeSensor( + flume_data, + flume_query_sensor, + f"{device_friendly_name} {flume_query_sensor[1]['friendly_name']}", + device_id, + ) + ) if flume_entity_list: async_add_entities(flume_entity_list) @@ -95,13 +107,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FlumeSensor(Entity): """Representation of the Flume sensor.""" - def __init__(self, flume_device, name, device_id): + def __init__(self, flume_data, flume_query_sensor, name, device_id): """Initialize the Flume sensor.""" - self._flume_device = flume_device + self._flume_data = flume_data + self._flume_query_sensor = flume_query_sensor self._name = name self._device_id = device_id self._undo_track_sensor = None - self._available = False + self._available = self._flume_data.available self._state = None @property @@ -128,7 +141,7 @@ class FlumeSensor(Entity): def unit_of_measurement(self): """Return the unit the value is expressed in.""" # This is in gallons per SCAN_INTERVAL - return "gal/m" + return self._flume_query_sensor[1]["unit_of_measurement"] @property def available(self): @@ -137,26 +150,57 @@ class FlumeSensor(Entity): @property def unique_id(self): - """Device unique ID.""" - return self._device_id + """Flume query and Device unique ID.""" + return f"{self._flume_query_sensor[0]}_{self._device_id}" - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data and updates the states.""" - _LOGGER.debug("Updating flume sensor: %s", self._name) - try: - self._flume_device.update_force() - except Exception as ex: # pylint: disable=broad-except - if self._available: - _LOGGER.error("Update of flume sensor %s failed: %s", self._name, ex) - self._available = False - return - _LOGGER.debug("Successful update of flume sensor: %s", self._name) - self._state = self._flume_device.value - self._available = True + + def format_state_value(value): + return round(value, 1) if isinstance(value, Number) else None + + self._flume_data.update() + self._state = format_state_value( + self._flume_data.flume_device.values[self._flume_query_sensor[0]] + ) + _LOGGER.debug( + "Updating sensor: '%s', value: '%s'", + self._name, + self._flume_data.flume_device.values[self._flume_query_sensor[0]], + ) + self._available = self._flume_data.available async def async_added_to_hass(self): """Request an update when added.""" # We do ask for an update with async_add_entities() # because it will update disabled entities self.async_schedule_update_ha_state() + + +class FlumeSensorData: + """Get the latest data and update the states.""" + + def __init__(self, flume_device): + """Initialize the data object.""" + self.flume_device = flume_device + self.available = True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the Flume.""" + _LOGGER.debug("Updating Flume data") + try: + self.flume_device.update_force() + except Exception as ex: # pylint: disable=broad-except + if self.available: + _LOGGER.error("Update of Flume data failed: %s", ex) + self.available = False + return + self.available = True + _LOGGER.debug( + "Flume update details: %s", + { + "values": self.flume_device.values, + "query_payload": self.flume_device.query_payload, + }, + ) diff --git a/requirements_all.txt b/requirements_all.txt index 85d3dd5983b..8b551ce729e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1334,7 +1334,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.flume -pyflume==0.4.0 +pyflume==0.5.5 # homeassistant.components.flunearyou pyflunearyou==1.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c342a6b8aea..5fbc89a3bf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -616,7 +616,7 @@ pyeverlights==0.1.0 pyfido==2.1.1 # homeassistant.components.flume -pyflume==0.4.0 +pyflume==0.5.5 # homeassistant.components.flunearyou pyflunearyou==1.0.7 From 508fc3fa0e083fdf1f6e05df332de404fc8a41a8 Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Tue, 28 Jul 2020 00:55:24 -0700 Subject: [PATCH 185/362] Fix lg_soundbar callback (#38259) * Don't schedule an update if the hass instance isn't instantiated If we get a status update packet before self.hass exists, we trip a "assert self.hass is not None" that was added in 0.112 and setup fails. * Fix callback hander properly The right fix is to register the callback after hass is ready for it. * Remove unnecessary check This is now guaranteed by the core code. * Don't request an immediate device update and do an async connect. * Remove unnecessary return --- .../components/lg_soundbar/media_player.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index a10b46f89ce..0b38bc1ab8d 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -25,7 +25,7 @@ SUPPORT_LG = ( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the LG platform.""" if discovery_info is not None: - add_entities([LGDevice(discovery_info)], True) + add_entities([LGDevice(discovery_info)]) class LGDevice(MediaPlayerEntity): @@ -33,8 +33,8 @@ class LGDevice(MediaPlayerEntity): def __init__(self, discovery_info): """Initialize the LG speakers.""" - host = discovery_info.get("host") - port = discovery_info.get("port") + self._host = discovery_info.get("host") + self._port = discovery_info.get("port") self._name = "" self._volume = 0 @@ -53,8 +53,17 @@ class LGDevice(MediaPlayerEntity): self._woofer_volume_max = 0 self._bass = 0 self._treble = 0 + self._device = None - self._device = temescal.temescal(host, port=port, callback=self.handle_event) + async def async_added_to_hass(self): + """Register the callback after hass is ready for it.""" + await self.hass.async_add_executor_job(self._connect) + + def _connect(self): + """Perform the actual devices setup.""" + self._device = temescal.temescal( + self._host, port=self._port, callback=self.handle_event + ) self.update() def handle_event(self, response): From 0a7dc407127df9b886bbad841e6ea2d2f8d72b7a Mon Sep 17 00:00:00 2001 From: Kyle Hendricks Date: Tue, 28 Jul 2020 06:03:56 -0400 Subject: [PATCH 186/362] Fix issue with certain Samsung TVs repeatedly showing auth dialog (#38308) Through some testing with the samsungtvws library, it was determined that the issue is related to the short read timeout (1s). Increasing the timeout to 10s should solve the issue. --- homeassistant/components/samsungtv/bridge.py | 2 +- tests/components/samsungtv/test_media_player.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 472ce894e1a..83b8ea3d138 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -250,7 +250,7 @@ class SamsungTVWSBridge(SamsungTVBridge): host=self.host, port=self.port, token=self.token, - timeout=1, + timeout=10, name=VALUE_CONF_NAME, ) self._remote.open() diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 15ac13c64d5..5d6c36153c2 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -97,7 +97,7 @@ MOCK_CALLS_ENTRY_WS = { "host": "fake", "name": "HomeAssistant", "port": 8001, - "timeout": 1, + "timeout": 10, "token": "abcde", } From a92a7ec84848edfbb1187579ba4afcdc42b3d5a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2020 12:05:20 +0200 Subject: [PATCH 187/362] Bump actions/upload-artifact from 2.1.0 to v2.1.1 (#38315) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 14bfd9d6ee5..af2a011649d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -737,7 +737,7 @@ jobs: -p no:sugar \ tests - name: Upload coverage artifact - uses: actions/upload-artifact@2.1.0 + uses: actions/upload-artifact@v2.1.1 with: name: coverage-${{ matrix.python-version }}-group${{ matrix.group }} path: .coverage From e8c9734f3aa26112411b0d12b8c1d106715eb8d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jul 2020 05:26:06 -1000 Subject: [PATCH 188/362] Bump tesla-powerwall to 0.2.12 to handle powerwall firmware 1.48+ (#38180) --- homeassistant/components/powerwall/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 1ba9562c4b7..930da38edde 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,6 +3,6 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.2.11"], + "requirements": ["tesla-powerwall==0.2.12"], "codeowners": ["@bdraco", "@jrester"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b551ce729e..0028e0038f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2101,7 +2101,7 @@ temperusb==1.5.3 # tensorflow==1.13.2 # homeassistant.components.powerwall -tesla-powerwall==0.2.11 +tesla-powerwall==0.2.12 # homeassistant.components.tesla teslajsonpy==0.10.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5fbc89a3bf7..cfc5babc36f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -927,7 +927,7 @@ sunwatcher==0.2.1 tellduslive==0.10.11 # homeassistant.components.powerwall -tesla-powerwall==0.2.11 +tesla-powerwall==0.2.12 # homeassistant.components.tesla teslajsonpy==0.10.1 From 2c6686c5e13def2b05198c9903c0f2d7661b2527 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Jul 2020 17:51:35 +0200 Subject: [PATCH 189/362] Remove AdGuard version check (#38326) --- homeassistant/components/adguard/__init__.py | 10 +--- .../components/adguard/config_flow.py | 25 ++------- homeassistant/components/adguard/const.py | 2 - homeassistant/components/adguard/strings.json | 2 - tests/components/adguard/test_config_flow.py | 51 +------------------ 5 files changed, 5 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 84e86bfcaba..71dff2ab6ee 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -1,5 +1,4 @@ """Support for AdGuard Home.""" -from distutils.version import LooseVersion import logging from typing import Any, Dict @@ -11,7 +10,6 @@ from homeassistant.components.adguard.const import ( DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN, - MIN_ADGUARD_HOME_VERSION, SERVICE_ADD_URL, SERVICE_DISABLE_URL, SERVICE_ENABLE_URL, @@ -67,16 +65,10 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard try: - version = await adguard.version() + await adguard.version() except AdGuardHomeConnectionError as exception: raise ConfigEntryNotReady from exception - if version and LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): - _LOGGER.error( - "This integration requires AdGuard Home v0.99.0 or higher to work correctly" - ) - raise ConfigEntryNotReady - for component in "sensor", "switch": hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index ede01706c5d..a0ace623862 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -1,12 +1,11 @@ """Config flow to configure the AdGuard Home integration.""" -from distutils.version import LooseVersion import logging from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.adguard.const import DOMAIN, MIN_ADGUARD_HOME_VERSION +from homeassistant.components.adguard.const import DOMAIN from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, @@ -79,20 +78,11 @@ class AdGuardHomeFlowHandler(ConfigFlow): ) try: - version = await adguard.version() + await adguard.version() except AdGuardHomeConnectionError: errors["base"] = "connection_error" return await self._show_setup_form(errors) - if version and LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): - return self.async_abort( - reason="adguard_home_outdated", - description_placeholders={ - "current_version": version, - "minimal_version": MIN_ADGUARD_HOME_VERSION, - }, - ) - return self.async_create_entry( title=user_input[CONF_HOST], data={ @@ -160,20 +150,11 @@ class AdGuardHomeFlowHandler(ConfigFlow): ) try: - version = await adguard.version() + await adguard.version() except AdGuardHomeConnectionError: errors["base"] = "connection_error" return await self._show_hassio_form(errors) - if LooseVersion(MIN_ADGUARD_HOME_VERSION) > LooseVersion(version): - return self.async_abort( - reason="adguard_home_addon_outdated", - description_placeholders={ - "current_version": version, - "minimal_version": MIN_ADGUARD_HOME_VERSION, - }, - ) - return self.async_create_entry( title=self._hassio_discovery["addon"], data={ diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py index eb12a9c163f..c77d76a70cf 100644 --- a/homeassistant/components/adguard/const.py +++ b/homeassistant/components/adguard/const.py @@ -7,8 +7,6 @@ DATA_ADGUARD_VERION = "adguard_version" CONF_FORCE = "force" -MIN_ADGUARD_HOME_VERSION = "v0.99.0" - SERVICE_ADD_URL = "add_url" SERVICE_DISABLE_URL = "disable_url" SERVICE_ENABLE_URL = "enable_url" diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index f5f780c70b5..f010f9e2ade 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -19,8 +19,6 @@ }, "error": { "connection_error": "Failed to connect." }, "abort": { - "adguard_home_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}.", - "adguard_home_addon_outdated": "This integration requires AdGuard Home {minimal_version} or higher, you have {current_version}. Please update your Hass.io AdGuard Home add-on.", "existing_instance_updated": "Updated existing configuration.", "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed." } diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index d0e874bacdc..69e1285889b 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -4,7 +4,7 @@ import aiohttp from homeassistant import config_entries, data_entry_flow from homeassistant.components.adguard import config_flow -from homeassistant.components.adguard.const import DOMAIN, MIN_ADGUARD_HOME_VERSION +from homeassistant.components.adguard.const import DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -229,52 +229,3 @@ async def test_hassio_connection_error(hass, aioclient_mock): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "hassio_confirm" assert result["errors"] == {"base": "connection_error"} - - -async def test_outdated_adguard_version(hass, aioclient_mock): - """Test we show abort when connecting with unsupported AdGuard version.""" - aioclient_mock.get( - f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}" - f"://{FIXTURE_USER_INPUT[CONF_HOST]}" - f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status", - json={"version": "v0.98.0"}, - headers={"Content-Type": "application/json"}, - ) - - flow = config_flow.AdGuardHomeFlowHandler() - flow.hass = hass - result = await flow.async_step_user(user_input=None) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "adguard_home_outdated" - assert result["description_placeholders"] == { - "current_version": "v0.98.0", - "minimal_version": MIN_ADGUARD_HOME_VERSION, - } - - -async def test_outdated_adguard_addon_version(hass, aioclient_mock): - """Test we show abort when connecting with unsupported AdGuard add-on version.""" - aioclient_mock.get( - "http://mock-adguard:3000/control/status", - json={"version": "v0.98.0"}, - headers={"Content-Type": "application/json"}, - ) - - result = await hass.config_entries.flow.async_init( - "adguard", - data={"addon": "AdGuard Home Addon", "host": "mock-adguard", "port": 3000}, - context={"source": "hassio"}, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "adguard_home_addon_outdated" - assert result["description_placeholders"] == { - "current_version": "v0.98.0", - "minimal_version": MIN_ADGUARD_HOME_VERSION, - } From 03582402fa5a83d4518ae994a94ff4bf2cb53aab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jul 2020 06:24:29 -1000 Subject: [PATCH 190/362] Add debug logging for when a chain of tasks blocks startup (#38311) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- homeassistant/core.py | 16 +++++++++++++ tests/test_core.py | 55 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index adeed758555..1d05336e5c4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -393,12 +393,28 @@ class HomeAssistant: """Block until all pending work is done.""" # To flush out any call_soon_threadsafe await asyncio.sleep(0) + start_time: Optional[float] = None while self._pending_tasks: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() if pending: await self._await_and_log_pending(pending) + + if start_time is None: + # Avoid calling monotonic() until we know + # we may need to start logging blocked tasks. + start_time = 0 + elif start_time == 0: + # If we have waited twice then we set the start + # time + start_time = monotonic() + elif monotonic() - start_time > BLOCK_LOG_TIMEOUT: + # We have waited at least three loops and new tasks + # continue to block. At this point we start + # logging all waiting tasks. + for task in pending: + _LOGGER.debug("Waiting for task: %s", task) else: await asyncio.sleep(0) diff --git a/tests/test_core.py b/tests/test_core.py index c144af86804..12ed00fde2c 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1419,9 +1419,62 @@ async def test_log_blocking_events(hass, caplog): hass.async_create_task(_wait_a_bit_1()) await hass.async_block_till_done() - with patch.object(ha, "BLOCK_LOG_TIMEOUT", 0.00001): + with patch.object(ha, "BLOCK_LOG_TIMEOUT", 0.0001): hass.async_create_task(_wait_a_bit_2()) await hass.async_block_till_done() assert "_wait_a_bit_2" in caplog.text assert "_wait_a_bit_1" not in caplog.text + + +async def test_chained_logging_hits_log_timeout(hass, caplog): + """Ensure we log which task is blocking startup when there is a task chain and debug logging is on.""" + caplog.set_level(logging.DEBUG) + + created = 0 + + async def _task_chain_1(): + nonlocal created + created += 1 + if created > 10: + return + hass.async_create_task(_task_chain_2()) + + async def _task_chain_2(): + nonlocal created + created += 1 + if created > 10: + return + hass.async_create_task(_task_chain_1()) + + with patch.object(ha, "BLOCK_LOG_TIMEOUT", 0.0001): + hass.async_create_task(_task_chain_1()) + await hass.async_block_till_done() + + assert "_task_chain_" in caplog.text + + +async def test_chained_logging_misses_log_timeout(hass, caplog): + """Ensure we do not log which task is blocking startup if we do not hit the timeout.""" + caplog.set_level(logging.DEBUG) + + created = 0 + + async def _task_chain_1(): + nonlocal created + created += 1 + if created > 10: + return + hass.async_create_task(_task_chain_2()) + + async def _task_chain_2(): + nonlocal created + created += 1 + if created > 10: + return + hass.async_create_task(_task_chain_1()) + + hass.async_create_task(_task_chain_1()) + await hass.async_block_till_done() + + assert "_task_chain_" not in caplog.text From d2022aa07b319f711486e81d3a2a540f8363135d Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Wed, 29 Jul 2020 07:49:43 +0800 Subject: [PATCH 191/362] Fix songpal already configured check in config flow (#37813) * Fix already configured check * Mark endpoint duplicate check as callback --- homeassistant/components/songpal/config_flow.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 96e1e7ed7df..9acbedd11c7 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback from .const import CONF_ENDPOINT, DOMAIN # pylint: disable=unused-import @@ -74,7 +75,7 @@ class SongpalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_init(self, user_input=None): """Handle a flow start.""" # Check if already configured - if self._endpoint_already_configured(): + if self._async_endpoint_already_configured(): return self.async_abort(reason="already_configured") if user_input is None: @@ -145,9 +146,10 @@ class SongpalConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_init(user_input) - def _endpoint_already_configured(self): + @callback + def _async_endpoint_already_configured(self): """See if we already have an endpoint matching user input configured.""" - existing_endpoints = [ - entry.data[CONF_ENDPOINT] for entry in self._async_current_entries() - ] - return self.conf.endpoint in existing_endpoints + for entry in self._async_current_entries(): + if entry.data.get(CONF_ENDPOINT) == self.conf.endpoint: + return True + return False From ff3d76b4641a7674c4262bb35b5ce1df06dff96b Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Wed, 29 Jul 2020 00:02:39 +0000 Subject: [PATCH 192/362] [ci skip] Translation update --- .../accuweather/translations/es.json | 5 +++- .../azure_devops/translations/es.json | 16 +++++++++++++ .../azure_devops/translations/uk.json | 11 +++++++++ .../components/control4/translations/no.json | 18 ++++++++++++++ .../components/enocean/translations/no.json | 7 ++++++ .../humidifier/translations/no.json | 10 ++++++++ .../simplisafe/translations/es.json | 12 ++++++++-- .../simplisafe/translations/uk.json | 5 ++++ .../components/volumio/translations/ru.json | 12 +++++++--- .../components/volumio/translations/uk.json | 18 ++++++++++++++ .../volumio/translations/zh-Hant.json | 24 +++++++++++++++++++ .../components/wolflink/translations/no.json | 6 +++++ .../wolflink/translations/sensor.no.json | 18 ++++++++++++++ 13 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/azure_devops/translations/uk.json create mode 100644 homeassistant/components/control4/translations/no.json create mode 100644 homeassistant/components/volumio/translations/uk.json create mode 100644 homeassistant/components/volumio/translations/zh-Hant.json diff --git a/homeassistant/components/accuweather/translations/es.json b/homeassistant/components/accuweather/translations/es.json index 88094786c51..4de57575464 100644 --- a/homeassistant/components/accuweather/translations/es.json +++ b/homeassistant/components/accuweather/translations/es.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "No se pudo conectar", - "invalid_api_key": "Clave API no v\u00e1lida" + "invalid_api_key": "Clave API no v\u00e1lida", + "requests_exceeded": "Se ha excedido el n\u00famero permitido de solicitudes a la API de Accuweather. Tienes que esperar o cambiar la Clave API." }, "step": { "user": { @@ -15,6 +16,7 @@ "longitude": "Longitud", "name": "Nombre de la integraci\u00f3n" }, + "description": "Si necesitas ayuda con la configuraci\u00f3n, echa un vistazo aqu\u00ed: https://www.home-assistant.io/integrations/accuweather/ \n\nEl pron\u00f3stico del tiempo no est\u00e1 habilitado por defecto. Puedes habilitarlo en las opciones de la integraci\u00f3n.", "title": "AccuWeather" } } @@ -25,6 +27,7 @@ "data": { "forecast": "Pron\u00f3stico del tiempo" }, + "description": "Debido a las limitaciones de la versi\u00f3n gratuita de la clave API de AccuWeather, cuando habilitas el pron\u00f3stico del tiempo, las actualizaciones de datos se realizar\u00e1n cada 64 minutos en lugar de cada 32 minutos.", "title": "Opciones de AccuWeather" } } diff --git a/homeassistant/components/azure_devops/translations/es.json b/homeassistant/components/azure_devops/translations/es.json index 7fc78e8f88e..ccf4658d727 100644 --- a/homeassistant/components/azure_devops/translations/es.json +++ b/homeassistant/components/azure_devops/translations/es.json @@ -1,14 +1,30 @@ { "config": { + "abort": { + "already_configured": "La cuenta ya ha sido configurada", + "reauth_successful": "Token de acceso actualizado correctamente " + }, + "error": { + "authorization_error": "Error de autorizaci\u00f3n. Comprueba que tienes acceso al proyecto y las credenciales son correctas.", + "connection_error": "No se pudo conectar con Azure DevOps", + "project_error": "No se pudo obtener informaci\u00f3n del proyecto." + }, + "flow_title": "Azure DevOps: {project_url}", "step": { "reauth": { + "data": { + "personal_access_token": "Token Personal de Acceso (PAT)" + }, + "description": "Error de autenticaci\u00f3n para {project_url}. Por favor, introduce tus credenciales actuales.", "title": "Reautenticaci\u00f3n" }, "user": { "data": { "organization": "Organizaci\u00f3n", + "personal_access_token": "Token Personal de Acceso (PAT)", "project": "Proyecto" }, + "description": "Configura una instancia de Azure DevOps para acceder a tu proyecto. Un Token Personal de Acceso s\u00f3lo es necesario para un proyecto privado.", "title": "A\u00f1adir Proyecto Azure DevOps" } } diff --git a/homeassistant/components/azure_devops/translations/uk.json b/homeassistant/components/azure_devops/translations/uk.json new file mode 100644 index 00000000000..4dd9879e2d9 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/uk.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "organization": "\u041e\u0440\u0433\u0430\u043d\u0456\u0437\u0430\u0446\u0456\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/no.json b/homeassistant/components/control4/translations/no.json new file mode 100644 index 00000000000..3ea1bb403bd --- /dev/null +++ b/homeassistant/components/control4/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "description": "Vennligst skriv inn Control4-kontodetaljene og IP-adressen til din lokale kontroller." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Sekunder mellom oppdateringer" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/enocean/translations/no.json b/homeassistant/components/enocean/translations/no.json index ef16b2a7cbf..5376e9ba8a8 100644 --- a/homeassistant/components/enocean/translations/no.json +++ b/homeassistant/components/enocean/translations/no.json @@ -1,6 +1,13 @@ { "config": { + "flow_title": "ENOcean oppsett", "step": { + "detect": { + "data": { + "path": "USB-donglebane" + }, + "title": "Velg banen til din ENOcean dongle" + }, "manual": { "data": { "path": "USB-donglebane" diff --git a/homeassistant/components/humidifier/translations/no.json b/homeassistant/components/humidifier/translations/no.json index 42caaf0d774..9d6e4b2ae61 100644 --- a/homeassistant/components/humidifier/translations/no.json +++ b/homeassistant/components/humidifier/translations/no.json @@ -6,6 +6,16 @@ "toggle": "Veksle {entity_name}", "turn_off": "Sl\u00e5 av {entity_name}", "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_mode": "{entity_name} er satt til en spesifikk modus", + "is_off": "{entity_name} er av", + "is_on": "{entity_name} er p\u00e5" + }, + "trigger_type": { + "target_humidity_changed": "{entity_name} m\u00e5let fuktighet endret", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" } }, "title": "Luftfukter" diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json index 96badf7b10c..64000e66d62 100644 --- a/homeassistant/components/simplisafe/translations/es.json +++ b/homeassistant/components/simplisafe/translations/es.json @@ -1,18 +1,26 @@ { "config": { "abort": { - "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso." + "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso.", + "reauth_successful": "SimpliSafe se ha reautenticado correctamente." }, "error": { "identifier_exists": "Cuenta ya registrada", "invalid_credentials": "Credenciales no v\u00e1lidas", + "still_awaiting_mfa": "Esperando todav\u00eda el clic en el correo electr\u00f3nico de MFA", "unknown": "Error inesperado" }, "step": { + "mfa": { + "description": "Comprueba tu correo electr\u00f3nico para obtener un enlace desde SimpliSafe. Despu\u00e9s de verificar el enlace, vulve aqu\u00ed para completar la instalaci\u00f3n de la integraci\u00f3n.", + "title": "Autenticaci\u00f3n Multi-Factor SimpliSafe" + }, "reauth_confirm": { "data": { "password": "Contrase\u00f1a" - } + }, + "description": "Tu token de acceso ha expirado o ha sido revocado. Introduce tu contrase\u00f1a para volver a vincular tu cuenta.", + "title": "Volver a vincular la Cuenta SimpliSafe" }, "user": { "data": { diff --git a/homeassistant/components/simplisafe/translations/uk.json b/homeassistant/components/simplisafe/translations/uk.json index c7938df009e..376fb4468db 100644 --- a/homeassistant/components/simplisafe/translations/uk.json +++ b/homeassistant/components/simplisafe/translations/uk.json @@ -1,6 +1,11 @@ { "config": { "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u044c", diff --git a/homeassistant/components/volumio/translations/ru.json b/homeassistant/components/volumio/translations/ru.json index 82e8fd9c9d2..84267ff4a4e 100644 --- a/homeassistant/components/volumio/translations/ru.json +++ b/homeassistant/components/volumio/translations/ru.json @@ -1,15 +1,21 @@ { "config": { "abort": { - "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043e" + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u043c\u0443 Volumio." }, "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f", - "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "step": { + "discovery_confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c Volumio `{name}`?", + "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u044b\u0439 Volumio" + }, "user": { "data": { + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/volumio/translations/uk.json b/homeassistant/components/volumio/translations/uk.json new file mode 100644 index 00000000000..d408ddd8810 --- /dev/null +++ b/homeassistant/components/volumio/translations/uk.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "port": "\u041f\u043e\u0440\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/zh-Hant.json b/homeassistant/components/volumio/translations/zh-Hant.json new file mode 100644 index 00000000000..48f3ad6d172 --- /dev/null +++ b/homeassistant/components/volumio/translations/zh-Hant.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u5df2\u63a2\u7d22\u5230\u7684 Volumio" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "discovery_confirm": { + "description": "\u662f\u5426\u8981\u65b0\u589e Volumio (`{name}`) \u81f3 Home Assistant\uff1f", + "title": "\u5df2\u641c\u7d22\u5230\u7684 Volumio" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "port": "\u901a\u8a0a\u57e0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/no.json b/homeassistant/components/wolflink/translations/no.json index f6d1302012e..d8fc1db3db3 100644 --- a/homeassistant/components/wolflink/translations/no.json +++ b/homeassistant/components/wolflink/translations/no.json @@ -1,6 +1,12 @@ { "config": { "step": { + "device": { + "data": { + "device_name": "Enhet" + }, + "title": "Velg WOLF-enhet" + }, "user": { "title": "WOLF SmartSet-tilkobling" } diff --git a/homeassistant/components/wolflink/translations/sensor.no.json b/homeassistant/components/wolflink/translations/sensor.no.json index 7a5692a28ff..fcd93f0b01b 100644 --- a/homeassistant/components/wolflink/translations/sensor.no.json +++ b/homeassistant/components/wolflink/translations/sensor.no.json @@ -29,6 +29,24 @@ "frost_warmwasser": "DHW frost", "frostschutz": "Frostbeskyttelse", "gasdruck": "Gasstrykk", + "glt_betrieb": "BMS-modus", + "gradienten_uberwachung": "Gradient overv\u00e5king", + "heizbetrieb": "Oppvarmingsmodus", + "heizgerat_mit_speicher": "Kjele med sylinder", + "heizung": "Oppvarming", + "initialisierung": "Initialisering", + "kalibration": "Kalibrering", + "kalibration_heizbetrieb": "Kalibrering av varmemodus", + "kalibration_kombibetrieb": "Kalibrering av kombimodus", + "kalibration_warmwasserbetrieb": "DHW-kalibrering", + "kaskadenbetrieb": "Kaskadedrift", + "kombibetrieb": "Kombimodus", + "kombigerat": "Kombikjel", + "kombigerat_mit_solareinbindung": "Kombikjele med solintegrasjon", + "mindest_kombizeit": "Minimum kombinasjonstid", + "nachlauf_heizkreispumpe": "Varmekrets pumpen kj\u00f8res p\u00e5", + "nachspulen": "Post-flush", + "nur_heizgerat": "Bare kjele", "parallelbetrieb": "Parallell modus", "partymodus": "Festmodus", "perm_cooling": "PermKj\u00f8ling", From b7976d28568105c6d09c39b92e6222cba26901ef Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Jul 2020 11:21:47 +0200 Subject: [PATCH 193/362] Add myself to xiaomi miio codeowners (#38350) * add myself to xiaomi miio codeowners * Update CODEOWNERS * Update manifest.json --- CODEOWNERS | 2 +- homeassistant/components/xiaomi_miio/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f5ece6fedf2..b626fde1770 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -469,7 +469,7 @@ homeassistant/components/worldclock/* @fabaff homeassistant/components/xbox_live/* @MartinHjelmare homeassistant/components/xfinity/* @cisasteelersfan homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi -homeassistant/components/xiaomi_miio/* @rytilahti @syssi +homeassistant/components/xiaomi_miio/* @rytilahti @syssi @starkillerOG homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 09719d720c0..853f8e7920b 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "requirements": ["construct==2.9.45", "python-miio==0.5.3"], - "codeowners": ["@rytilahti", "@syssi"], + "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "zeroconf": ["_miio._udp.local."] } From 00e50d18b9a1a229f58340bf0dcffed3752ab337 Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Wed, 29 Jul 2020 15:12:48 +0200 Subject: [PATCH 194/362] Upgrade youtube_dl to version 2020.07.28 (#38328) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 6e3717481cf..62d53b17c47 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2020.06.16.1"], + "requirements": ["youtube_dl==2020.07.28"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index 0028e0038f6..ead9da598c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2257,7 +2257,7 @@ yeelight==0.5.2 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2020.06.16.1 +youtube_dl==2020.07.28 # homeassistant.components.zengge zengge==0.2 From 417e00ee9c31a5e385097dc36b17ff2fdb1b8429 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Jul 2020 15:31:29 +0200 Subject: [PATCH 195/362] Temporary lock pip to 20.1.1 to avoid build issue (#38358) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af2a011649d..1f7434e6aca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,7 +46,7 @@ jobs: run: | python -m venv venv . venv/bin/activate - pip install -U pip setuptools + pip install -U pip==20.1.1 setuptools pip install -r requirements.txt -r requirements_test.txt # Uninstalling typing as a workaround. Eventually we should make sure # all our dependencies drop typing. @@ -603,7 +603,7 @@ jobs: run: | python -m venv venv . venv/bin/activate - pip install -U pip setuptools wheel + pip install -U pip==20.1.1 setuptools wheel pip install -r requirements_all.txt pip install -r requirements_test.txt # Uninstalling typing as a workaround. Eventually we should make sure From 167b10ccc16a1c56b4bdc9ca8221e9a7e4625d10 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Jul 2020 16:11:06 +0200 Subject: [PATCH 196/362] Add wheels job for building core wheels (#38359) --- azure-pipelines-wheels.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index 41755209360..aafacb1e6e1 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -26,6 +26,23 @@ resources: endpoint: 'home-assistant' jobs: +- template: templates/azp-job-wheels.yaml@azure + parameters: + builderVersion: '$(versionWheels)' + builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev' + builderPip: 'Cython;numpy' + skipBinary: 'aiohttp' + wheelsRequirement: 'requirements.txt' + wheelsRequirementDiff: 'requirements_diff.txt' + wheelsConstraint: 'homeassistant/package_constraints.txt' + preBuild: + - script: | + if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then + exit 0 + else + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt + fi + displayName: 'Prepare requirements files for Home Assistant Core wheels' - template: templates/azp-job-wheels.yaml@azure parameters: builderVersion: '$(versionWheels)' From 98ce4897abeea2abc179f9332868b79b1f27134d Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Wed, 29 Jul 2020 08:16:24 -0700 Subject: [PATCH 197/362] Bump androidtv to 0.0.47 and adb-shell to 0.2.1 (#38344) --- homeassistant/components/androidtv/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 40e7575bbb9..7a76a618756 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,8 +3,8 @@ "name": "Android TV", "documentation": "https://www.home-assistant.io/integrations/androidtv", "requirements": [ - "adb-shell[async]==0.2.0", - "androidtv[async]==0.0.46", + "adb-shell[async]==0.2.1", + "androidtv[async]==0.0.47", "pure-python-adb==0.2.2.dev0" ], "codeowners": ["@JeffLIrion"] diff --git a/requirements_all.txt b/requirements_all.txt index ead9da598c5..579da414b7a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -115,7 +115,7 @@ adafruit-circuitpython-bmp280==3.1.1 adafruit-circuitpython-mcp230xx==2.2.2 # homeassistant.components.androidtv -adb-shell[async]==0.2.0 +adb-shell[async]==0.2.1 # homeassistant.components.alarmdecoder adext==0.3 @@ -240,7 +240,7 @@ ambiclimate==0.2.1 amcrest==1.7.0 # homeassistant.components.androidtv -androidtv[async]==0.0.46 +androidtv[async]==0.0.47 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfc5babc36f..f36883fb0f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -49,7 +49,7 @@ abodepy==1.1.0 accuweather==0.0.9 # homeassistant.components.androidtv -adb-shell[async]==0.2.0 +adb-shell[async]==0.2.1 # homeassistant.components.adguard adguardhome==0.4.2 @@ -141,7 +141,7 @@ airly==0.0.2 ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv[async]==0.0.46 +androidtv[async]==0.0.47 # homeassistant.components.apns apns2==0.3.0 From cb40ee342e185b76d1736a0333d5c33de289fdc9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Jul 2020 18:47:48 +0200 Subject: [PATCH 198/362] Add jobs names to Wheels builds (#38363) --- azure-pipelines-wheels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index aafacb1e6e1..c8943595429 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -35,6 +35,7 @@ jobs: wheelsRequirement: 'requirements.txt' wheelsRequirementDiff: 'requirements_diff.txt' wheelsConstraint: 'homeassistant/package_constraints.txt' + jobName: 'Wheels_Core' preBuild: - script: | if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then @@ -52,6 +53,7 @@ jobs: wheelsRequirement: 'requirements_wheels.txt' wheelsRequirementDiff: 'requirements_diff.txt' wheelsConstraint: 'homeassistant/package_constraints.txt' + jobName: 'Wheels_Integrations' preBuild: - script: | cp requirements_all.txt requirements_wheels.txt From 1d01a5ed7bed26d0f56df4f7464fd8a67d07e6a2 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 29 Jul 2020 11:49:13 -0600 Subject: [PATCH 199/362] Update aioharmony to 0.2.6 (#38360) --- homeassistant/components/harmony/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index 40f88ad19ef..4d8b83f4643 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -2,7 +2,7 @@ "domain": "harmony", "name": "Logitech Harmony Hub", "documentation": "https://www.home-assistant.io/integrations/harmony", - "requirements": ["aioharmony==0.2.5"], + "requirements": ["aioharmony==0.2.6"], "codeowners": ["@ehendrix23", "@bramkragten", "@bdraco"], "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 579da414b7a..d74b0f31936 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -170,7 +170,7 @@ aioftp==0.12.0 aioguardian==1.0.1 # homeassistant.components.harmony -aioharmony==0.2.5 +aioharmony==0.2.6 # homeassistant.components.homekit_controller aiohomekit[IP]==0.2.45 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f36883fb0f7..00ea81dbb13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -95,7 +95,7 @@ aiofreepybox==0.0.8 aioguardian==1.0.1 # homeassistant.components.harmony -aioharmony==0.2.5 +aioharmony==0.2.6 # homeassistant.components.homekit_controller aiohomekit[IP]==0.2.45 From b916eb6cf28a3143d6efc2bfa0fce9fd9a256000 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Wed, 29 Jul 2020 11:50:09 -0600 Subject: [PATCH 200/362] Update run-in-env.sh (#36577) --- script/run-in-env.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/script/run-in-env.sh b/script/run-in-env.sh index d9fe17f4b17..cc4d3784693 100755 --- a/script/run-in-env.sh +++ b/script/run-in-env.sh @@ -9,10 +9,12 @@ if [ -s .python-version ]; then fi # other common virtualenvs +my_path=$(git rev-parse --show-toplevel) + for venv in venv .venv .; do - if [ -f $venv/bin/activate ]; then - . $venv/bin/activate - fi + if [ -f "${my_path}/${venv}/bin/activate" ]; then + . "${my_path}/${venv}/bin/activate" + fi done exec "$@" From e86fd9af8a9c441c57b275166592c240f6bff75c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 29 Jul 2020 11:56:44 -0600 Subject: [PATCH 201/362] Bump aioambient to 1.2.0 (#38364) --- homeassistant/components/ambient_station/__init__.py | 2 +- homeassistant/components/ambient_station/config_flow.py | 4 +++- homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 120b83d7923..89b6236d392 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -293,7 +293,7 @@ async def async_setup_entry(hass, config_entry): Client( config_entry.data[CONF_API_KEY], config_entry.data[CONF_APP_KEY], - session, + session=session, ), ) hass.loop.create_task(ambient.ws_connect()) diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index c363a2839fb..a4c0a6aa44f 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -43,7 +43,9 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) - client = Client(user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + client = Client( + user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session=session + ) try: devices = await client.api.get_devices() diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index e73190bb580..cd2a0f5605f 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,6 +3,6 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.1.1"], + "requirements": ["aioambient==1.2.0"], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index d74b0f31936..15423f5445b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -142,7 +142,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.1.1 +aioambient==1.2.0 # homeassistant.components.asuswrt aioasuswrt==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00ea81dbb13..423f70ec7e4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.1.1 +aioambient==1.2.0 # homeassistant.components.asuswrt aioasuswrt==1.2.7 From 497c1587fe9104f104b7ba95214ddb6327c7eeb7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 29 Jul 2020 12:12:07 -0600 Subject: [PATCH 202/362] Bump simplisafe-python to 9.2.2 (#38365) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index c986add4539..0ec77d13b9a 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,6 +3,6 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==9.2.1"], + "requirements": ["simplisafe-python==9.2.2"], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15423f5445b..31c30dd81ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1960,7 +1960,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==9.2.1 +simplisafe-python==9.2.2 # homeassistant.components.sisyphus sisyphus-control==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 423f70ec7e4..c6ef1a101bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -875,7 +875,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==9.2.1 +simplisafe-python==9.2.2 # homeassistant.components.sleepiq sleepyq==0.7 From 1d987b484664a065ca60648acca211bd2f07e1e3 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 29 Jul 2020 13:56:32 -0500 Subject: [PATCH 203/362] Ignore remote Plex clients during plex.tv lookup (#38327) --- homeassistant/components/plex/server.py | 2 +- tests/components/plex/mock_classes.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index db779d67bb0..146291bdbcf 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -115,7 +115,7 @@ class PlexServer: self._plextv_clients = [ x for x in self.account.resources() - if "player" in x.provides and x.presence + if "player" in x.provides and x.presence and x.publicAddressMatches ] _LOGGER.debug( "Current available clients from plex.tv: %s", self._plextv_clients diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index eacee6d9f98..dd8e9a93ab8 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -53,6 +53,7 @@ class MockResource: self.provides = ["player"] self.device = MockPlexClient(f"http://192.168.0.1{index}:32500", index + 10) self.presence = index == 0 + self.publicAddressMatches = True def connect(self, timeout): """Mock the resource connect method.""" From ed104d19272e31bd323f719fbdc7f388ccafc85f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jul 2020 09:20:06 -1000 Subject: [PATCH 204/362] Avoid error with ignored harmony config entries (#38367) --- homeassistant/components/harmony/config_flow.py | 3 +++ tests/components/harmony/test_config_flow.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 9142eed2dba..6d5adabe235 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -164,6 +164,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _host_already_configured(self, host): """See if we already have a harmony entry matching the host.""" for entry in self._async_current_entries(): + if CONF_HOST not in entry.data: + continue + if entry.data[CONF_HOST] == host: return True return False diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index d159a0f025f..acf1ffd16f1 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -153,6 +153,9 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known(hass): ) config_entry.add_to_hass(hass) + config_entry_without_host = MockConfigEntry(domain=DOMAIN, data={"name": "other"},) + config_entry_without_host.add_to_hass(hass) + harmonyapi = _get_mock_harmonyapi(connect=True) with patch( From c5ca484eaa14004d6d8318215448c631335189e1 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 29 Jul 2020 15:49:10 -0400 Subject: [PATCH 205/362] Bump ElkM1 library version. (#38368) To reduce required version of dependent library. No code changed. --- CODEOWNERS | 2 +- homeassistant/components/elkm1/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b626fde1770..10393f2ce17 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -113,7 +113,7 @@ homeassistant/components/edl21/* @mtdcr homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck -homeassistant/components/elkm1/* @bdraco +homeassistant/components/elkm1/* @gwww @bdraco homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 homeassistant/components/emoncms/* @borpin diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index 20b8195d5b8..ca694157ba7 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -2,7 +2,7 @@ "domain": "elkm1", "name": "Elk-M1 Control", "documentation": "https://www.home-assistant.io/integrations/elkm1", - "requirements": ["elkm1-lib==0.7.18"], - "codeowners": ["@bdraco"], + "requirements": ["elkm1-lib==0.7.19"], + "codeowners": ["@gwww", "@bdraco"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 31c30dd81ad..d04788d5a52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ elgato==0.2.0 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==0.7.18 +elkm1-lib==0.7.19 # homeassistant.components.mobile_app emoji==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6ef1a101bf..5d2a206b058 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,7 +267,7 @@ eebrightbox==0.0.4 elgato==0.2.0 # homeassistant.components.elkm1 -elkm1-lib==0.7.18 +elkm1-lib==0.7.19 # homeassistant.components.mobile_app emoji==0.5.4 From 13e8e287784d187f589ba81744ba334031c256ae Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 29 Jul 2020 15:35:26 -0500 Subject: [PATCH 206/362] Add basic websocket api for OZW (#38265) --- homeassistant/components/ozw/__init__.py | 5 + homeassistant/components/ozw/websocket_api.py | 114 ++++++++++++++++++ tests/components/ozw/test_websocket_api.py | 57 +++++++++ tests/fixtures/ozw/generic_network_dump.csv | 3 +- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/ozw/websocket_api.py create mode 100644 tests/components/ozw/test_websocket_api.py diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index e7f6e0d3587..fa0eddfbcd1 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -35,6 +35,7 @@ from .entity import ( create_value_id, ) from .services import ZWaveServices +from .websocket_api import ZWaveWebsocketApi _LOGGER = logging.getLogger(__name__) @@ -206,6 +207,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): services = ZWaveServices(hass, manager) services.async_register() + # Register WebSocket API + ws_api = ZWaveWebsocketApi(hass, manager) + ws_api.async_register_api() + @callback def async_receive_message(msg): manager.receive_message(msg.topic, msg.payload) diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py new file mode 100644 index 00000000000..3c11acb90d2 --- /dev/null +++ b/homeassistant/components/ozw/websocket_api.py @@ -0,0 +1,114 @@ +"""Web socket API for OpenZWave.""" + +import logging + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +TYPE = "type" +ID = "id" +OZW_INSTANCE = "ozw_instance" +NODE_ID = "node_id" + + +class ZWaveWebsocketApi: + """Class that holds our websocket api commands.""" + + def __init__(self, hass, manager): + """Initialize with both hass and ozwmanager objects.""" + self._hass = hass + self._manager = manager + + @callback + def async_register_api(self): + """Register all of our api endpoints.""" + websocket_api.async_register_command(self._hass, self.websocket_network_status) + websocket_api.async_register_command(self._hass, self.websocket_node_status) + websocket_api.async_register_command(self._hass, self.websocket_node_statistics) + + @websocket_api.websocket_command( + { + vol.Required(TYPE): "ozw/network_status", + vol.Optional(OZW_INSTANCE, default=1): vol.Coerce(int), + } + ) + def websocket_network_status(self, hass, connection, msg): + """Get Z-Wave network status.""" + + connection.send_result( + msg[ID], + { + "state": self._manager.get_instance(msg[OZW_INSTANCE]) + .get_status() + .status, + OZW_INSTANCE: msg[OZW_INSTANCE], + }, + ) + + @websocket_api.websocket_command( + { + vol.Required(TYPE): "ozw/node_status", + vol.Required(NODE_ID): vol.Coerce(int), + vol.Optional(OZW_INSTANCE, default=1): vol.Coerce(int), + } + ) + def websocket_node_status(self, hass, connection, msg): + """Get the status for a Z-Wave node.""" + + node = self._manager.get_instance(msg[OZW_INSTANCE]).get_node(msg[NODE_ID]) + connection.send_result( + msg[ID], + { + "node_query_stage": node.node_query_stage, + "node_id": node.node_id, + "is_zwave_plus": node.is_zwave_plus, + "is_awake": node.is_awake, + "is_failed": node.is_failed, + "node_baud_rate": node.node_baud_rate, + "is_beaming": node.is_beaming, + "is_flirs": node.is_flirs, + "is_routing": node.is_routing, + "is_securityv1": node.is_securityv1, + "node_basic_string": node.node_basic_string, + "node_generic_string": node.node_generic_string, + "node_specific_string": node.node_specific_string, + OZW_INSTANCE: msg[OZW_INSTANCE], + }, + ) + + @websocket_api.websocket_command( + { + vol.Required(TYPE): "ozw/node_statistics", + vol.Required(NODE_ID): vol.Coerce(int), + vol.Optional(OZW_INSTANCE, default=1): vol.Coerce(int), + } + ) + def websocket_node_statistics(self, hass, connection, msg): + """Get the statistics for a Z-Wave node.""" + + stats = ( + self._manager.get_instance(msg[OZW_INSTANCE]) + .get_node(msg[NODE_ID]) + .get_statistics() + ) + connection.send_result( + msg[ID], + { + "node_id": msg[NODE_ID], + "send_count": stats.send_count, + "sent_failed": stats.sent_failed, + "retries": stats.retries, + "last_request_rtt": stats.last_request_rtt, + "last_response_rtt": stats.last_response_rtt, + "average_request_rtt": stats.average_request_rtt, + "average_response_rtt": stats.average_response_rtt, + "received_packets": stats.received_packets, + "received_dup_packets": stats.received_dup_packets, + "received_unsolicited": stats.received_unsolicited, + OZW_INSTANCE: msg[OZW_INSTANCE], + }, + ) diff --git a/tests/components/ozw/test_websocket_api.py b/tests/components/ozw/test_websocket_api.py new file mode 100644 index 00000000000..7067e4ecd72 --- /dev/null +++ b/tests/components/ozw/test_websocket_api.py @@ -0,0 +1,57 @@ +"""Test OpenZWave Websocket API.""" + +from homeassistant.components.ozw.websocket_api import ID, NODE_ID, OZW_INSTANCE, TYPE + +from .common import setup_ozw + + +async def test_websocket_api(hass, generic_data, hass_ws_client): + """Test the ozw websocket api.""" + await setup_ozw(hass, fixture=generic_data) + client = await hass_ws_client(hass) + + # Test network status + await client.send_json({ID: 5, TYPE: "ozw/network_status"}) + msg = await client.receive_json() + result = msg["result"] + + assert result["state"] == "driverAllNodesQueried" + assert result[OZW_INSTANCE] == 1 + + # Test node status + await client.send_json({ID: 6, TYPE: "ozw/node_status", NODE_ID: 32}) + msg = await client.receive_json() + result = msg["result"] + + assert result[OZW_INSTANCE] == 1 + assert result[NODE_ID] == 32 + assert result["node_query_stage"] == "Complete" + assert result["is_zwave_plus"] + assert result["is_awake"] + assert not result["is_failed"] + assert result["node_baud_rate"] == 100000 + assert result["is_beaming"] + assert not result["is_flirs"] + assert result["is_routing"] + assert not result["is_securityv1"] + assert result["node_basic_string"] == "Routing Slave" + assert result["node_generic_string"] == "Binary Switch" + assert result["node_specific_string"] == "Binary Power Switch" + + # Test node statistics + await client.send_json({ID: 7, TYPE: "ozw/node_statistics", NODE_ID: 39}) + msg = await client.receive_json() + result = msg["result"] + + assert result[OZW_INSTANCE] == 1 + assert result[NODE_ID] == 39 + assert result["send_count"] == 57 + assert result["sent_failed"] == 0 + assert result["retries"] == 1 + assert result["last_request_rtt"] == 26 + assert result["last_response_rtt"] == 38 + assert result["average_request_rtt"] == 29 + assert result["average_response_rtt"] == 37 + assert result["received_packets"] == 3594 + assert result["received_dup_packets"] == 12 + assert result["received_unsolicited"] == 3546 diff --git a/tests/fixtures/ozw/generic_network_dump.csv b/tests/fixtures/ozw/generic_network_dump.csv index 9214796759a..a953121e881 100644 --- a/tests/fixtures/ozw/generic_network_dump.csv +++ b/tests/fixtures/ozw/generic_network_dump.csv @@ -279,4 +279,5 @@ OpenZWave/1/node/39/association/1/,{ "Name": "Lifeline", "Help": "", "M OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandClassId": 43, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "TimeStamp": 1579566891} OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367} OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630} -OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} \ No newline at end of file +OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710} +OpenZWave/1/node/39/statistics/,{ "sendCount": 57, "sentFailed": 0, "retries": 1, "receivedPackets": 3594, "receivedDupPackets": 12, "receivedUnsolicited": 3546, "lastSentTimeStamp": 1595764791, "lastReceivedTimeStamp": 1595802261, "lastRequestRTT": 26, "averageRequestRTT": 29, "lastResponseRTT": 38, "averageResponseRTT": 37, "quality": 0, "extendedTXSupported": false, "txTime": 0, "hops": 0, "rssi_1": "", "rssi_2": "", "rssi_3": "", "rssi_4": "", "rssi_5": "", "route_1": 0, "route_2": 0, "route_3": 0, "route_4": 0, "ackChannel": 0, "lastTXChannel": 0, "routeScheme": "Idle", "routeUsed": "", "routeSpeed": "Auto", "routeTries": 0, "lastFailedLinkFrom": 0, "lastFailedLinkTo": 0} \ No newline at end of file From 1b593e3169281578f3acbe7377f2aca7ac82d91e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jul 2020 11:20:19 -1000 Subject: [PATCH 207/362] Prevent nut config flow error when checking ignored entries (#38372) --- homeassistant/components/nut/config_flow.py | 1 + tests/components/nut/test_config_flow.py | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 5d90d16f157..7cebf0c6759 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -216,6 +216,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): existing_host_port_aliases = { _format_host_port_alias(entry.data) for entry in self._async_current_entries() + if CONF_HOST in entry.data } return _format_host_port_alias(user_input) in existing_host_port_aliases diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 5a2155441b5..d86c0d8eb88 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -247,7 +247,25 @@ async def test_form_import_dupe(hass): entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "import"}, data=VALID_CONFIG + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=VALID_CONFIG + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_form_import_with_ignored_entry(hass): + """Test we get abort on duplicate import when there is an ignored one.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry(domain=DOMAIN, data=VALID_CONFIG) + entry.add_to_hass(hass) + ignored_entry = MockConfigEntry( + domain=DOMAIN, data={}, source=config_entries.SOURCE_IGNORE + ) + ignored_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=VALID_CONFIG ) assert result["type"] == "abort" assert result["reason"] == "already_configured" From e3dc8a1ff2f5b613c2d2c5f7c1d7702deab72105 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jul 2020 23:46:14 +0200 Subject: [PATCH 208/362] Bump pychromecast to 7.2.0 (#38351) --- homeassistant/components/cast/config_flow.py | 13 +++++++++++-- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cast/test_init.py | 12 +++++------- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 5c2b6dca932..80d4abc9796 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -1,16 +1,25 @@ """Config flow for Cast.""" -from pychromecast.discovery import discover_chromecasts +import functools + +from pychromecast.discovery import discover_chromecasts, stop_discovery from homeassistant import config_entries from homeassistant.helpers import config_entry_flow from .const import DOMAIN +from .helpers import ChromeCastZeroconf async def _async_has_devices(hass): """Return if there are devices that can be discovered.""" - return await hass.async_add_executor_job(discover_chromecasts) + casts, browser = await hass.async_add_executor_job( + functools.partial( + discover_chromecasts, zeroconf_instance=ChromeCastZeroconf.get_zeroconf() + ) + ) + stop_discovery(browser) + return casts config_entry_flow.register_discovery_flow( diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 5d807525226..1187887e864 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==7.1.2"], + "requirements": ["pychromecast==7.2.0"], "after_dependencies": ["cloud","zeroconf"], "zeroconf": ["_googlecast._tcp.local."], "codeowners": ["@emontnemery"] diff --git a/requirements_all.txt b/requirements_all.txt index d04788d5a52..3f623fd3867 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1253,7 +1253,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==7.1.2 +pychromecast==7.2.0 # homeassistant.components.cmus pycmus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d2a206b058..8eb00a4bc36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ pyblackbird==0.5 pybotvac==0.0.17 # homeassistant.components.cast -pychromecast==7.1.2 +pychromecast==7.2.0 # homeassistant.components.coolmaster pycoolmasternet==0.0.4 diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py index 8f194668e56..24be4d53ee6 100644 --- a/tests/components/cast/test_init.py +++ b/tests/components/cast/test_init.py @@ -13,7 +13,9 @@ async def test_creating_entry_sets_up_media_player(hass): "homeassistant.components.cast.media_player.async_setup_entry", return_value=True, ) as mock_setup, patch( - "pychromecast.discovery.discover_chromecasts", return_value=True + "pychromecast.discovery.discover_chromecasts", return_value=(True, None) + ), patch( + "pychromecast.discovery.stop_discovery" ): result = await hass.config_entries.flow.async_init( cast.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -34,9 +36,7 @@ async def test_configuring_cast_creates_entry(hass): """Test that specifying config will create an entry.""" with patch( "homeassistant.components.cast.async_setup_entry", return_value=True - ) as mock_setup, patch( - "pychromecast.discovery.discover_chromecasts", return_value=True - ): + ) as mock_setup: await async_setup_component( hass, cast.DOMAIN, {"cast": {"some_config": "to_trigger_import"}} ) @@ -49,9 +49,7 @@ async def test_not_configuring_cast_not_creates_entry(hass): """Test that no config will not create an entry.""" with patch( "homeassistant.components.cast.async_setup_entry", return_value=True - ) as mock_setup, patch( - "pychromecast.discovery.discover_chromecasts", return_value=True - ): + ) as mock_setup: await async_setup_component(hass, cast.DOMAIN, {}) await hass.async_block_till_done() From 99b624a67614b1cacc5cf48aabdcf6d5c76081e4 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Thu, 30 Jul 2020 00:03:25 +0000 Subject: [PATCH 209/362] [ci skip] Translation update --- .../accuweather/translations/pt.json | 21 +++++++++++++++++++ .../components/adguard/translations/no.json | 2 ++ .../components/awair/translations/no.json | 3 +++ .../components/enocean/translations/no.json | 3 +++ .../garmin_connect/translations/no.json | 3 +++ .../components/gogogate2/translations/no.json | 2 +- .../components/pi_hole/translations/no.json | 2 +- .../components/syncthru/translations/no.json | 3 ++- .../components/volumio/translations/pt.json | 12 +++++++++++ .../components/withings/translations/pt.json | 3 +++ .../wolflink/translations/sensor.ko.json | 12 +++++++++++ 11 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/pt.json create mode 100644 homeassistant/components/volumio/translations/pt.json create mode 100644 homeassistant/components/wolflink/translations/sensor.ko.json diff --git a/homeassistant/components/accuweather/translations/pt.json b/homeassistant/components/accuweather/translations/pt.json new file mode 100644 index 00000000000..6288344fd6b --- /dev/null +++ b/homeassistant/components/accuweather/translations/pt.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Previs\u00e3o meteorol\u00f3gica" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/no.json b/homeassistant/components/adguard/translations/no.json index 0633e817db9..bcd6fa5361d 100644 --- a/homeassistant/components/adguard/translations/no.json +++ b/homeassistant/components/adguard/translations/no.json @@ -17,8 +17,10 @@ "user": { "data": { "host": "Vert", + "password": "Passord", "port": "", "ssl": "AdGuard Hjem bruker et SSL-sertifikat", + "username": "Brukernavn", "verify_ssl": "AdGuard Home bruker et riktig sertifikat" }, "description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll." diff --git a/homeassistant/components/awair/translations/no.json b/homeassistant/components/awair/translations/no.json index afce9147d0b..dd69f2b255f 100644 --- a/homeassistant/components/awair/translations/no.json +++ b/homeassistant/components/awair/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reauth_successful": "Tilgangstoken oppdatert" + }, "error": { "unknown": "Ukjent Awair API-feil." }, diff --git a/homeassistant/components/enocean/translations/no.json b/homeassistant/components/enocean/translations/no.json index 5376e9ba8a8..ca8b24fa852 100644 --- a/homeassistant/components/enocean/translations/no.json +++ b/homeassistant/components/enocean/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "invalid_dongle_path": "Ugyldig donglesti" + }, "flow_title": "ENOcean oppsett", "step": { "detect": { diff --git a/homeassistant/components/garmin_connect/translations/no.json b/homeassistant/components/garmin_connect/translations/no.json index 28732d8c194..9058d46d02a 100644 --- a/homeassistant/components/garmin_connect/translations/no.json +++ b/homeassistant/components/garmin_connect/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne kontoen er allerede konfigurert." + }, "error": { "cannot_connect": "Kunne ikke koble til, pr\u00f8v igjen.", "invalid_auth": "Ugyldig godkjenning.", diff --git a/homeassistant/components/gogogate2/translations/no.json b/homeassistant/components/gogogate2/translations/no.json index 6619f4c8fe3..436ca38bf7b 100644 --- a/homeassistant/components/gogogate2/translations/no.json +++ b/homeassistant/components/gogogate2/translations/no.json @@ -7,7 +7,7 @@ "password": "Passord", "username": "Brukernavn" }, - "description": "Gi n\u00f8dvendig informasjon nedenfor. Merk: bare \"admin\" brukeren er kjent for \u00e5 fungere.", + "description": "Gi n\u00f8dvendig informasjon nedenfor.", "title": "Konfigurer GogoGate2" } } diff --git a/homeassistant/components/pi_hole/translations/no.json b/homeassistant/components/pi_hole/translations/no.json index f31e66cb1a4..e8bdbd2d18d 100644 --- a/homeassistant/components/pi_hole/translations/no.json +++ b/homeassistant/components/pi_hole/translations/no.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API-n\u00f8kkel (valgfritt)", + "api_key": "API-n\u00f8kkel", "host": "Vert", "location": "Beliggenhet", "name": "Navn", diff --git a/homeassistant/components/syncthru/translations/no.json b/homeassistant/components/syncthru/translations/no.json index f5d626c44ff..a17786167c6 100644 --- a/homeassistant/components/syncthru/translations/no.json +++ b/homeassistant/components/syncthru/translations/no.json @@ -9,7 +9,8 @@ "step": { "user": { "data": { - "name": "Navn" + "name": "Navn", + "url": "URL-adresse for webgrensesnitt" } } } diff --git a/homeassistant/components/volumio/translations/pt.json b/homeassistant/components/volumio/translations/pt.json new file mode 100644 index 00000000000..f681da4210f --- /dev/null +++ b/homeassistant/components/volumio/translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "Servidor", + "port": "Porta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/translations/pt.json b/homeassistant/components/withings/translations/pt.json index 0a1f02335cc..b80d6630c35 100644 --- a/homeassistant/components/withings/translations/pt.json +++ b/homeassistant/components/withings/translations/pt.json @@ -5,6 +5,9 @@ "data": { "profile": "Perfil" } + }, + "reauth": { + "title": "Re-autenticar Perfil" } } } diff --git a/homeassistant/components/wolflink/translations/sensor.ko.json b/homeassistant/components/wolflink/translations/sensor.ko.json new file mode 100644 index 00000000000..99e965e1b21 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ko.json @@ -0,0 +1,12 @@ +{ + "state": { + "wolflink__state": { + "1_x_warmwasser": "1 x DHW", + "abgasklappe": "\uc5f0\ud1b5 \uac00\uc2a4 \uc870\uc808\uae30", + "absenkbetrieb": "\uc911\ub2e8 \ub300\uccb4 \ubaa8\ub4dc", + "absenkstop": "\uc911\ub2e8 \ub300\uccb4 \uc911\uc9c0", + "aktiviert": "\ud65c\uc131\ud654", + "antilegionellenfunktion": "\ud56d \ub808\uc9c0\uc624\ub12c\ub77c\uade0 \uae30\ub2a5" + } + } +} \ No newline at end of file From fa9866db96ea2e63cf05d2c3c97e8bdc64b5f604 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Jul 2020 02:51:30 +0200 Subject: [PATCH 210/362] Add support for multiple time triggers in automations (#37975) * Add support for multiple time triggers in automations * Attach with single callback * Patch time in tests * Improve test coverage * Adjusting my facepalm moment --- homeassistant/components/automation/time.py | 29 ++++++-- tests/components/automation/test_time.py | 82 ++++++++++++++++++++- 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 5f461952960..f59ceff81ea 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -13,20 +13,37 @@ from homeassistant.helpers.event import async_track_time_change _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.Schema( - {vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): cv.time} + { + vol.Required(CONF_PLATFORM): "time", + vol.Required(CONF_AT): vol.All(cv.ensure_list, [cv.time]), + } ) async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" - at_time = config.get(CONF_AT) - hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second + at_times = config[CONF_AT] @callback def time_automation_listener(now): """Listen for time changes and calls action.""" hass.async_run_job(action, {"trigger": {"platform": "time", "now": now}}) - return async_track_time_change( - hass, time_automation_listener, hour=hours, minute=minutes, second=seconds - ) + removes = [ + async_track_time_change( + hass, + time_automation_listener, + hour=at_time.hour, + minute=at_time.minute, + second=at_time.second, + ) + for at_time in at_times + ] + + @callback + def remove_track_time_changes(): + """Remove tracked time changes.""" + for remove in removes: + remove() + + return remove_track_time_changes diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py index c93cdbc36e9..c8b95985636 100644 --- a/tests/components/automation/test_time.py +++ b/tests/components/automation/test_time.py @@ -4,10 +4,11 @@ from datetime import timedelta import pytest import homeassistant.components.automation as automation +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.async_mock import patch +from tests.async_mock import Mock, patch from tests.common import ( assert_setup_component, async_fire_time_changed, @@ -66,6 +67,53 @@ async def test_if_fires_using_at(hass, calls): assert calls[0].data["some"] == "time - 5" +async def test_if_fires_using_multiple_at(hass, calls): + """Test for firing at.""" + + now = dt_util.utcnow() + + time_that_will_not_match_right_away = now.replace( + year=now.year + 1, hour=4, minute=59, second=0 + ) + + with patch( + "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" + }, + }, + } + }, + ) + + now = dt_util.utcnow() + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0) + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "time - 5" + + async_fire_time_changed( + hass, now.replace(year=now.year + 1, hour=6, minute=0, second=0) + ) + + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "time - 6" + + async def test_if_not_fires_using_wrong_at(hass, calls): """YAML translates time values to total seconds. @@ -231,3 +279,35 @@ async def test_if_action_list_weekday(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 + + +async def test_untrack_time_change(hass): + """Test for removing tracked time changes.""" + mock_track_time_change = Mock() + with patch( + "homeassistant.components.automation.time.async_track_time_change", + return_value=mock_track_time_change, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": { + "platform": "time", + "at": ["5:00:00", "6:00:00", "7:00:00"], + }, + "action": {"service": "test.automation", "data": {"test": "test"}}, + } + }, + ) + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "automation.test"}, + blocking=True, + ) + + assert len(mock_track_time_change.mock_calls) == 3 From 8ab1b41974910e9a128fa9d3528448a746487fbd Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Wed, 29 Jul 2020 22:01:59 -0300 Subject: [PATCH 211/362] Add support for dimmable bond lights (#38203) * Add support for dimmable lights * Fix formatting * Add supported features test on Bond Light * Add more tests to bond light and fixes comments * Fix rebase conflict resolution * Apply suggestions from code review Co-authored-by: Chris Talkington --- homeassistant/components/bond/light.py | 29 +++++++- homeassistant/components/bond/manifest.json | 8 +-- homeassistant/components/bond/utils.py | 5 ++ tests/components/bond/test_light.py | 73 ++++++++++++++++++++- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 77afb188111..574c50dc3e3 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -47,20 +47,45 @@ class BondLight(BondEntity, LightEntity): def __init__(self, hub: BondHub, device: BondDevice): """Create HA entity representing Bond fan.""" super().__init__(hub, device) - + self._brightness: Optional[int] = None self._light: Optional[int] = None def _apply_state(self, state: dict): self._light = state.get("light") + self._brightness = state.get("brightness") + + @property + def supported_features(self) -> Optional[int]: + """Flag supported features.""" + features = 0 + if self._device.supports_set_brightness(): + features |= SUPPORT_BRIGHTNESS + + return features @property def is_on(self) -> bool: """Return if light is currently on.""" return self._light == 1 + @property + def brightness(self) -> int: + """Return the brightness of this light between 1..255.""" + brightness_value = ( + round(self._brightness * 255 / 100) if self._brightness else None + ) + return brightness_value + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - await self._hub.bond.action(self._device.device_id, Action.turn_light_on()) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness: + await self._hub.bond.action( + self._device.device_id, + Action(Action.SET_BRIGHTNESS, round((brightness * 100) / 255)), + ) + else: + await self._hub.bond.action(self._device.device_id, Action.turn_light_on()) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 9d5a9975503..3b3be1fb461 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,10 +3,6 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": [ - "bond-api==0.1.7" - ], - "codeowners": [ - "@prystupa" - ] + "requirements": ["bond-api==0.1.7"], + "codeowners": ["@prystupa"] } diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 416d5c8eb32..b45ca9bd251 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -60,6 +60,11 @@ class BondDevice: ] ) + def supports_set_brightness(self) -> bool: + """Return True if this device supports setting a light brightness.""" + actions: List[str] = self._attrs["actions"] + return bool([action for action in actions if action in [Action.SET_BRIGHTNESS]]) + class BondHub: """Hub device representing Bond Bridge.""" diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 555da5e707f..e1167eac107 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -5,10 +5,15 @@ import logging from bond_api import Action, DeviceType from homeassistant import core -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SUPPORT_BRIGHTNESS, +) from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) @@ -36,6 +41,15 @@ def ceiling_fan(name: str): } +def dimmable_ceiling_fan(name: str): + """Create a ceiling fan (that has built-in light) with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": [Action.TURN_LIGHT_ON, Action.TURN_LIGHT_OFF, Action.SET_BRIGHTNESS], + } + + def fireplace(name: str): """Create a fireplace with given name.""" return {"name": name, "type": DeviceType.FIREPLACE} @@ -128,6 +142,52 @@ async def test_turn_off_light(hass: core.HomeAssistant): ) +async def test_brightness_support(hass: core.HomeAssistant): + """Tests that a dimmable light should support the brightness feature.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + state = hass.states.get("light.name_1") + assert state.attributes[ATTR_SUPPORTED_FEATURES] & SUPPORT_BRIGHTNESS + + +async def test_brightness_not_supported(hass: core.HomeAssistant): + """Tests that a non-dimmable light should not support the brightness feature.""" + await setup_platform( + hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id", + ) + + state = hass.states.get("light.name_1") + assert not state.attributes[ATTR_SUPPORTED_FEATURES] & SUPPORT_BRIGHTNESS + + +async def test_turn_on_light_with_brightness(hass: core.HomeAssistant): + """Tests that turn on command, on a dimmable light, delegates to API and parses brightness.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_set_brightness, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1", ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_set_brightness.assert_called_once_with( + "test-device-id", Action(Action.SET_BRIGHTNESS, 50) + ) + + async def test_update_reports_light_is_on(hass: core.HomeAssistant): """Tests that update command sets correct state when Bond API reports the light is on.""" await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) @@ -220,3 +280,14 @@ async def test_light_available(hass: core.HomeAssistant): await help_test_entity_available( hass, LIGHT_DOMAIN, ceiling_fan("name-1"), "light.name_1" ) + + +async def test_parse_brightness(hass: core.HomeAssistant): + """Tests that reported brightness level (0..100) converted to HA brightness (0...255).""" + await setup_platform(hass, LIGHT_DOMAIN, dimmable_ceiling_fan("name-1")) + + with patch_bond_device_state(return_value={"light": 1, "brightness": 50}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128 From 00a4bcff3dbe303e732f18d4c93b2de229de9e71 Mon Sep 17 00:00:00 2001 From: Sergiy Maysak Date: Thu, 30 Jul 2020 12:45:04 +0300 Subject: [PATCH 212/362] Bump wirelesstagpy to 0.4.1 (#38387) --- homeassistant/components/wirelesstag/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json index d3059a49497..97205e6fc9d 100644 --- a/homeassistant/components/wirelesstag/manifest.json +++ b/homeassistant/components/wirelesstag/manifest.json @@ -2,6 +2,6 @@ "domain": "wirelesstag", "name": "Wireless Sensor Tags", "documentation": "https://www.home-assistant.io/integrations/wirelesstag", - "requirements": ["wirelesstagpy==0.4.0"], + "requirements": ["wirelesstagpy==0.4.1"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 3f623fd3867..0ef1979b287 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2213,7 +2213,7 @@ websocket-client==0.54.0 wiffi==1.0.0 # homeassistant.components.wirelesstag -wirelesstagpy==0.4.0 +wirelesstagpy==0.4.1 # homeassistant.components.withings withings-api==2.1.6 From a00aa6740e87d16999bbf19ac22acaa4d915de71 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Thu, 30 Jul 2020 08:44:26 -0400 Subject: [PATCH 213/362] Fix bond fans without defined max_speed (#38382) --- homeassistant/components/bond/fan.py | 2 +- tests/components/bond/common.py | 10 +++++++--- tests/components/bond/test_fan.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 6f6a98036c7..cb247b37309 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -75,7 +75,7 @@ class BondFan(BondEntity, FanEntity): return None # map 1..max_speed Bond speed to 1..3 HA speed - max_speed = self._device.props.get("max_speed", 3) + max_speed = max(self._device.props.get("max_speed", 3), self._speed) ha_speed = math.ceil(self._speed * (len(self.speed_list) - 1) / max_speed) return self.speed_list[ha_speed] diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 181fe3eaf07..bb3329b6a2d 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -49,9 +49,11 @@ async def setup_platform( hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any], + *, bond_device_id: str = "bond-device-id", - props: Dict[str, Any] = None, bond_version: Dict[str, Any] = None, + props: Dict[str, Any] = None, + state: Dict[str, Any] = None, ): """Set up the specified Bond platform.""" mock_entry = MockConfigEntry( @@ -65,9 +67,11 @@ async def setup_platform( return_value=[bond_device_id] ), patch_bond_device( return_value=discovered_device - ), patch_bond_device_state(), patch_bond_device_properties( + ), patch_bond_device_properties( return_value=props - ), patch_bond_device_state(): + ), patch_bond_device_state( + return_value=state + ): assert await async_setup_component(hass, BOND_DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 6a8a15fc4c0..91f0c21e77a 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -96,6 +96,20 @@ async def test_non_standard_speed_list(hass: core.HomeAssistant): ) +async def test_fan_speed_with_no_max_seed(hass: core.HomeAssistant): + """Tests that fans without max speed (increase/decrease controls) map speed to HA standard.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_device_id="test-device-id", + props={"no": "max_speed"}, + state={"power": 1, "speed": 14}, + ) + + assert hass.states.get("fan.name_1").attributes["speed"] == fan.SPEED_HIGH + + async def test_turn_on_fan_with_speed(hass: core.HomeAssistant): """Tests that turn on command delegates to set speed API.""" await setup_platform( From 76e8870e989adff65ec04260e2e143d7bcdd7b25 Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 30 Jul 2020 17:51:46 +0300 Subject: [PATCH 214/362] Clean up Volumio code (#38400) --- homeassistant/components/volumio/config_flow.py | 6 +++--- homeassistant/components/volumio/media_player.py | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 8b68a4d38de..950a161a5c3 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -71,9 +71,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: info = None + self._host = user_input[CONF_HOST] + self._port = user_input[CONF_PORT] try: - self._host = user_input[CONF_HOST] - self._port = user_input[CONF_PORT] info = await validate_input(self.hass, self._host, self._port) except CannotConnect: errors["base"] = "cannot_connect" @@ -83,7 +83,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if info is not None: self._name = info.get("name", self._host) - self._uuid = info.get("id", None) + self._uuid = info.get("id") if self._uuid is not None: await self._set_uid_and_abort() diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 0fadb5b51ed..d471f283ef1 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -63,16 +63,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): uid = config_entry.data[CONF_ID] name = config_entry.data[CONF_NAME] - entity = Volumio(hass, volumio, uid, name, info) + entity = Volumio(volumio, uid, name, info) async_add_entities([entity]) class Volumio(MediaPlayerEntity): """Volumio Player Object.""" - def __init__(self, hass, volumio, uid, name, info): + def __init__(self, volumio, uid, name, info): """Initialize the media player.""" - self._hass = hass self._volumio = volumio self._uid = uid self._name = name From c2a21fa4965df8de6970fefa43e231bdc9f70a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 30 Jul 2020 18:04:00 +0300 Subject: [PATCH 215/362] Update coordinator improvements (#38366) * Make generic * Add type info to bunch of uses * Recognize requests exceptions * Recognize urllib exceptions --- .../components/cert_expiry/__init__.py | 7 ++--- homeassistant/components/guardian/util.py | 2 +- homeassistant/components/ipp/__init__.py | 2 +- homeassistant/components/roku/__init__.py | 2 +- homeassistant/components/toon/coordinator.py | 4 +-- homeassistant/components/updater/__init__.py | 4 +-- homeassistant/components/upnp/sensor.py | 8 +++--- homeassistant/components/withings/common.py | 4 ++- homeassistant/components/wled/__init__.py | 2 +- homeassistant/helpers/update_coordinator.py | 26 ++++++++++++++----- tests/helpers/test_update_coordinator.py | 10 +++++-- 11 files changed, 46 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 38c73f8df2b..19fa4927d05 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,6 +1,7 @@ """The cert_expiry component.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import Optional from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -50,7 +51,7 @@ async def async_unload_entry(hass, entry): return await hass.config_entries.async_forward_entry_unload(entry, "sensor") -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator): +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime]): """Class to manage fetching Cert Expiry data from single endpoint.""" def __init__(self, hass, host, port): @@ -67,7 +68,7 @@ class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL, ) - async def _async_update_data(self): + async def _async_update_data(self) -> Optional[datetime]: """Fetch certificate.""" try: timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index e5fe565bbf4..bd83307afb7 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -14,7 +14,7 @@ from .const import LOGGER DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) -class GuardianDataUpdateCoordinator(DataUpdateCoordinator): +class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): """Define an extended DataUpdateCoordinator with some Guardian goodies.""" def __init__( diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py index 1258e1031b4..0e2b559d5e4 100644 --- a/homeassistant/components/ipp/__init__.py +++ b/homeassistant/components/ipp/__init__.py @@ -86,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class IPPDataUpdateCoordinator(DataUpdateCoordinator): +class IPPDataUpdateCoordinator(DataUpdateCoordinator[IPPPrinter]): """Class to manage fetching IPP data from single endpoint.""" def __init__( diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index 1a46fd9471c..2627d68e3c3 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -108,7 +108,7 @@ def roku_exception_handler(func): return handler -class RokuDataUpdateCoordinator(DataUpdateCoordinator): +class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Class to manage fetching Roku data.""" def __init__( diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 8e9722316e2..b7ad30f3aaf 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -21,8 +21,8 @@ from .const import CONF_CLOUDHOOK_URL, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) -class ToonDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching WLED data from single endpoint.""" +class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): + """Class to manage fetching Toon data from single endpoint.""" def __init__( self, hass: HomeAssistant, *, entry: ConfigEntry, session: OAuth2Session diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 0b53850733f..59f858f7cf4 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -64,7 +64,7 @@ async def async_setup(hass, config): include_components = conf.get(CONF_COMPONENT_REPORTING) - async def check_new_version(): + async def check_new_version() -> Updater: """Check if a new version is available and report if one is.""" newest, release_notes = await get_newest_version( hass, huuid, include_components @@ -98,7 +98,7 @@ async def async_setup(hass, config): return Updater(update_available, newest, release_notes) - coordinator = hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( + coordinator = hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator[Updater]( hass, _LOGGER, name="Home Assistant update", diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index aea0ec40460..f4d36da0b4d 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,6 +1,6 @@ """Support for UPnP/IGD Sensors.""" from datetime import timedelta -from typing import Mapping +from typing import Any, Mapping from homeassistant.config_entries import ConfigEntry from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND @@ -94,7 +94,7 @@ async def async_setup_entry( update_interval = timedelta(seconds=update_interval_sec) _LOGGER.debug("update_interval: %s", update_interval) _LOGGER.debug("Adding sensors") - coordinator = DataUpdateCoordinator( + coordinator = DataUpdateCoordinator[Mapping[str, Any]]( hass, _LOGGER, name=device.name, @@ -122,7 +122,7 @@ class UpnpSensor(Entity): def __init__( self, - coordinator: DataUpdateCoordinator, + coordinator: DataUpdateCoordinator[Mapping[str, Any]], device: Device, sensor_type: Mapping[str, str], update_multiplier: int = 2, @@ -169,7 +169,7 @@ class UpnpSensor(Entity): return self._sensor_type["unit"] @property - def device_info(self) -> Mapping[str, any]: + def device_info(self) -> Mapping[str, Any]: """Get device info.""" return { "connections": {(dr.CONNECTION_UPNP, self._device.udn)}, diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 0e2ff7c164f..89bc56dc77c 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -582,7 +582,9 @@ class DataManager: update_interval=timedelta(minutes=120), update_method=self.async_subscribe_webhook, ) - self.poll_data_update_coordinator = DataUpdateCoordinator( + self.poll_data_update_coordinator = DataUpdateCoordinator[ + Dict[MeasureType, Any] + ]( hass, _LOGGER, name="poll_data_update_coordinator", diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index 70d14895fbc..5cc2453d78c 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -107,7 +107,7 @@ def wled_exception_handler(func): return handler -class WLEDDataUpdateCoordinator(DataUpdateCoordinator): +class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" def __init__( diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index b7a36379107..7b7e6af4d62 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -3,9 +3,11 @@ import asyncio from datetime import datetime, timedelta import logging from time import monotonic -from typing import Any, Awaitable, Callable, List, Optional +from typing import Awaitable, Callable, Generic, List, Optional, TypeVar +import urllib.error import aiohttp +import requests from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event @@ -16,12 +18,14 @@ from .debounce import Debouncer REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 REQUEST_REFRESH_DEFAULT_IMMEDIATE = True +T = TypeVar("T") + class UpdateFailed(Exception): """Raised when an update has failed.""" -class DataUpdateCoordinator: +class DataUpdateCoordinator(Generic[T]): """Class to manage fetching data from single endpoint.""" def __init__( @@ -31,7 +35,7 @@ class DataUpdateCoordinator: *, name: str, update_interval: Optional[timedelta] = None, - update_method: Optional[Callable[[], Awaitable]] = None, + update_method: Optional[Callable[[], Awaitable[T]]] = None, request_refresh_debouncer: Optional[Debouncer] = None, ): """Initialize global data updater.""" @@ -41,7 +45,7 @@ class DataUpdateCoordinator: self.update_method = update_method self.update_interval = update_interval - self.data: Optional[Any] = None + self.data: Optional[T] = None self._listeners: List[CALLBACK_TYPE] = [] self._unsub_refresh: Optional[CALLBACK_TYPE] = None @@ -120,7 +124,7 @@ class DataUpdateCoordinator: """ await self._debounced_refresh.async_call() - async def _async_update_data(self) -> Optional[Any]: + async def _async_update_data(self) -> Optional[T]: """Fetch the latest data from the source.""" if self.update_method is None: raise NotImplementedError("Update method not implemented") @@ -138,16 +142,24 @@ class DataUpdateCoordinator: start = monotonic() self.data = await self._async_update_data() - except asyncio.TimeoutError: + except (asyncio.TimeoutError, requests.exceptions.Timeout): if self.last_update_success: self.logger.error("Timeout fetching %s data", self.name) self.last_update_success = False - except aiohttp.ClientError as err: + except (aiohttp.ClientError, requests.exceptions.RequestException) as err: if self.last_update_success: self.logger.error("Error requesting %s data: %s", self.name, err) self.last_update_success = False + except urllib.error.URLError as err: + if self.last_update_success: + if err.reason == "timed out": + self.logger.error("Timeout fetching %s data", self.name) + else: + self.logger.error("Error requesting %s data: %s", self.name, err) + self.last_update_success = False + except UpdateFailed as err: if self.last_update_success: self.logger.error("Error fetching %s data: %s", self.name, err) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 99399fee30f..56c53f1994c 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -2,9 +2,11 @@ import asyncio from datetime import timedelta import logging +import urllib.error import aiohttp import pytest +import requests from homeassistant.helpers import update_coordinator from homeassistant.util.dt import utcnow @@ -19,12 +21,12 @@ def get_crd(hass, update_interval): """Make coordinator mocks.""" calls = 0 - async def refresh(): + async def refresh() -> int: nonlocal calls calls += 1 return calls - crd = update_coordinator.DataUpdateCoordinator( + crd = update_coordinator.DataUpdateCoordinator[int]( hass, LOGGER, name="test", @@ -111,7 +113,11 @@ async def test_request_refresh_no_auto_update(crd_without_update_interval): "err_msg", [ (asyncio.TimeoutError, "Timeout fetching test data"), + (requests.exceptions.Timeout, "Timeout fetching test data"), + (urllib.error.URLError("timed out"), "Timeout fetching test data"), (aiohttp.ClientError, "Error requesting test data"), + (requests.exceptions.RequestException, "Error requesting test data"), + (urllib.error.URLError("something"), "Error requesting test data"), (update_coordinator.UpdateFailed, "Error fetching test data"), ], ) From ad0560ef378954efdd4ec059530888a6e7a34a57 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 Jul 2020 20:41:18 +0200 Subject: [PATCH 216/362] Improve tests for Airly integration (#38357) * Add tests * More tests * Add PARALLEL_UPDATES * Change Quality scale to platinum * Change PARALLEL_UPDATES value --- .coveragerc | 4 - homeassistant/components/airly/air_quality.py | 2 + homeassistant/components/airly/manifest.json | 3 +- homeassistant/components/airly/sensor.py | 2 + tests/components/airly/__init__.py | 31 ++++ tests/components/airly/test_air_quality.py | 113 ++++++++++++++ tests/components/airly/test_init.py | 140 ++++++++++++++++++ tests/components/airly/test_sensor.py | 128 ++++++++++++++++ 8 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 tests/components/airly/test_air_quality.py create mode 100644 tests/components/airly/test_init.py create mode 100644 tests/components/airly/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 90ce03476a8..c49ac6257d8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -31,10 +31,6 @@ omit = homeassistant/components/agent_dvr/camera.py homeassistant/components/agent_dvr/const.py homeassistant/components/agent_dvr/helpers.py - homeassistant/components/airly/__init__.py - homeassistant/components/airly/air_quality.py - homeassistant/components/airly/sensor.py - homeassistant/components/airly/const.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/air_quality.py homeassistant/components/airvisual/sensor.py diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py index deeff9af00f..6e1e90051e0 100644 --- a/homeassistant/components/airly/air_quality.py +++ b/homeassistant/components/airly/air_quality.py @@ -31,6 +31,8 @@ LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit" LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit" +PARALLEL_UPDATES = 1 + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Airly air_quality entity based on a config entry.""" diff --git a/homeassistant/components/airly/manifest.json b/homeassistant/components/airly/manifest.json index e86a187793f..8140bc91c5f 100644 --- a/homeassistant/components/airly/manifest.json +++ b/homeassistant/components/airly/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/airly", "codeowners": ["@bieniu"], "requirements": ["airly==0.0.2"], - "config_flow": true + "config_flow": true, + "quality_scale": "platinum" } diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index a0c5975188b..4f8ba0f11c7 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -27,6 +27,8 @@ ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_UNIT = "unit" +PARALLEL_UPDATES = 1 + SENSOR_TYPES = { ATTR_API_PM1: { ATTR_DEVICE_CLASS: None, diff --git a/tests/components/airly/__init__.py b/tests/components/airly/__init__.py index f31dfb7712d..29828bddc17 100644 --- a/tests/components/airly/__init__.py +++ b/tests/components/airly/__init__.py @@ -1 +1,32 @@ """Tests for Airly.""" +import json + +from homeassistant.components.airly.const import DOMAIN + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture + + +async def init_integration(hass, forecast=False) -> MockConfigEntry: + """Set up the Airly integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="55.55-122.12", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/airly/test_air_quality.py b/tests/components/airly/test_air_quality.py new file mode 100644 index 00000000000..fca2761f2f3 --- /dev/null +++ b/tests/components/airly/test_air_quality.py @@ -0,0 +1,113 @@ +"""Test air_quality of Airly integration.""" +from datetime import timedelta +import json + +from airly.exceptions import AirlyError + +from homeassistant.components.air_quality import ATTR_AQI, ATTR_PM_2_5, ATTR_PM_10 +from homeassistant.components.airly.air_quality import ( + ATTRIBUTION, + LABEL_ADVICE, + LABEL_AQI_DESCRIPTION, + LABEL_AQI_LEVEL, + LABEL_PM_2_5_LIMIT, + LABEL_PM_2_5_PERCENT, + LABEL_PM_10_LIMIT, + LABEL_PM_10_PERCENT, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + STATE_UNAVAILABLE, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed, load_fixture +from tests.components.airly import init_integration + + +async def test_air_quality(hass): + """Test states of the air_quality.""" + await init_integration(hass) + registry = await hass.helpers.entity_registry.async_get_registry() + + state = hass.states.get("air_quality.home") + assert state + assert state.state == "14" + assert state.attributes.get(ATTR_AQI) == 23 + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(LABEL_ADVICE) == "Great air!" + assert state.attributes.get(ATTR_PM_10) == 19 + assert state.attributes.get(ATTR_PM_2_5) == 14 + assert state.attributes.get(LABEL_AQI_DESCRIPTION) == "Great air here today!" + assert state.attributes.get(LABEL_AQI_LEVEL) == "very low" + assert state.attributes.get(LABEL_PM_2_5_LIMIT) == 25.0 + assert state.attributes.get(LABEL_PM_2_5_PERCENT) == 55 + assert state.attributes.get(LABEL_PM_10_LIMIT) == 50.0 + assert state.attributes.get(LABEL_PM_10_PERCENT) == 37 + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + + entry = registry.async_get("air_quality.home") + assert entry + assert entry.unique_id == "55.55-122.12" + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when service causes an error.""" + await init_integration(hass) + + state = hass.states.get("air_quality.home") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14" + + future = utcnow() + timedelta(minutes=60) + with patch( + "airly._private._RequestsHandler.get", + side_effect=AirlyError(500, "Unexpected error"), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.home") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=120) + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("air_quality.home") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "14" + + +async def test_manual_update_entity(hass): + """Test manual update entity via service homeasasistant/update_entity.""" + await init_integration(hass) + + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.components.airly.AirlyDataUpdateCoordinator._async_update_data" + ) as mock_update: + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["air_quality.home"]}, + blocking=True, + ) + assert mock_update.call_count == 1 diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py new file mode 100644 index 00000000000..28f2aca4fbb --- /dev/null +++ b/tests/components/airly/test_init.py @@ -0,0 +1,140 @@ +"""Test init of Airly integration.""" +from datetime import timedelta +import json + +from homeassistant.components.airly.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import STATE_UNAVAILABLE + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture +from tests.components.airly import init_integration + + +async def test_async_setup_entry(hass): + """Test a successful setup entry.""" + await init_integration(hass) + + state = hass.states.get("air_quality.home") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "14" + + +async def test_config_not_ready(hass): + """Test for setup failure if connection to Airly is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="55.55-122.12", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch("airly._private._RequestsHandler.get", side_effect=ConnectionError()): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_config_without_unique_id(hass): + """Test for setup entry without unique_id.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_LOADED + assert entry.unique_id == "55.55-122.12" + + +async def test_config_with_turned_off_station(hass): + """Test for setup entry for a turned off measuring station.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="55.55-122.12", + data={ + "api_key": "foo", + "latitude": 55.55, + "longitude": 122.12, + "name": "Home", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_no_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_update_interval(hass): + """Test correct update interval when the number of configured instances changes.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + for instance in hass.data[DOMAIN].values(): + assert instance.update_interval == timedelta(minutes=15) + + entry = MockConfigEntry( + domain=DOMAIN, + title="Work", + unique_id="66.66-111.11", + data={ + "api_key": "foo", + "latitude": 66.66, + "longitude": 111.11, + "name": "Work", + }, + ) + + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + assert entry.state == ENTRY_STATE_LOADED + for instance in hass.data[DOMAIN].values(): + assert instance.update_interval == timedelta(minutes=30) + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py new file mode 100644 index 00000000000..3131789c6e0 --- /dev/null +++ b/tests/components/airly/test_sensor.py @@ -0,0 +1,128 @@ +"""Test sensor of Airly integration.""" +from datetime import timedelta +import json + +from homeassistant.components.airly.sensor import ATTRIBUTION +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PRESSURE_HPA, + STATE_UNAVAILABLE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed, load_fixture +from tests.components.airly import init_integration + + +async def test_sensor(hass): + """Test states of the sensor.""" + await init_integration(hass) + registry = await hass.helpers.entity_registry.async_get_registry() + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state == "92.8" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY + + entry = registry.async_get("sensor.home_humidity") + assert entry + assert entry.unique_id == "55.55-122.12-humidity" + + state = hass.states.get("sensor.home_pm1") + assert state + assert state.state == "9" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + + entry = registry.async_get("sensor.home_pm1") + assert entry + assert entry.unique_id == "55.55-122.12-pm1" + + state = hass.states.get("sensor.home_pressure") + assert state + assert state.state == "1001" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PRESSURE_HPA + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_PRESSURE + + entry = registry.async_get("sensor.home_pressure") + assert entry + assert entry.unique_id == "55.55-122.12-pressure" + + state = hass.states.get("sensor.home_temperature") + assert state + assert state.state == "14.2" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE + + entry = registry.async_get("sensor.home_temperature") + assert entry + assert entry.unique_id == "55.55-122.12-temperature" + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when service is offline.""" + await init_integration(hass) + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "92.8" + + future = utcnow() + timedelta(minutes=60) + with patch("airly._private._RequestsHandler.get", side_effect=ConnectionError()): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=120) + with patch( + "airly._private._RequestsHandler.get", + return_value=json.loads(load_fixture("airly_valid_station.json")), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_humidity") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "92.8" + + +async def test_manual_update_entity(hass): + """Test manual update entity via service homeasasistant/update_entity.""" + await init_integration(hass) + + await async_setup_component(hass, "homeassistant", {}) + with patch( + "homeassistant.components.airly.AirlyDataUpdateCoordinator._async_update_data" + ) as mock_update: + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.home_humidity"]}, + blocking=True, + ) + assert mock_update.call_count == 1 From ecf22198c517beb4b73d525c298aa8560d5d44a9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Jul 2020 21:37:34 +0200 Subject: [PATCH 217/362] Ensure Toon webhook ID isn't registered on re-registration (#38376) --- homeassistant/components/toon/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index b7ad30f3aaf..fa4cf52a630 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -71,6 +71,9 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): self.entry.data[CONF_WEBHOOK_ID] ) + # Ensure the webhook is not registered already + webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) + webhook_register( self.hass, DOMAIN, From 0493f1c20603010a0c8e78e54d97f44bacfe6001 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Thu, 30 Jul 2020 19:00:58 -0400 Subject: [PATCH 218/362] Generate bond config entry ID from the hub metadata (#38354) * Generate bond config entry ID from the hub metadata * Generate bond config entry ID from the hub metadata (PR feedback) --- homeassistant/components/bond/__init__.py | 3 +++ homeassistant/components/bond/config_flow.py | 13 ++++++++----- tests/components/bond/test_config_flow.py | 16 +++++++++++----- tests/components/bond/test_init.py | 1 + 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index b9c1c90e1af..a0ed6545b91 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -41,6 +41,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = hub + if not entry.unique_id: + hass.config_entries.async_update_entry(entry, unique_id=hub.bond_id) + device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 215ae4af91d..6491e62c22b 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -17,11 +17,13 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(data): +async def validate_input(data) -> str: """Validate the user input allows us to connect.""" try: bond = Bond(data[CONF_HOST], data[CONF_ACCESS_TOKEN]) + version = await bond.version() + # call to non-version API is needed to validate authentication await bond.devices() except ClientConnectionError: raise CannotConnect @@ -30,8 +32,8 @@ async def validate_input(data): raise InvalidAuth raise - # Return info to be stored in the config entry. - return {"title": data[CONF_HOST]} + # Return unique ID from the hub to be stored in the config entry. + return version["bondid"] class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -45,7 +47,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - info = await validate_input(user_input) + bond_id = await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -54,7 +56,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - return self.async_create_entry(title=info["title"], data=user_input) + await self.async_set_unique_id(bond_id) + return self.async_create_entry(title=bond_id, data=user_input) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index bc6609d54ec..0620af8133b 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -6,7 +6,7 @@ from homeassistant import config_entries, core, setup from homeassistant.components.bond.const import DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST -from .common import patch_bond_device_ids +from .common import patch_bond_device_ids, patch_bond_version from tests.async_mock import Mock, patch @@ -20,7 +20,9 @@ async def test_form(hass: core.HomeAssistant): assert result["type"] == "form" assert result["errors"] == {} - with patch_bond_device_ids(), patch( + with patch_bond_version( + return_value={"bondid": "test-bond-id"} + ), patch_bond_device_ids(), patch( "homeassistant.components.bond.async_setup", return_value=True ) as mock_setup, patch( "homeassistant.components.bond.async_setup_entry", return_value=True, @@ -31,7 +33,7 @@ async def test_form(hass: core.HomeAssistant): ) assert result2["type"] == "create_entry" - assert result2["title"] == "some host" + assert result2["title"] == "test-bond-id" assert result2["data"] == { CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token", @@ -47,7 +49,9 @@ async def test_form_invalid_auth(hass: core.HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch_bond_device_ids( + with patch_bond_version( + return_value={"bond_id": "test-bond-id"} + ), patch_bond_device_ids( side_effect=ClientResponseError(Mock(), Mock(), status=401), ): result2 = await hass.config_entries.flow.async_configure( @@ -81,7 +85,9 @@ async def test_form_unexpected_error(hass: core.HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch_bond_device_ids( + with patch_bond_version( + return_value={"bond_id": "test-bond-id"} + ), patch_bond_device_ids( side_effect=ClientResponseError(Mock(), Mock(), status=500) ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 78e60f93d53..7a0f057f17b 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -64,6 +64,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state == ENTRY_STATE_LOADED + assert config_entry.unique_id == "test-bond-id" # verify hub device is registered correctly device_registry = await dr.async_get_registry(hass) From 06ddb2c95ef2294748dc5173fbce5fd5cfaa8f65 Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 30 Jul 2020 16:19:43 -0700 Subject: [PATCH 219/362] Bump pywemo to 0.4.45 (#38414) --- homeassistant/components/wemo/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index e08e82b3269..7bb3371c153 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.4.43"], + "requirements": ["pywemo==0.4.45"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/requirements_all.txt b/requirements_all.txt index 0ef1979b287..6bc23c3577a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1834,7 +1834,7 @@ pyvolumio==0.1 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.4.43 +pywemo==0.4.45 # homeassistant.components.xeoma pyxeoma==1.4.1 From 695585b68c172174746803c3126b5db63f13c631 Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Fri, 31 Jul 2020 07:38:35 +0800 Subject: [PATCH 220/362] Add battery sensor to xiaomi_aqara (#38004) --- .../components/xiaomi_aqara/const.py | 40 ++++++++++++++++ .../components/xiaomi_aqara/sensor.py | 47 ++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py index ab214cb13cc..58932d7a0bc 100644 --- a/homeassistant/components/xiaomi_aqara/const.py +++ b/homeassistant/components/xiaomi_aqara/const.py @@ -13,3 +13,43 @@ CONF_KEY = "key" CONF_SID = "sid" DEFAULT_DISCOVERY_RETRY = 5 + +BATTERY_MODELS = [ + "sensor_ht", + "weather", + "weather.v1", + "sensor_motion.aq2", + "vibration", + "magnet", + "sensor_magnet", + "sensor_magnet.aq2", + "motion", + "sensor_motion", + "sensor_motion.aq2", + "switch", + "sensor_switch", + "sensor_switch.aq2", + "sensor_switch.aq3", + "remote.b1acn01", + "86sw1", + "sensor_86sw1", + "sensor_86sw1.aq1", + "remote.b186acn01", + "86sw2", + "sensor_86sw2", + "sensor_86sw2.aq1", + "remote.b286acn01", + "cube", + "sensor_cube", + "sensor_cube.aqgl01", + "smoke", + "sensor_smoke", + "sensor_wleak.aq1", + "vibration", + "vibration.aq1", + "curtain", + "curtain.aq2", + "curtain.hagl04", + "lock.aq1", + "lock.acn02", +] diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index fe1eb5a80fe..3463cded56d 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -2,6 +2,8 @@ import logging from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_PRESSURE, @@ -11,7 +13,7 @@ from homeassistant.const import ( ) from . import XiaomiDevice -from .const import DOMAIN, GATEWAYS_KEY +from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY _LOGGER = logging.getLogger(__name__) @@ -79,6 +81,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) else: _LOGGER.warning("Unmapped Device Model") + + # Set up battery sensors + for devices in gateway.devices.values(): + for device in devices: + if device["model"] in BATTERY_MODELS: + entities.append( + XiaomiBatterySensor(device, "Battery", gateway, config_entry) + ) + async_add_entities(entities) @@ -144,3 +155,37 @@ class XiaomiSensor(XiaomiDevice): else: self._state = round(value, 1) return True + + +class XiaomiBatterySensor(XiaomiDevice): + """Representation of a XiaomiSensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return UNIT_PERCENTAGE + + @property + def device_class(self): + """Return the device class of this entity.""" + return DEVICE_CLASS_BATTERY + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def parse_data(self, data, raw_data): + """Parse data sent by gateway.""" + succeed = super().parse_voltage(data) + if not succeed: + return False + battery_level = int(self._device_state_attributes.pop(ATTR_BATTERY_LEVEL)) + if battery_level <= 0 or battery_level > 100: + return False + self._state = battery_level + return True + + def parse_voltage(self, data): + """Parse battery level data sent by gateway.""" + return False # Override parse_voltage to do nothing From 4bd9509fa78531df35fbfbad481d3d492615cc3f Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Fri, 31 Jul 2020 00:02:40 +0000 Subject: [PATCH 221/362] [ci skip] Translation update --- .../accuweather/translations/lb.json | 35 +++++++++++++++++++ .../azure_devops/translations/lb.json | 33 +++++++++++++++++ .../components/bond/translations/lb.json | 4 ++- .../components/control4/translations/lb.json | 3 +- .../components/enocean/translations/lb.json | 23 ++++++++++++ .../components/netatmo/translations/lb.json | 3 +- .../components/smarthab/translations/lb.json | 2 ++ .../components/syncthru/translations/lb.json | 13 ++++++- .../components/volumio/translations/lb.json | 24 +++++++++++++ .../wolflink/translations/sensor.lb.json | 3 ++ .../components/zerproc/translations/lb.json | 8 ++++- 11 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/lb.json create mode 100644 homeassistant/components/azure_devops/translations/lb.json create mode 100644 homeassistant/components/volumio/translations/lb.json diff --git a/homeassistant/components/accuweather/translations/lb.json b/homeassistant/components/accuweather/translations/lb.json new file mode 100644 index 00000000000..b83cf69c8d7 --- /dev/null +++ b/homeassistant/components/accuweather/translations/lb.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun ass m\u00e9iglech." + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel", + "requests_exceeded": "D\u00e9i zougelooss Zuel vun Ufroen un Accuweather API gouf iwwerschratt. Du muss ofwaarden oder den API Schl\u00ebssel \u00e4nneren." + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad", + "name": "Numm vun der Integratioun" + }, + "description": "Falls du H\u00ebllef mat der Konfiguratioun brauch kuck h\u00e9i:\nhttps://www.home-assistant.io/integrations/accuweather/\n\nWieder Pr\u00e9visounen si standardm\u00e9isseg net aktiv. Du kanns d\u00e9i an den Optioune vun der Integratioun aschalten.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "Wieder Pr\u00e9visioun" + }, + "description": "Duerch d'Limite vun der Gratis Versioun vun der AccuWeather API, wann d'Wieder Pr\u00e9visoune aktiv\u00e9iert sinn, ginn d'Aktualis\u00e9ierungen all 64 Minutten gemaach, am plaatz vun all 32 Minutten.", + "title": "AccuWeather Optiounen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/lb.json b/homeassistant/components/azure_devops/translations/lb.json new file mode 100644 index 00000000000..ae6df660cff --- /dev/null +++ b/homeassistant/components/azure_devops/translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Kont ass scho konfigur\u00e9iert", + "reauth_successful": "Acc\u00e8s Jeton erfollegr\u00e4ich aktualis\u00e9iert" + }, + "error": { + "authorization_error": "Feeler bei der Authorisatioun. Iwwerpr\u00e9if ob d\u00e4in Kont den acc\u00e8s zum Projet souw\u00e9i d\u00e9i richteg Umeldungsinformatioune huet", + "connection_error": "Konnt sech net mat Azure DevOps verbannen", + "project_error": "Konnt keng Projet Informatiounen ausliesen." + }, + "flow_title": "Azure DevOps: {project_url}", + "step": { + "reauth": { + "data": { + "personal_access_token": "Pers\u00e9inlechen Acc\u00e8s Jeton (PAT)" + }, + "description": "Feeler bei der Authentifikatioun fir {project_url}. G\u00ebff deng aktuell Umeldungsinformatiounen an.", + "title": "Reauthentifikatioun" + }, + "user": { + "data": { + "organization": "Organisatioun", + "personal_access_token": "Pers\u00e9inlechen Acc\u00e8s Jeton (PAT)", + "project": "Projet" + }, + "description": "Riicht eng Azure DevOps Instanz an fir d\u00e4in Projet z'acc\u00e9d\u00e9ieren. E Pers\u00e9inlechen Acc\u00e8s Jetons ass n\u00ebmme fir ee Private Projet n\u00e9ideg.", + "title": "Azure DevOps Project dob\u00e4isetzen" + } + } + }, + "title": "Azure DevOps" +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/lb.json b/homeassistant/components/bond/translations/lb.json index c0e3c9c97a0..abce9d5efb9 100644 --- a/homeassistant/components/bond/translations/lb.json +++ b/homeassistant/components/bond/translations/lb.json @@ -1,13 +1,15 @@ { "config": { "error": { + "cannot_connect": "Feeler beim verbannen", "invalid_auth": "Ong\u00eblteg Authentifikatioun", "unknown": "Onerwaarte Feeler" }, "step": { "user": { "data": { - "access_token": "Acc\u00e8s jeton" + "access_token": "Acc\u00e8s jeton", + "host": "Host" } } } diff --git a/homeassistant/components/control4/translations/lb.json b/homeassistant/components/control4/translations/lb.json index e1209bca632..782a5ebe705 100644 --- a/homeassistant/components/control4/translations/lb.json +++ b/homeassistant/components/control4/translations/lb.json @@ -14,7 +14,8 @@ "host": "IP Adress", "password": "Passwuert", "username": "Benotzernumm" - } + }, + "description": "G\u00ebff deng Control4 Kont Informatiounen an d'IP Adress vun dengem lokale Kontroller an." } } }, diff --git a/homeassistant/components/enocean/translations/lb.json b/homeassistant/components/enocean/translations/lb.json index 1b32ae55b19..58d131203b4 100644 --- a/homeassistant/components/enocean/translations/lb.json +++ b/homeassistant/components/enocean/translations/lb.json @@ -1,3 +1,26 @@ { + "config": { + "abort": { + "invalid_dongle_path": "Ong\u00eblte Dongle Pad" + }, + "error": { + "invalid_dongle_path": "Kee g\u00ebltege Dongle an d\u00ebsem Pad fonnt" + }, + "flow_title": "ENOcean Konfiguratioun", + "step": { + "detect": { + "data": { + "path": "USB Dongle Pad" + }, + "title": "Wiel de Pad zu dengem ENOcean Dongle aus." + }, + "manual": { + "data": { + "path": "USB Dongle Pad" + }, + "title": "G\u00ebff de Pad zu dengem ENOcean Dongle an" + } + } + }, "title": "EnOcean" } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/lb.json b/homeassistant/components/netatmo/translations/lb.json index cb7701e9a0a..86212bb5b9f 100644 --- a/homeassistant/components/netatmo/translations/lb.json +++ b/homeassistant/components/netatmo/translations/lb.json @@ -26,7 +26,8 @@ }, "public_weather_areas": { "data": { - "new_area": "Numm vum Ber\u00e4ich" + "new_area": "Numm vum Ber\u00e4ich", + "weather_areas": "Wieder Ber\u00e4icher" } } } diff --git a/homeassistant/components/smarthab/translations/lb.json b/homeassistant/components/smarthab/translations/lb.json index 0378cd2300e..d2892805fac 100644 --- a/homeassistant/components/smarthab/translations/lb.json +++ b/homeassistant/components/smarthab/translations/lb.json @@ -1,6 +1,7 @@ { "config": { "error": { + "service": "Feeler beim verbanne mat SmartHab. De Service ass viellaicht net ereechbar. Iwwerpr\u00e9if deng Verbindung.", "unknown_error": "Onerwaarte Feeler", "wrong_login": "Ong\u00eblteg Authentifikatioun" }, @@ -10,6 +11,7 @@ "email": "E-Mail", "password": "Passwuert" }, + "description": "W\u00e9inst technesche Gr\u00ebnn soll een zweeten Kont benotz gin fir d\u00e4in Home Assistant. Du kanns een zous\u00e4tzleche Kont an der SmartHab Applikatioun erstellen.", "title": "SmartHab ariichten" } } diff --git a/homeassistant/components/syncthru/translations/lb.json b/homeassistant/components/syncthru/translations/lb.json index 4e5a8218bd7..b67031fdcf8 100644 --- a/homeassistant/components/syncthru/translations/lb.json +++ b/homeassistant/components/syncthru/translations/lb.json @@ -1,10 +1,21 @@ { "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, "error": { - "invalid_url": "Ong\u00eblteg URL" + "invalid_url": "Ong\u00eblteg URL", + "syncthru_not_supported": "Apparat \u00ebnnerst\u00ebtzt kee SyncThru", + "unknown_state": "Printer Status onbekannt, iwwerpr\u00e9if URL an Netzwierk konnektivit\u00e9it." }, "flow_title": "Samsung SyncThru Printer: {name}", "step": { + "confirm": { + "data": { + "name": "Numm", + "url": "Web interface URL" + } + }, "user": { "data": { "name": "Numm", diff --git a/homeassistant/components/volumio/translations/lb.json b/homeassistant/components/volumio/translations/lb.json new file mode 100644 index 00000000000..65a32a4a795 --- /dev/null +++ b/homeassistant/components/volumio/translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert", + "cannot_connect": "Kann sech net mam enteckte Volumio verbannen" + }, + "error": { + "cannot_connect": "Feeler beim verbannen", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "discovery_confirm": { + "description": "Soll de Volumio (`{name}`) am Home Assistant dob\u00e4i gesaat ginn?", + "title": "Entdeckte Volumio" + }, + "user": { + "data": { + "host": "Host", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.lb.json b/homeassistant/components/wolflink/translations/sensor.lb.json index 4a19955c56e..94c46e4f362 100644 --- a/homeassistant/components/wolflink/translations/sensor.lb.json +++ b/homeassistant/components/wolflink/translations/sensor.lb.json @@ -24,7 +24,10 @@ "initialisierung": "Initialis\u00e9ierung", "kalibration": "Kalibratioun", "kalibration_warmwasserbetrieb": "DHW Kalibratioun", + "parallelbetrieb": "Parrallel Modus", "partymodus": "Party Modus", + "permanent": "Permanent", + "permanentbetrieb": "Permanente Modus", "reduzierter_betrieb": "Limit\u00e9ierte Modus", "rt_abschaltung": "RT ausmaachen", "rt_frostschutz": "RT Frostschutz", diff --git a/homeassistant/components/zerproc/translations/lb.json b/homeassistant/components/zerproc/translations/lb.json index e1d2e73d527..d4a2cccba4c 100644 --- a/homeassistant/components/zerproc/translations/lb.json +++ b/homeassistant/components/zerproc/translations/lb.json @@ -1,7 +1,13 @@ { "config": { "abort": { - "no_devices_found": "Keng Apparater am Netzwierk fonnt" + "no_devices_found": "Keng Apparater am Netzwierk fonnt", + "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguraioun ass m\u00e9iglech." + }, + "step": { + "confirm": { + "description": "Soll d'Konfiguratioun gestart ginn?" + } } }, "title": "Zerproc" From 57883ec10aecd7df36499afc86bae18771e5b7a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2020 16:58:17 -1000 Subject: [PATCH 222/362] Fix variable error during stream close (#38417) --- homeassistant/components/stream/worker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 3df7f8fc151..965f2611ed4 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -174,5 +174,6 @@ def stream_worker(hass, stream, quit_event): buffer.output.mux(packet) # Close stream - buffer.output.close() + for buffer in outputs.values(): + buffer.output.close() container.close() From 79055487ed956aed51ca4a1a50eb54293157c388 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jul 2020 20:50:42 -1000 Subject: [PATCH 223/362] Simplify generate_entity_id (#38418) * Simplify generate_entity_id Use similar optimized logic for async_generate_entity_id from entity_registry that was already optimized * pylint * make generate_entity_id a wrapper around async_generate_entity_id instead --- homeassistant/helpers/entity.py | 36 ++++++++++++++------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7c19d540704..4fd54f4ee8a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -30,7 +30,6 @@ from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.helpers.event import Event, async_track_entity_registry_updated_event from homeassistant.util import dt as dt_util, ensure_unique_string, slugify -from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 @@ -43,21 +42,7 @@ def generate_entity_id( hass: Optional[HomeAssistant] = None, ) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" - if current_ids is None: - if hass is None: - raise ValueError("Missing required parameter currentids or hass") - return run_callback_threadsafe( - hass.loop, - async_generate_entity_id, - entity_id_format, - name, - current_ids, - hass, - ).result() - - name = (slugify(name or "") or slugify(DEVICE_DEFAULT_NAME)).lower() - - return ensure_unique_string(entity_id_format.format(name), current_ids) + return async_generate_entity_id(entity_id_format, name, current_ids, hass) @callback @@ -68,14 +53,23 @@ def async_generate_entity_id( hass: Optional[HomeAssistant] = None, ) -> str: """Generate a unique entity ID based on given entity IDs or used IDs.""" - if current_ids is None: - if hass is None: - raise ValueError("Missing required parameter currentids or hass") - current_ids = hass.states.async_entity_ids() name = (name or DEVICE_DEFAULT_NAME).lower() + preferred_string = entity_id_format.format(slugify(name)) - return ensure_unique_string(entity_id_format.format(slugify(name)), current_ids) + if current_ids is not None: + return ensure_unique_string(preferred_string, current_ids) + + if hass is None: + raise ValueError("Missing required parameter current_ids or hass") + + test_string = preferred_string + tries = 1 + while hass.states.get(test_string): + tries += 1 + test_string = f"{preferred_string}_{tries}" + + return test_string class Entity(ABC): From 0136c565ebbd7cd977c0760d00d17e94b4ad08a8 Mon Sep 17 00:00:00 2001 From: Stefan Lehmann Date: Fri, 31 Jul 2020 13:59:32 +0200 Subject: [PATCH 224/362] Fix ads integration after 0.113 (#38402) --- homeassistant/components/ads/__init__.py | 20 +++++++++++++------- homeassistant/components/ads/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 15d58eb4620..b17a066eba7 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -230,7 +230,13 @@ class AdsHub: hnotify = int(contents.hNotification) _LOGGER.debug("Received notification %d", hnotify) - data = contents.data + + # get dynamically sized data array + data_size = contents.cbSampleSize + data = (ctypes.c_ubyte * data_size).from_address( + ctypes.addressof(contents) + + pyads.structs.SAdsNotificationHeader.data.offset + ) try: with self._lock: @@ -241,17 +247,17 @@ class AdsHub: # Parse data to desired datatype if notification_item.plc_datatype == self.PLCTYPE_BOOL: - value = bool(struct.unpack(" Date: Fri, 31 Jul 2020 08:08:25 -0400 Subject: [PATCH 225/362] Abort bond hub config flow if hub is already registered (#38416) Co-authored-by: Chris Talkington --- homeassistant/components/bond/config_flow.py | 1 + homeassistant/components/bond/manifest.json | 3 +- homeassistant/components/bond/strings.json | 3 ++ .../components/bond/translations/en.json | 5 ++- tests/components/bond/test_config_flow.py | 39 ++++++++++++++++++- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6491e62c22b..c4fb7310b88 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -57,6 +57,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured() return self.async_create_entry(title=bond_id, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 3b3be1fb461..8144b29e7d9 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -4,5 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", "requirements": ["bond-api==0.1.7"], - "codeowners": ["@prystupa"] + "codeowners": ["@prystupa"], + "quality_scale": "platinum" } diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index a243c938f12..6577c99456c 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -12,6 +12,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } } diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index da96c12c92c..3e614b03676 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Device is already configured" + }, "error": { "cannot_connect": "Failed to connect", "invalid_auth": "Invalid authentication", @@ -14,4 +17,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 0620af8133b..fa20355f356 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from .common import patch_bond_device_ids, patch_bond_version from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry async def test_form(hass: core.HomeAssistant): @@ -69,7 +70,9 @@ async def test_form_cannot_connect(hass: core.HomeAssistant): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch_bond_device_ids(side_effect=ClientConnectionError()): + with patch_bond_version( + side_effect=ClientConnectionError() + ), patch_bond_device_ids(): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -97,3 +100,37 @@ async def test_form_unexpected_error(hass: core.HomeAssistant): assert result2["type"] == "form" assert result2["errors"] == {"base": "unknown"} + + +async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): + """Test that only one entry allowed per unique ID reported by Bond hub device.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ).add_to_hass(hass) + + await setup.async_setup_component(hass, "persistent_notification", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch_bond_version( + return_value={"bondid": "already-registered-bond-id"} + ), patch_bond_device_ids(), patch( + "homeassistant.components.bond.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.bond.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 From d02c432e4d34a4b185f8e03a91091f55ca21aaec Mon Sep 17 00:00:00 2001 From: cgtobi Date: Fri, 31 Jul 2020 14:38:49 +0200 Subject: [PATCH 226/362] Fix rmvtransport breaking when destinations don't match (#38401) --- .../components/rmvtransport/sensor.py | 34 ++++++++++++++++--- tests/components/rmvtransport/test_sensor.py | 8 ++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 704bde67a5c..76e75d77a58 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -100,7 +100,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= tasks = [sensor.async_update() for sensor in sensors] if tasks: await asyncio.wait(tasks) - if not all(sensor.data.departures for sensor in sensors): + + if not any(sensor.data for sensor in sensors): raise PlatformNotReady async_add_entities(sensors) @@ -165,6 +166,7 @@ class RMVDepartureSensor(Entity): "minutes": self.data.departures[0].get("minutes"), "departure_time": self.data.departures[0].get("departure_time"), "product": self.data.departures[0].get("product"), + ATTR_ATTRIBUTION: ATTRIBUTION, } except IndexError: return {} @@ -183,13 +185,16 @@ class RMVDepartureSensor(Entity): """Get the latest data and update the state.""" await self.data.async_update() + if self._name == DEFAULT_NAME: + self._name = self.data.station + + self._station = self.data.station + if not self.data.departures: self._state = None self._icon = ICONS[None] return - if self._name == DEFAULT_NAME: - self._name = self.data.station - self._station = self.data.station + self._state = self.data.departures[0].get("minutes") self._icon = ICONS[self.data.departures[0].get("product")] @@ -220,6 +225,7 @@ class RMVDepartureData: self._max_journeys = max_journeys self.rmv = RMVtransport(session, timeout) self.departures = [] + self._error_notification = False @Throttle(SCAN_INTERVAL) async def async_update(self): @@ -231,31 +237,49 @@ class RMVDepartureData: direction_id=self._direction, max_journeys=50, ) + except RMVtransportApiConnectionError: self.departures = [] _LOGGER.warning("Could not retrieve data from rmv.de") return + self.station = _data.get("station") + _deps = [] + _deps_not_found = set(self._destinations) + for journey in _data["journeys"]: # find the first departure meeting the criteria - _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION} + _nextdep = {} if self._destinations: dest_found = False for dest in self._destinations: if dest in journey["stops"]: dest_found = True + if dest in _deps_not_found: + _deps_not_found.remove(dest) _nextdep["destination"] = dest + if not dest_found: continue + elif self._lines and journey["number"] not in self._lines: continue + elif journey["minutes"] < self._time_offset: continue + for attr in ["direction", "departure_time", "product", "minutes"]: _nextdep[attr] = journey.get(attr, "") + _nextdep["line"] = journey.get("number", "") _deps.append(_nextdep) + if len(_deps) > self._max_journeys: break + + if not self._error_notification and _deps_not_found: + self._error_notification = True + _LOGGER.info("Destination(s) %s not found", ", ".join(_deps_not_found)) + self.departures = _deps diff --git a/tests/components/rmvtransport/test_sensor.py b/tests/components/rmvtransport/test_sensor.py index 419eaafc5eb..b576f385173 100644 --- a/tests/components/rmvtransport/test_sensor.py +++ b/tests/components/rmvtransport/test_sensor.py @@ -48,7 +48,7 @@ VALID_CONFIG_DEST = { def get_departures_mock(): """Mock rmvtransport departures loading.""" - data = { + return { "station": "Frankfurt (Main) Hauptbahnhof", "stationId": "3000010", "filter": "11111111111", @@ -145,18 +145,16 @@ def get_departures_mock(): }, ], } - return data def get_no_departures_mock(): """Mock no departures in results.""" - data = { + return { "station": "Frankfurt (Main) Hauptbahnhof", "stationId": "3000010", "filter": "11111111111", "journeys": [], } - return data async def test_rmvtransport_min_config(hass): @@ -232,4 +230,4 @@ async def test_rmvtransport_no_departures(hass): await hass.async_block_till_done() state = hass.states.get("sensor.frankfurt_main_hauptbahnhof") - assert not state + assert state.state == "unavailable" From c795c68bc0641189590dba444d3b00248f9ff995 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Fri, 31 Jul 2020 08:45:03 -0400 Subject: [PATCH 227/362] Support 'stop' action for covers in device automation (#38219) --- .../components/cover/device_action.py | 15 +++++++- homeassistant/components/cover/strings.json | 1 + .../components/cover/translations/en.json | 7 ++-- tests/components/cover/test_device_action.py | 35 +++++++++++++++++-- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index dba4ff8be89..29dd97909e3 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -16,6 +16,7 @@ from homeassistant.const import ( SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, ) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import entity_registry @@ -31,9 +32,10 @@ from . import ( SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, ) -CMD_ACTION_TYPES = {"open", "close", "open_tilt", "close_tilt"} +CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( @@ -99,6 +101,15 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: CONF_TYPE: "close", } ) + if supported_features & SUPPORT_STOP: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "stop", + } + ) if supported_features & SUPPORT_SET_TILT_POSITION: actions.append( @@ -160,6 +171,8 @@ async def async_call_action_from_config( service = SERVICE_OPEN_COVER elif config[CONF_TYPE] == "close": service = SERVICE_CLOSE_COVER + elif config[CONF_TYPE] == "stop": + service = SERVICE_STOP_COVER elif config[CONF_TYPE] == "open_tilt": service = SERVICE_OPEN_COVER_TILT elif config[CONF_TYPE] == "close_tilt": diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index de52614891f..cb98c542d43 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -4,6 +4,7 @@ "action_type": { "open": "Open {entity_name}", "close": "Close {entity_name}", + "stop": "Stop {entity_name}", "open_tilt": "Open {entity_name} tilt", "close_tilt": "Close {entity_name} tilt", "set_position": "Set {entity_name} position", diff --git a/homeassistant/components/cover/translations/en.json b/homeassistant/components/cover/translations/en.json index de2ad4e0b15..10551a82988 100644 --- a/homeassistant/components/cover/translations/en.json +++ b/homeassistant/components/cover/translations/en.json @@ -6,7 +6,8 @@ "open": "Open {entity_name}", "open_tilt": "Open {entity_name} tilt", "set_position": "Set {entity_name} position", - "set_tilt_position": "Set {entity_name} tilt position" + "set_tilt_position": "Set {entity_name} tilt position", + "stop": "Stop {entity_name}" }, "condition_type": { "is_closed": "{entity_name} is closed", @@ -27,9 +28,9 @@ }, "state": { "_": { - "closed": "Closed", + "closed": "[%key:common::state::closed%]", "closing": "Closing", - "open": "Open", + "open": "[%key:common::state::open%]", "opening": "Opening", "stopped": "Stopped" } diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index e70c18621f4..b38521d4051 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -60,6 +60,12 @@ async def test_get_actions(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": ent.entity_id, }, + { + "domain": DOMAIN, + "type": "stop", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, ] actions = await async_get_device_automations(hass, "action", device_entry.id) assert_lists_same(actions, expected_actions) @@ -95,6 +101,12 @@ async def test_get_actions_tilt(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": ent.entity_id, }, + { + "domain": DOMAIN, + "type": "stop", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, { "domain": DOMAIN, "type": "open_tilt", @@ -171,6 +183,12 @@ async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": ent.entity_id, }, + { + "domain": DOMAIN, + "type": "stop", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, { "domain": DOMAIN, "type": "set_tilt_position", @@ -201,7 +219,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 2 # open, close + assert len(actions) == 3 # open, close, stop for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -282,7 +300,7 @@ async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg ] } actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 3 # open, close, set_tilt_position + assert len(actions) == 4 # open, close, stop, set_tilt_position for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -322,27 +340,40 @@ async def test_action(hass): "type": "close", }, }, + { + "trigger": {"platform": "event", "event_type": "test_event_stop"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "stop", + }, + }, ] }, ) open_calls = async_mock_service(hass, "cover", "open_cover") close_calls = async_mock_service(hass, "cover", "close_cover") + stop_calls = async_mock_service(hass, "cover", "stop_cover") hass.bus.async_fire("test_event_open") await hass.async_block_till_done() assert len(open_calls) == 1 assert len(close_calls) == 0 + assert len(stop_calls) == 0 hass.bus.async_fire("test_event_close") await hass.async_block_till_done() assert len(open_calls) == 1 assert len(close_calls) == 1 + assert len(stop_calls) == 0 hass.bus.async_fire("test_event_stop") await hass.async_block_till_done() assert len(open_calls) == 1 assert len(close_calls) == 1 + assert len(stop_calls) == 1 async def test_action_tilt(hass): From 49cbc9735cf90273b29532323e36aa5a4502628f Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Fri, 31 Jul 2020 08:47:01 -0500 Subject: [PATCH 228/362] Add identifiers to device registry api output (#38427) --- homeassistant/components/config/device_registry.py | 1 + tests/components/config/test_device_registry.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 5b12ccb92eb..de1f38f3e57 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -73,6 +73,7 @@ def _entry_dict(entry): "sw_version": entry.sw_version, "entry_type": entry.entry_type, "id": entry.id, + "identifiers": list(entry.identifiers), "via_device_id": entry.via_device_id, "area_id": entry.area_id, "name_by_user": entry.name_by_user, diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index c2557c83a4a..1f82434c7a6 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -46,6 +46,7 @@ async def test_list_devices(hass, client, registry): { "config_entries": ["1234"], "connections": [["ethernet", "12:34:56:78:90:AB:CD:EF"]], + "identifiers": [["bridgeid", "0123"]], "manufacturer": "manufacturer", "model": "model", "name": None, @@ -58,6 +59,7 @@ async def test_list_devices(hass, client, registry): { "config_entries": ["1234"], "connections": [], + "identifiers": [["bridgeid", "1234"]], "manufacturer": "manufacturer", "model": "model", "name": None, From bb69aba05145a9db2737ebcc866d62e09c514b57 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 31 Jul 2020 10:40:23 -0500 Subject: [PATCH 229/362] Remove unused SmartThings capability subscriptions (#38128) --- homeassistant/components/smartthings/const.py | 8 ++++++ .../components/smartthings/smartapp.py | 21 ++++++++++++++++ tests/components/smartthings/test_smartapp.py | 25 ++++++++++++++++--- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 9d779bf9d5b..03188411f07 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -24,6 +24,8 @@ SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_" SETTINGS_INSTANCE_ID = "hassInstanceId" +SUBSCRIPTION_WARNING_LIMIT = 40 + STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -41,6 +43,12 @@ SUPPORTED_PLATFORMS = [ "scene", ] +IGNORED_CAPABILITIES = [ + "execute", + "healthCheck", + "ocf", +] + TOKEN_REFRESH_INTERVAL = timedelta(days=14) VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 918ee455c27..24d6e4ae18f 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -44,10 +44,12 @@ from .const import ( DATA_BROKERS, DATA_MANAGER, DOMAIN, + IGNORED_CAPABILITIES, SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION, + SUBSCRIPTION_WARNING_LIMIT, ) _LOGGER = logging.getLogger(__name__) @@ -355,7 +357,26 @@ async def smartapp_sync_subscriptions( capabilities = set() for device in devices: capabilities.update(device.capabilities) + # Remove items not defined in the library capabilities.intersection_update(CAPABILITIES) + # Remove unused capabilities + capabilities.difference_update(IGNORED_CAPABILITIES) + capability_count = len(capabilities) + if capability_count > SUBSCRIPTION_WARNING_LIMIT: + _LOGGER.warning( + "Some device attributes may not receive push updates and there may be subscription " + "creation failures under app '%s' because %s subscriptions are required but " + "there is a limit of %s per app", + installed_app_id, + capability_count, + SUBSCRIPTION_WARNING_LIMIT, + ) + _LOGGER.debug( + "Synchronizing subscriptions for %s capabilities under app '%s': %s", + capability_count, + installed_app_id, + capabilities, + ) # Get current subscriptions and find differences subscriptions = await api.subscriptions(installed_app_id) diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py index 458e5f8ce27..42215def82f 100644 --- a/tests/components/smartthings/test_smartapp.py +++ b/tests/components/smartthings/test_smartapp.py @@ -1,7 +1,7 @@ """Tests for the smartapp module.""" from uuid import uuid4 -from pysmartthings import AppEntity, Capability +from pysmartthings import CAPABILITIES, AppEntity, Capability from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.const import ( @@ -89,7 +89,7 @@ async def test_smartapp_webhook(hass): async def test_smartapp_sync_subscriptions( hass, smartthings_mock, device_factory, subscription_factory ): - """Test synchronization adds and removes.""" + """Test synchronization adds and removes and ignores unused.""" smartthings_mock.subscriptions.return_value = [ subscription_factory(Capability.thermostat), subscription_factory(Capability.switch), @@ -98,7 +98,7 @@ async def test_smartapp_sync_subscriptions( devices = [ device_factory("", [Capability.battery, "ping"]), device_factory("", [Capability.switch, Capability.switch_level]), - device_factory("", [Capability.switch]), + device_factory("", [Capability.switch, Capability.execute]), ] await smartapp.smartapp_sync_subscriptions( @@ -134,6 +134,25 @@ async def test_smartapp_sync_subscriptions_up_to_date( assert smartthings_mock.create_subscription.call_count == 0 +async def test_smartapp_sync_subscriptions_limit_warning( + hass, smartthings_mock, device_factory, subscription_factory, caplog +): + """Test synchronization over the limit logs a warning.""" + smartthings_mock.subscriptions.return_value = [] + devices = [ + device_factory("", CAPABILITIES), + ] + + await smartapp.smartapp_sync_subscriptions( + hass, str(uuid4()), str(uuid4()), str(uuid4()), devices + ) + + assert ( + "Some device attributes may not receive push updates and there may be " + "subscription creation failures" in caplog.text + ) + + async def test_smartapp_sync_subscriptions_handles_exceptions( hass, smartthings_mock, device_factory, subscription_factory ): From 9d0f58009e206215c772d19cf72c950a718e3d72 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Fri, 31 Jul 2020 15:41:36 -0400 Subject: [PATCH 230/362] Add support for HomeKit doorbell (#38419) * Add support for HomeKit doorbell * Update homeassistant/components/homekit/type_cameras.py Co-authored-by: J. Nick Koston * Update homeassistant/components/homekit/type_cameras.py Co-authored-by: J. Nick Koston * add speaker service for doorbells * fixed test as doorbell char requires null value * removed null value for doorbell presses. and removed broken override of default values Co-authored-by: J. Nick Koston --- homeassistant/components/homekit/__init__.py | 10 ++ homeassistant/components/homekit/const.py | 4 + .../components/homekit/type_cameras.py | 69 ++++++++++-- homeassistant/components/homekit/util.py | 4 + tests/components/homekit/test_type_cameras.py | 102 ++++++++++++++++++ 5 files changed, 179 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 494daa54b23..2b6db6af528 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DOMAIN as BINARY_SENSOR_DOMAIN, ) from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN @@ -55,6 +56,7 @@ from .const import ( CONF_FILTER, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_SAFE_MODE, @@ -487,6 +489,7 @@ class HomeKit: { (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_BATTERY_CHARGING), (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_MOTION), + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY), (SENSOR_DOMAIN, DEVICE_CLASS_BATTERY), (SENSOR_DOMAIN, DEVICE_CLASS_HUMIDITY), } @@ -631,6 +634,13 @@ class HomeKit: self._config.setdefault(state.entity_id, {}).setdefault( CONF_LINKED_MOTION_SENSOR, motion_binary_sensor_entity_id, ) + doorbell_binary_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( + (BINARY_SENSOR_DOMAIN, DEVICE_CLASS_OCCUPANCY) + ) + if doorbell_binary_sensor_entity_id: + self._config.setdefault(state.entity_id, {}).setdefault( + CONF_LINKED_DOORBELL_SENSOR, doorbell_binary_sensor_entity_id, + ) if state.entity_id.startswith(f"{HUMIDIFIER_DOMAIN}."): current_humidity_sensor_entity_id = device_lookup[ diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index b32d7f4bad4..e38b86a7032 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -41,6 +41,7 @@ CONF_FEATURE_LIST = "feature_list" CONF_FILTER = "filter" CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor" CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor" +CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor" CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor" CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor" CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold" @@ -112,6 +113,7 @@ SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement" SERV_CARBON_DIOXIDE_SENSOR = "CarbonDioxideSensor" SERV_CARBON_MONOXIDE_SENSOR = "CarbonMonoxideSensor" SERV_CONTACT_SENSOR = "ContactSensor" +SERV_DOORBELL = "Doorbell" SERV_FANV2 = "Fanv2" SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener" SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier" @@ -126,6 +128,7 @@ SERV_OCCUPANCY_SENSOR = "OccupancySensor" SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" SERV_SMOKE_SENSOR = "SmokeSensor" +SERV_SPEAKER = "Speaker" SERV_SWITCH = "Switch" SERV_TELEVISION = "Television" SERV_TELEVISION_SPEAKER = "TelevisionSpeaker" @@ -184,6 +187,7 @@ CHAR_OCCUPANCY_DETECTED = "OccupancyDetected" CHAR_ON = "On" CHAR_OUTLET_IN_USE = "OutletInUse" CHAR_POSITION_STATE = "PositionState" +CHAR_PROGRAMMABLE_SWITCH_EVENT = "ProgrammableSwitchEvent" CHAR_REMOTE_KEY = "RemoteKey" CHAR_ROTATION_DIRECTION = "RotationDirection" CHAR_ROTATION_SPEED = "RotationSpeed" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 629e1019f4a..93b822f9e7a 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -23,9 +23,12 @@ from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( CHAR_MOTION_DETECTED, + CHAR_MUTE, + CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, CONF_AUDIO_MAP, CONF_AUDIO_PACKET_SIZE, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_MAX_FPS, CONF_MAX_HEIGHT, @@ -48,13 +51,18 @@ from .const import ( DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, + SERV_DOORBELL, SERV_MOTION_SENSOR, + SERV_SPEAKER, ) from .img_util import scale_jpeg_camera_image from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) +DOORBELL_SINGLE_PRESS = 0 +DOORBELL_DOUBLE_PRESS = 1 +DOORBELL_LONG_PRESS = 2 VIDEO_OUTPUT = ( "-map {v_map} -an " @@ -190,18 +198,32 @@ class Camera(HomeAccessory, PyhapCamera): category=CATEGORY_CAMERA, options=options, ) + self._char_motion_detected = None self.linked_motion_sensor = self.config.get(CONF_LINKED_MOTION_SENSOR) - if not self.linked_motion_sensor: - return - state = self.hass.states.get(self.linked_motion_sensor) - if not state: - return - serv_motion = self.add_preload_service(SERV_MOTION_SENSOR) - self._char_motion_detected = serv_motion.configure_char( - CHAR_MOTION_DETECTED, value=False - ) - self._async_update_motion_state(state) + if self.linked_motion_sensor: + state = self.hass.states.get(self.linked_motion_sensor) + if state: + serv_motion = self.add_preload_service(SERV_MOTION_SENSOR) + self._char_motion_detected = serv_motion.configure_char( + CHAR_MOTION_DETECTED, value=False + ) + self._async_update_motion_state(state) + + self._char_doorbell_detected = None + self.linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR) + if self.linked_doorbell_sensor: + state = self.hass.states.get(self.linked_doorbell_sensor) + if state: + serv_doorbell = self.add_preload_service(SERV_DOORBELL) + self.set_primary_service(serv_doorbell) + self._char_doorbell_detected = serv_doorbell.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, value=0, + ) + serv_speaker = self.add_preload_service(SERV_SPEAKER) + serv_speaker.configure_char(CHAR_MUTE, value=0) + + self._async_update_doorbell_state(state) async def run_handler(self): """Handle accessory driver started event. @@ -215,6 +237,13 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_motion_state_event, ) + if self._char_doorbell_detected: + async_track_state_change_event( + self.hass, + [self.linked_doorbell_sensor], + self._async_update_doorbell_state_event, + ) + await super().run_handler() @callback @@ -240,6 +269,26 @@ class Camera(HomeAccessory, PyhapCamera): detected, ) + @callback + def _async_update_doorbell_state_event(self, event): + """Handle state change event listener callback.""" + self._async_update_doorbell_state(event.data.get("new_state")) + + @callback + def _async_update_doorbell_state(self, new_state): + """Handle link doorbell sensor state change to update HomeKit value.""" + if not new_state: + return + + if new_state.state == STATE_ON: + self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) + _LOGGER.debug( + "%s: Set linked doorbell %s sensor to %d", + self.entity_id, + self.linked_doorbell_sensor, + DOORBELL_SINGLE_PRESS, + ) + @callback def async_update_state(self, new_state): """Handle state change to update HomeKit value.""" diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 449d2506d04..201a0529f82 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -34,6 +34,7 @@ from .const import ( CONF_FEATURE_LIST, CONF_LINKED_BATTERY_CHARGING_SENSOR, CONF_LINKED_BATTERY_SENSOR, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_HUMIDITY_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_LOW_BATTERY_THRESHOLD, @@ -127,6 +128,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( CONF_VIDEO_PACKET_SIZE, default=DEFAULT_VIDEO_PACKET_SIZE ): cv.positive_int, vol.Optional(CONF_LINKED_MOTION_SENSOR): cv.entity_domain(binary_sensor.DOMAIN), + vol.Optional(CONF_LINKED_DOORBELL_SENSOR): cv.entity_domain( + binary_sensor.DOMAIN + ), } ) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index dbc28cb1ea8..9e8faa34d38 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -10,12 +10,16 @@ from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( AUDIO_CODEC_COPY, CHAR_MOTION_DETECTED, + CHAR_PROGRAMMABLE_SWITCH_EVENT, CONF_AUDIO_CODEC, + CONF_LINKED_DOORBELL_SENSOR, CONF_LINKED_MOTION_SENSOR, CONF_STREAM_SOURCE, CONF_SUPPORT_AUDIO, CONF_VIDEO_CODEC, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + SERV_DOORBELL, SERV_MOTION_SENSOR, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, @@ -601,3 +605,101 @@ async def test_camera_with_a_missing_linked_motion_sensor(hass, run_driver, even assert acc.category == 17 # Camera assert not acc.get_service(SERV_MOTION_SENSOR) + + +async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): + """Test a camera with a linked doorbell sensor can update.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + + hass.states.async_set( + doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + entity_id = "camera.demo_camera" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + { + CONF_STREAM_SOURCE: "/dev/null", + CONF_SUPPORT_AUDIO: True, + CONF_VIDEO_CODEC: VIDEO_CODEC_H264_OMX, + CONF_AUDIO_CODEC: AUDIO_CODEC_COPY, + CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id, + }, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + service = acc.get_service(SERV_DOORBELL) + assert service + char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char + + assert char.value == 0 + + hass.states.async_set( + doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + assert char.value == 0 + + char.set_value(True) + hass.states.async_set( + doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} + ) + await hass.async_block_till_done() + assert char.value == 0 + + # Ensure we do not throw when the linked + # doorbell sensor is removed + hass.states.async_remove(doorbell_entity_id) + await hass.async_block_till_done() + await acc.run_handler() + await hass.async_block_till_done() + assert char.value == 0 + + +async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, events): + """Test a camera with a configured linked doorbell sensor that is missing.""" + await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) + await async_setup_component( + hass, camera.DOMAIN, {camera.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + doorbell_entity_id = "binary_sensor.doorbell" + entity_id = "camera.demo_camera" + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Camera( + hass, + run_driver, + "Camera", + entity_id, + 2, + {CONF_LINKED_DOORBELL_SENSOR: doorbell_entity_id}, + ) + bridge = HomeBridge("hass", run_driver, "Test Bridge") + bridge.add_accessory(acc) + + await acc.run_handler() + + assert acc.aid == 2 + assert acc.category == 17 # Camera + + assert not acc.get_service(SERV_DOORBELL) From 476235a2599b375d9bd20cc7fa4a2453d876bb8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jul 2020 09:55:38 -1000 Subject: [PATCH 231/362] Restore the ability to tell when a harmony activity is starting (#38335) * Restore the ability to tell when a harmony activity is starting * adjust for poweroff * switch to activity name for activity starting * adjust * do not set starting on initial update --- homeassistant/components/harmony/const.py | 1 + homeassistant/components/harmony/remote.py | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/harmony/const.py b/homeassistant/components/harmony/const.py index 26368810c83..f6315b57b57 100644 --- a/homeassistant/components/harmony/const.py +++ b/homeassistant/components/harmony/const.py @@ -10,4 +10,5 @@ ATTR_ACTIVITY_LIST = "activity_list" ATTR_DEVICES_LIST = "devices_list" ATTR_LAST_ACTIVITY = "last_activity" ATTR_CURRENT_ACTIVITY = "current_activity" +ATTR_ACTIVITY_STARTING = "activity_starting" PREVIOUS_ACTIVE_ACTIVITY = "Previous Active Activity" diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index badd1cc9508..1ebcfbaa760 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -30,6 +30,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ACTIVITY_POWER_OFF, ATTR_ACTIVITY_LIST, + ATTR_ACTIVITY_STARTING, ATTR_CURRENT_ACTIVITY, ATTR_DEVICES_LIST, ATTR_LAST_ACTIVITY, @@ -138,6 +139,8 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): self._state = None self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity + self._activity_starting = None + self._is_initial_update = False self._client = HarmonyClient(ip_address=host) self._config_path = out_path self.delay_secs = delay_secs @@ -172,10 +175,15 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): "connect": self.got_connected, "disconnect": self.got_disconnected, "new_activity_starting": self.new_activity, - "new_activity": None, + "new_activity": self._new_activity_finished, } self._client.callbacks = ClientCallbackType(**callbacks) + def _new_activity_finished(self, activity_info: tuple) -> None: + """Call for finished updated current activity.""" + self._activity_starting = None + self.async_write_ha_state() + async def async_added_to_hass(self): """Complete the initialization.""" await super().async_added_to_hass() @@ -252,6 +260,7 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): def device_state_attributes(self): """Add platform specific attributes.""" return { + ATTR_ACTIVITY_STARTING: self._activity_starting, ATTR_CURRENT_ACTIVITY: self._current_activity, ATTR_ACTIVITY_LIST: list_names_from_hublist( self._client.hub_config.activities @@ -288,6 +297,10 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): activity_id, activity_name = activity_info _LOGGER.debug("%s: activity reported as: %s", self._name, activity_name) self._current_activity = activity_name + if self._is_initial_update: + self._is_initial_update = False + else: + self._activity_starting = activity_name if activity_id != -1: # Save the activity so we can restore # to that activity if none is specified From f4c0dc99c2586f7d8a7db36f4267cec03df04372 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Jul 2020 22:06:02 +0200 Subject: [PATCH 232/362] Pin yarl dependency to 1.4.2 as core dependency (#38428) --- homeassistant/package_constraints.txt | 1 + requirements.txt | 1 + setup.py | 1 + 3 files changed, 3 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 41bb086bfbd..6066ee71715 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,6 +27,7 @@ ruamel.yaml==0.15.100 sqlalchemy==1.3.18 voluptuous-serialize==2.4.0 voluptuous==0.11.7 +yarl==1.4.2 zeroconf==0.28.0 pycryptodome>=3.6.6 diff --git a/requirements.txt b/requirements.txt index 38d077e3da1..702e4eaf19f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ requests==2.24.0 ruamel.yaml==0.15.100 voluptuous==0.11.7 voluptuous-serialize==2.4.0 +yarl==1.4.2 diff --git a/setup.py b/setup.py index ae762d2d8ce..81f8727ed60 100755 --- a/setup.py +++ b/setup.py @@ -52,6 +52,7 @@ REQUIRES = [ "ruamel.yaml==0.15.100", "voluptuous==0.11.7", "voluptuous-serialize==2.4.0", + "yarl==1.4.2", ] MIN_PY_VERSION = ".".join(map(str, hass_const.REQUIRED_PYTHON_VER)) From 1c9a36b7317f2236accab1e17446adbfa972395c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 31 Jul 2020 22:06:17 +0200 Subject: [PATCH 233/362] Fix double encoding issue in google_translate TTS (#38429) --- homeassistant/components/google_translate/tts.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index 36543e0515e..a040da0dccd 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -8,7 +8,6 @@ from aiohttp.hdrs import REFERER, USER_AGENT import async_timeout from gtts_token import gtts_token import voluptuous as vol -import yarl from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.const import HTTP_OK @@ -129,7 +128,7 @@ class GoogleProvider(Provider): url_param = { "ie": "UTF-8", "tl": language, - "q": yarl.URL(part).raw_path, + "q": part, "tk": part_token, "total": len(message_parts), "idx": idx, From a9c34b1d2b94619b1a5ef1fc684087e88de8e771 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sat, 1 Aug 2020 00:43:14 +0100 Subject: [PATCH 234/362] Update aioazuredevops to v1.3.5 (#38441) --- homeassistant/components/azure_devops/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/azure_devops/manifest.json b/homeassistant/components/azure_devops/manifest.json index be0d2fb0fbe..17338f5a29f 100644 --- a/homeassistant/components/azure_devops/manifest.json +++ b/homeassistant/components/azure_devops/manifest.json @@ -3,6 +3,6 @@ "name": "Azure DevOps", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/azure_devops", - "requirements": ["aioazuredevops==1.3.4"], + "requirements": ["aioazuredevops==1.3.5"], "codeowners": ["@timmo001"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7b361179e3d..dc945876acc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -148,7 +148,7 @@ aioambient==1.2.0 aioasuswrt==1.2.7 # homeassistant.components.azure_devops -aioazuredevops==1.3.4 +aioazuredevops==1.3.5 # homeassistant.components.aws aiobotocore==0.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8eb00a4bc36..9a746c7c64c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -76,7 +76,7 @@ aioambient==1.2.0 aioasuswrt==1.2.7 # homeassistant.components.azure_devops -aioazuredevops==1.3.4 +aioazuredevops==1.3.5 # homeassistant.components.aws aiobotocore==0.11.1 From 04e5fc7ccdea4d5830d102549bd831cefc131380 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sat, 1 Aug 2020 00:03:18 +0000 Subject: [PATCH 235/362] [ci skip] Translation update --- .../accuweather/translations/uk.json | 26 +++++++++++++++++++ .../azure_devops/translations/uk.json | 14 +++++++--- .../components/bond/translations/ca.json | 3 +++ .../components/bond/translations/en.json | 2 +- .../components/bond/translations/it.json | 3 +++ .../components/bond/translations/ru.json | 3 +++ .../components/bond/translations/uk.json | 7 +++++ .../components/control4/translations/uk.json | 20 ++++++++++++++ .../components/cover/translations/ca.json | 3 ++- .../components/cover/translations/en.json | 4 +-- .../components/cover/translations/it.json | 3 ++- .../components/cover/translations/ru.json | 3 ++- .../components/cover/translations/uk.json | 5 ++++ .../components/enocean/translations/no.json | 3 +++ .../components/volumio/translations/it.json | 24 +++++++++++++++++ .../components/volumio/translations/uk.json | 6 ++++- .../wolflink/translations/sensor.uk.json | 15 +++++++++++ .../components/wolflink/translations/uk.json | 18 +++++++++++++ 18 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/uk.json create mode 100644 homeassistant/components/bond/translations/uk.json create mode 100644 homeassistant/components/control4/translations/uk.json create mode 100644 homeassistant/components/volumio/translations/it.json create mode 100644 homeassistant/components/wolflink/translations/sensor.uk.json create mode 100644 homeassistant/components/wolflink/translations/uk.json diff --git a/homeassistant/components/accuweather/translations/uk.json b/homeassistant/components/accuweather/translations/uk.json new file mode 100644 index 00000000000..8c3f282b350 --- /dev/null +++ b/homeassistant/components/accuweather/translations/uk.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "invalid_api_key": "\u0425\u0438\u0431\u043d\u0438\u0439 \u043a\u043b\u044e\u0447 API" + }, + "step": { + "user": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u0432\u0433\u043e\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430 \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457" + }, + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u041f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u0438" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/uk.json b/homeassistant/components/azure_devops/translations/uk.json index 4dd9879e2d9..f447cfb1a81 100644 --- a/homeassistant/components/azure_devops/translations/uk.json +++ b/homeassistant/components/azure_devops/translations/uk.json @@ -1,11 +1,19 @@ { "config": { + "flow_title": "Azure DevOps: {project_url}", "step": { + "reauth": { + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f" + }, "user": { "data": { - "organization": "\u041e\u0440\u0433\u0430\u043d\u0456\u0437\u0430\u0446\u0456\u044f" - } + "organization": "\u041e\u0440\u0433\u0430\u043d\u0456\u0437\u0430\u0446\u0456\u044f", + "personal_access_token": "\u0422\u043e\u043a\u0435\u043d \u043e\u0441\u043e\u0431\u0438\u0441\u0442\u043e\u0433\u043e \u0434\u043e\u0441\u0442\u0443\u043f\u0443 (PAT)", + "project": "\u041f\u0440\u043e\u0454\u043a\u0442" + }, + "title": "\u0414\u043e\u0434\u0430\u0442\u0438 \u043f\u0440\u043e\u0435\u043a\u0442 Azure DevOps" } } - } + }, + "title": "Azure DevOps" } \ No newline at end of file diff --git a/homeassistant/components/bond/translations/ca.json b/homeassistant/components/bond/translations/ca.json index f3a39bde721..b068144df09 100644 --- a/homeassistant/components/bond/translations/ca.json +++ b/homeassistant/components/bond/translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index 3e614b03676..f26c34aa917 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -17,4 +17,4 @@ } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/it.json b/homeassistant/components/bond/translations/it.json index 3f69402f705..8c813c64226 100644 --- a/homeassistant/components/bond/translations/it.json +++ b/homeassistant/components/bond/translations/it.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, "error": { "cannot_connect": "Impossibile connettersi", "invalid_auth": "Autenticazione non valida", diff --git a/homeassistant/components/bond/translations/ru.json b/homeassistant/components/bond/translations/ru.json index bd57c6c7095..7da16b7bab7 100644 --- a/homeassistant/components/bond/translations/ru.json +++ b/homeassistant/components/bond/translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, "error": { "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", diff --git a/homeassistant/components/bond/translations/uk.json b/homeassistant/components/bond/translations/uk.json new file mode 100644 index 00000000000..d7da60ea178 --- /dev/null +++ b/homeassistant/components/bond/translations/uk.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/uk.json b/homeassistant/components/control4/translations/uk.json new file mode 100644 index 00000000000..6c0426eba8f --- /dev/null +++ b/homeassistant/components/control4/translations/uk.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0421\u0435\u043a\u0443\u043d\u0434 \u043c\u0456\u0436 \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044f\u043c\u0438" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/translations/ca.json b/homeassistant/components/cover/translations/ca.json index 970661be215..d033606abdd 100644 --- a/homeassistant/components/cover/translations/ca.json +++ b/homeassistant/components/cover/translations/ca.json @@ -6,7 +6,8 @@ "open": "Obre {entity_name}", "open_tilt": "Inclinaci\u00f3 {entity_name} obert/a", "set_position": "Estableix la posici\u00f3 de {entity_name}", - "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}" + "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}", + "stop": "Atura {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e0 tancat/da", diff --git a/homeassistant/components/cover/translations/en.json b/homeassistant/components/cover/translations/en.json index 10551a82988..c78898872c9 100644 --- a/homeassistant/components/cover/translations/en.json +++ b/homeassistant/components/cover/translations/en.json @@ -28,9 +28,9 @@ }, "state": { "_": { - "closed": "[%key:common::state::closed%]", + "closed": "Closed", "closing": "Closing", - "open": "[%key:common::state::open%]", + "open": "Open", "opening": "Opening", "stopped": "Stopped" } diff --git a/homeassistant/components/cover/translations/it.json b/homeassistant/components/cover/translations/it.json index 95f2e34d8eb..ebba7da0e9c 100644 --- a/homeassistant/components/cover/translations/it.json +++ b/homeassistant/components/cover/translations/it.json @@ -6,7 +6,8 @@ "open": "Apri {entity_name}", "open_tilt": "Apri l'inclinazione di {entity_name}", "set_position": "Imposta la posizione di {entity_name}", - "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}" + "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}", + "stop": "Stop {entity_name}" }, "condition_type": { "is_closed": "{entity_name} \u00e8 chiuso", diff --git a/homeassistant/components/cover/translations/ru.json b/homeassistant/components/cover/translations/ru.json index 53d646fc09f..9b302e0e52a 100644 --- a/homeassistant/components/cover/translations/ru.json +++ b/homeassistant/components/cover/translations/ru.json @@ -6,7 +6,8 @@ "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name}", "open_tilt": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name} \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043d\u0430\u043a\u043b\u043e\u043d\u0430", "set_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 {entity_name}", - "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}" + "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}", + "stop": "\u041e\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c {entity_name}" }, "condition_type": { "is_closed": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", diff --git a/homeassistant/components/cover/translations/uk.json b/homeassistant/components/cover/translations/uk.json index 0e0917177e6..0485a9bb371 100644 --- a/homeassistant/components/cover/translations/uk.json +++ b/homeassistant/components/cover/translations/uk.json @@ -1,4 +1,9 @@ { + "device_automation": { + "action_type": { + "stop": "\u0417\u0443\u043f\u0438\u043d\u0438\u0442\u0438 {entity_name}" + } + }, "state": { "_": { "closed": "\u0417\u0430\u0447\u0438\u043d\u0435\u043d\u043e", diff --git a/homeassistant/components/enocean/translations/no.json b/homeassistant/components/enocean/translations/no.json index ca8b24fa852..b51e1a75ba7 100644 --- a/homeassistant/components/enocean/translations/no.json +++ b/homeassistant/components/enocean/translations/no.json @@ -3,6 +3,9 @@ "abort": { "invalid_dongle_path": "Ugyldig donglesti" }, + "error": { + "invalid_dongle_path": "Ingen gyldig dongle funnet for denne banen" + }, "flow_title": "ENOcean oppsett", "step": { "detect": { diff --git a/homeassistant/components/volumio/translations/it.json b/homeassistant/components/volumio/translations/it.json new file mode 100644 index 00000000000..f53ea6005b2 --- /dev/null +++ b/homeassistant/components/volumio/translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi al Volumio rilevato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "discovery_confirm": { + "description": "Vuoi aggiungere Volumio (`{name}`) a Home Assistant?", + "title": "Rilevato Volumio" + }, + "user": { + "data": { + "host": "Host", + "port": "Porta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/uk.json b/homeassistant/components/volumio/translations/uk.json index d408ddd8810..58947e14e4f 100644 --- a/homeassistant/components/volumio/translations/uk.json +++ b/homeassistant/components/volumio/translations/uk.json @@ -4,9 +4,13 @@ "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" }, "error": { - "cannot_connect": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f" + "cannot_connect": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f", + "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" }, "step": { + "discovery_confirm": { + "title": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e Volumio" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/wolflink/translations/sensor.uk.json b/homeassistant/components/wolflink/translations/sensor.uk.json new file mode 100644 index 00000000000..665ff99992c --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.uk.json @@ -0,0 +1,15 @@ +{ + "state": { + "wolflink__state": { + "permanent": "\u041f\u043e\u0441\u0442\u0456\u0439\u043d\u043e", + "smart_home": "\u0420\u043e\u0437\u0443\u043c\u043d\u0438\u0439 \u0434\u0456\u043c", + "sparen": "\u0415\u043a\u043e\u043d\u043e\u043c\u0456\u044f", + "stabilisierung": "\u0421\u0442\u0430\u0431\u0456\u043b\u0456\u0437\u0430\u0446\u0456\u044f", + "standby": "\u041e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u043d\u044f", + "start": "\u041f\u043e\u0447\u0430\u0442\u043e\u043a", + "storung": "\u041f\u043e\u043c\u0438\u043b\u043a\u0430", + "taktsperre": "\u0410\u043d\u0442\u0438\u0446\u0438\u043a\u043b", + "test": "\u0422\u0435\u0441\u0442" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/uk.json b/homeassistant/components/wolflink/translations/uk.json new file mode 100644 index 00000000000..a7fbdfff913 --- /dev/null +++ b/homeassistant/components/wolflink/translations/uk.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", + "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" + }, + "step": { + "device": { + "data": { + "device_name": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439" + } + } + } + } +} \ No newline at end of file From 2e340d2c2f8a5202d8341ff10201e5441f59f4b8 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Fri, 31 Jul 2020 20:36:02 -0400 Subject: [PATCH 236/362] Update bond-api to 0.1.8 (#38442) * Bump bond API dependency version * Bump bond API dependency version (PR feedback) --- homeassistant/components/bond/light.py | 2 +- homeassistant/components/bond/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 574c50dc3e3..7dec44dbb38 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -82,7 +82,7 @@ class BondLight(BondEntity, LightEntity): if brightness: await self._hub.bond.action( self._device.device_id, - Action(Action.SET_BRIGHTNESS, round((brightness * 100) / 255)), + Action.set_brightness(round((brightness * 100) / 255)), ) else: await self._hub.bond.action(self._device.device_id, Action.turn_light_on()) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 8144b29e7d9..c03da96cf4e 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,7 +3,7 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.7"], + "requirements": ["bond-api==0.1.8"], "codeowners": ["@prystupa"], "quality_scale": "platinum" } diff --git a/requirements_all.txt b/requirements_all.txt index dc945876acc..6e5d32d25a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -362,7 +362,7 @@ blockchain==1.4.4 bomradarloop==0.1.4 # homeassistant.components.bond -bond-api==0.1.7 +bond-api==0.1.8 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a746c7c64c..dc43253bc2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -187,7 +187,7 @@ blinkpy==0.15.1 bomradarloop==0.1.4 # homeassistant.components.bond -bond-api==0.1.7 +bond-api==0.1.8 # homeassistant.components.braviatv bravia-tv==1.0.6 From 416ee7f143c65e398b58160a5a5963c8a239ba81 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Fri, 31 Jul 2020 23:05:00 -0300 Subject: [PATCH 237/362] Add support to climate devices in Google Assistant Fan Trait (#38337) Co-authored-by: Paulus Schoutsen --- .../components/google_assistant/trait.py | 98 +++++++++++++------ tests/components/google_assistant/__init__.py | 10 +- .../google_assistant/test_google_assistant.py | 4 + .../components/google_assistant/test_trait.py | 59 +++++++++++ 4 files changed, 137 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index da3363fb4d9..90b5016260d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1153,55 +1153,89 @@ class FanSpeedTrait(_Trait): @staticmethod def supported(domain, features, device_class): """Test if state is supported.""" - if domain != fan.DOMAIN: - return False - - return features & fan.SUPPORT_SET_SPEED + if domain == fan.DOMAIN: + return features & fan.SUPPORT_SET_SPEED + if domain == climate.DOMAIN: + return features & climate.SUPPORT_FAN_MODE + return False def sync_attributes(self): """Return speed point and modes attributes for a sync request.""" - modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + domain = self.state.domain speeds = [] - for mode in modes: - if mode not in self.speed_synonyms: - continue - speed = { - "speed_name": mode, - "speed_values": [ - {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} - ], - } - speeds.append(speed) + reversible = False + + if domain == fan.DOMAIN: + modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + for mode in modes: + if mode not in self.speed_synonyms: + continue + speed = { + "speed_name": mode, + "speed_values": [ + {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} + ], + } + speeds.append(speed) + reversible = bool( + self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & fan.SUPPORT_DIRECTION + ) + elif domain == climate.DOMAIN: + modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) + for mode in modes: + speed = { + "speed_name": mode, + "speed_values": [{"speed_synonym": [mode], "lang": "en"}], + } + speeds.append(speed) return { "availableFanSpeeds": {"speeds": speeds, "ordered": True}, - "reversible": bool( - self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - & fan.SUPPORT_DIRECTION - ), + "reversible": reversible, } def query_attributes(self): """Return speed point and modes query attributes.""" attrs = self.state.attributes + domain = self.state.domain response = {} - - speed = attrs.get(fan.ATTR_SPEED) - if speed is not None: - response["on"] = speed != fan.SPEED_OFF - response["currentFanSpeedSetting"] = speed - + if domain == climate.DOMAIN: + speed = attrs.get(climate.ATTR_FAN_MODE) + if speed is not None: + response["currentFanSpeedSetting"] = speed + if domain == fan.DOMAIN: + speed = attrs.get(fan.ATTR_SPEED) + if speed is not None: + response["on"] = speed != fan.SPEED_OFF + response["currentFanSpeedSetting"] = speed return response async def execute(self, command, data, params, challenge): """Execute an SetFanSpeed command.""" - await self.hass.services.async_call( - fan.DOMAIN, - fan.SERVICE_SET_SPEED, - {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params["fanSpeed"]}, - blocking=True, - context=data.context, - ) + domain = self.state.domain + if domain == climate.DOMAIN: + await self.hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_FAN_MODE: params["fanSpeed"], + }, + blocking=True, + context=data.context, + ) + if domain == fan.DOMAIN: + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_SPEED, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_SPEED: params["fanSpeed"], + }, + blocking=True, + context=data.context, + ) @register_trait diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index a801a6c960f..f665fa53ed2 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -229,7 +229,10 @@ DEMO_DEVICES = [ { "id": "climate.hvac", "name": {"name": "Hvac"}, - "traits": ["action.devices.traits.TemperatureSetting"], + "traits": [ + "action.devices.traits.TemperatureSetting", + "action.devices.traits.FanSpeed", + ], "type": "action.devices.types.THERMOSTAT", "willReportState": False, "attributes": { @@ -247,7 +250,10 @@ DEMO_DEVICES = [ { "id": "climate.ecobee", "name": {"name": "Ecobee"}, - "traits": ["action.devices.traits.TemperatureSetting"], + "traits": [ + "action.devices.traits.TemperatureSetting", + "action.devices.traits.FanSpeed", + ], "type": "action.devices.types.THERMOSTAT", "willReportState": False, }, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index e4beaa14bba..cd268a5c2d9 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -231,6 +231,7 @@ async def test_query_climate_request(hass_fixture, assistant_client, auth_header "thermostatTemperatureAmbient": 23, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": 21, + "currentFanSpeedSetting": "Auto Low", } assert devices["climate.hvac"] == { "online": True, @@ -238,6 +239,7 @@ async def test_query_climate_request(hass_fixture, assistant_client, auth_header "thermostatTemperatureAmbient": 22, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, + "currentFanSpeedSetting": "On High", } @@ -288,6 +290,7 @@ async def test_query_climate_request_f(hass_fixture, assistant_client, auth_head "thermostatTemperatureAmbient": -5, "thermostatMode": "heatcool", "thermostatTemperatureSetpointLow": -6.1, + "currentFanSpeedSetting": "Auto Low", } assert devices["climate.hvac"] == { "online": True, @@ -295,6 +298,7 @@ async def test_query_climate_request_f(hass_fixture, assistant_client, auth_head "thermostatTemperatureAmbient": -5.6, "thermostatMode": "cool", "thermostatHumidityAmbient": 54, + "currentFanSpeedSetting": "On High", } hass_fixture.config.units.temperature_unit = const.TEMP_CELSIUS diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 9d99736c87f..faad53fbc66 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1313,6 +1313,65 @@ async def test_fan_speed(hass): assert calls[0].data == {"entity_id": "fan.living_room_fan", "speed": "medium"} +async def test_climate_fan_speed(hass): + """Test FanSpeed trait speed control support for climate domain.""" + assert helpers.get_google_type(climate.DOMAIN, None) is not None + assert trait.FanSpeedTrait.supported(climate.DOMAIN, climate.SUPPORT_FAN_MODE, None) + + trt = trait.FanSpeedTrait( + hass, + State( + "climate.living_room_ac", + "on", + attributes={ + "fan_modes": ["auto", "low", "medium", "high"], + "fan_mode": "low", + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "availableFanSpeeds": { + "ordered": True, + "speeds": [ + { + "speed_name": "auto", + "speed_values": [{"speed_synonym": ["auto"], "lang": "en"}], + }, + { + "speed_name": "low", + "speed_values": [{"speed_synonym": ["low"], "lang": "en"}], + }, + { + "speed_name": "medium", + "speed_values": [{"speed_synonym": ["medium"], "lang": "en"}], + }, + { + "speed_name": "high", + "speed_values": [{"speed_synonym": ["high"], "lang": "en"}], + }, + ], + }, + "reversible": False, + } + + assert trt.query_attributes() == { + "currentFanSpeedSetting": "low", + } + + assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": "medium"}) + + calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_SET_FAN_MODE) + await trt.execute(trait.COMMAND_FANSPEED, BASIC_DATA, {"fanSpeed": "medium"}, {}) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "climate.living_room_ac", + "fan_mode": "medium", + } + + async def test_inputselector(hass): """Test input selector trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None From ff1709979fe8f33512bf4f30c010a67d3575f144 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Sat, 1 Aug 2020 09:13:17 +0200 Subject: [PATCH 238/362] Add unique ids for "buienradar" platforms weather and camera (#37761) * Add unique ids for buienradar weather and camera * Remove prefix from unique ids --- homeassistant/components/buienradar/camera.py | 7 +++++++ homeassistant/components/buienradar/sensor.py | 3 --- homeassistant/components/buienradar/util.py | 11 ----------- homeassistant/components/buienradar/weather.py | 15 +++++++++++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 78c8f82d1ff..1bc0ee464db 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -95,6 +95,8 @@ class BuienradarCam(Camera): # deadline for image refresh - self.delta after last successful load self._deadline: Optional[datetime] = None + self._unique_id = f"{self._dimension}_{self._country}" + @property def name(self) -> str: """Return the component name.""" @@ -186,3 +188,8 @@ class BuienradarCam(Camera): async with self._condition: self._loading = False self._condition.notify_all() + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 92811b98a80..47bacfe2247 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -204,7 +204,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Create the buienradar sensor.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) timeframe = config[CONF_TIMEFRAME] @@ -236,7 +235,6 @@ class BrSensor(Entity): def __init__(self, sensor_type, client_name, coordinates): """Initialize the sensor.""" - self.client_name = client_name self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type @@ -428,7 +426,6 @@ class BrSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - if self.type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: self._attribution} if self._timeframe is not None: diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index e64925bf19e..b4f2314eee5 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -107,7 +107,6 @@ class BrData: async def async_update(self, *_): """Update the data from buienradar.""" - content = await self.get_data(JSON_FEED_URL) if content.get(SUCCESS) is not True: @@ -170,25 +169,21 @@ class BrData: @property def attribution(self): """Return the attribution.""" - return self.data.get(ATTRIBUTION) @property def stationname(self): """Return the name of the selected weatherstation.""" - return self.data.get(STATIONNAME) @property def condition(self): """Return the condition.""" - return self.data.get(CONDITION) @property def temperature(self): """Return the temperature, or None.""" - try: return float(self.data.get(TEMPERATURE)) except (ValueError, TypeError): @@ -197,7 +192,6 @@ class BrData: @property def pressure(self): """Return the pressure, or None.""" - try: return float(self.data.get(PRESSURE)) except (ValueError, TypeError): @@ -206,7 +200,6 @@ class BrData: @property def humidity(self): """Return the humidity, or None.""" - try: return int(self.data.get(HUMIDITY)) except (ValueError, TypeError): @@ -215,7 +208,6 @@ class BrData: @property def visibility(self): """Return the visibility, or None.""" - try: return int(self.data.get(VISIBILITY)) except (ValueError, TypeError): @@ -224,7 +216,6 @@ class BrData: @property def wind_speed(self): """Return the windspeed, or None.""" - try: return float(self.data.get(WINDSPEED)) except (ValueError, TypeError): @@ -233,7 +224,6 @@ class BrData: @property def wind_bearing(self): """Return the wind bearing, or None.""" - try: return int(self.data.get(WINDAZIMUTH)) except (ValueError, TypeError): @@ -242,5 +232,4 @@ class BrData: @property def forecast(self): """Return the forecast data.""" - return self.data.get(FORECAST) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 37dee08313e..d0a0c0e18b4 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -90,7 +90,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= for condi in condlst: hass.data[DATA_CONDITION][condi] = cond - async_add_entities([BrWeather(data, config)]) + async_add_entities([BrWeather(data, config, coordinates)]) # schedule the first update in 1 minute from now: await data.schedule_update(1) @@ -99,12 +99,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BrWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, data, config): + def __init__(self, data, config, coordinates): """Initialise the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME) self._forecast = config[CONF_FORECAST] self._data = data + self._unique_id = "{:2.6f}{:2.6f}".format( + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] + ) + @property def attribution(self): """Return the attribution.""" @@ -120,7 +124,6 @@ class BrWeather(WeatherEntity): @property def condition(self): """Return the current condition.""" - if self._data and self._data.condition: ccode = self._data.condition.get(CONDCODE) if ccode: @@ -170,7 +173,6 @@ class BrWeather(WeatherEntity): @property def forecast(self): """Return the forecast array.""" - if not self._forecast: return None @@ -197,3 +199,8 @@ class BrWeather(WeatherEntity): fcdata_out.append(data_out) return fcdata_out + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id From fe69a853862d2c5883044d082c7632f54ed4c671 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 Jul 2020 23:20:37 -1000 Subject: [PATCH 239/362] Improve logging when a unique id conflict is detected (#38434) * fix error when unique id is re-used * add test for the logging * Update homeassistant/helpers/entity_platform.py Co-authored-by: Martin Hjelmare * Update homeassistant/helpers/entity_platform.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/helpers/entity_platform.py | 11 +++++++++-- tests/helpers/test_entity_platform.py | 14 +++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index d101abe2b1a..fb542f660d1 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -325,11 +325,13 @@ class EntityPlatform: entity.platform = None return + requested_entity_id = None suggested_object_id = None # Get entity_id from unique ID registration if entity.unique_id is not None: if entity.entity_id is not None: + requested_entity_id = entity.entity_id suggested_object_id = split_entity_id(entity.entity_id)[1] else: suggested_object_id = entity.name @@ -435,9 +437,14 @@ class EntityPlatform: already_exists = True if already_exists: - msg = f"Entity id already exists - ignoring: {entity.entity_id}" if entity.unique_id is not None: - msg += f". Platform {self.platform_name} does not generate unique IDs" + msg = f"Platform {self.platform_name} does not generate unique IDs. " + if requested_entity_id: + msg += f"ID {entity.unique_id} is already used by {entity.entity_id} - ignoring {requested_entity_id}" + else: + msg += f"ID {entity.unique_id} already exists - ignoring {entity.entity_id}" + else: + msg = f"Entity id already exists - ignoring: {entity.entity_id}" self.logger.error(msg) entity.hass = None entity.platform = None diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index ddecd1988ed..527a89843dd 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -375,8 +375,9 @@ async def test_async_remove_with_platform(hass): assert len(hass.states.async_entity_ids()) == 0 -async def test_not_adding_duplicate_entities_with_unique_id(hass): +async def test_not_adding_duplicate_entities_with_unique_id(hass, caplog): """Test for not adding duplicate entities.""" + caplog.set_level(logging.ERROR) component = EntityComponent(_LOGGER, DOMAIN, hass) await component.async_add_entities( @@ -384,9 +385,20 @@ async def test_not_adding_duplicate_entities_with_unique_id(hass): ) assert len(hass.states.async_entity_ids()) == 1 + assert not caplog.text ent2 = MockEntity(name="test2", unique_id="not_very_unique") await component.async_add_entities([ent2]) + assert "test1" in caplog.text + assert DOMAIN in caplog.text + + ent3 = MockEntity( + name="test2", entity_id="test_domain.test3", unique_id="not_very_unique" + ) + await component.async_add_entities([ent3]) + assert "test1" in caplog.text + assert "test3" in caplog.text + assert DOMAIN in caplog.text assert ent2.hass is None assert ent2.platform is None From c3a820c4a37242df5abc43548dbd3747c3d259cc Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 1 Aug 2020 08:51:48 -0500 Subject: [PATCH 240/362] Fix queued script not updating current attribute when queuing (#38432) --- homeassistant/helpers/script.py | 4 +- tests/helpers/test_script.py | 86 +++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1f9963e184b..6fed54227a3 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -176,8 +176,6 @@ class _ScriptRun: try: if self._stop.is_set(): return - self._script.last_triggered = utcnow() - self._changed() self._log("Running script") for self._step, self._action in enumerate(self._script.sequence): if self._stop.is_set(): @@ -797,6 +795,8 @@ class Script: self._hass, self, cast(dict, variables), context, self._log_exceptions ) self._runs.append(run) + self.last_triggered = utcnow() + self._changed() try: await asyncio.shield(run.async_run()) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 28761c0ba17..fbfd06aa930 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1290,23 +1290,46 @@ async def test_script_mode_queued(hass): sequence = cv.SCRIPT_SCHEMA( [ {"event": event, "event_data": {"value": 1}}, - {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "alias": "wait_1", + }, {"event": event, "event_data": {"value": 2}}, - {"wait_template": "{{ states.switch.test.state == 'on' }}"}, + { + "wait_template": "{{ states.switch.test.state == 'on' }}", + "alias": "wait_2", + }, ] ) logger = logging.getLogger("TEST") script_obj = script.Script( hass, sequence, script_mode="queued", max_runs=2, logger=logger ) - wait_started_flag = async_watch_for_action(script_obj, "wait") + + watch_messages = [] + + @callback + def check_action(): + for message, flag in watch_messages: + if script_obj.last_action and message in script_obj.last_action: + flag.set() + + script_obj.change_listener = check_action + wait_started_flag_1 = asyncio.Event() + watch_messages.append(("wait_1", wait_started_flag_1)) + wait_started_flag_2 = asyncio.Event() + watch_messages.append(("wait_2", wait_started_flag_2)) try: + assert not script_obj.is_running + assert script_obj.runs == 0 + hass.states.async_set("switch.test", "on") hass.async_create_task(script_obj.async_run()) - await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.wait_for(wait_started_flag_1.wait(), 1) assert script_obj.is_running + assert script_obj.runs == 1 assert len(events) == 1 assert events[0].data["value"] == 1 @@ -1314,25 +1337,26 @@ async def test_script_mode_queued(hass): # This second run should not start until the first run has finished. hass.async_create_task(script_obj.async_run()) - await asyncio.sleep(0) + assert script_obj.is_running + assert script_obj.runs == 2 assert len(events) == 1 - wait_started_flag.clear() hass.states.async_set("switch.test", "off") - await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.wait_for(wait_started_flag_2.wait(), 1) assert script_obj.is_running + assert script_obj.runs == 2 assert len(events) == 2 assert events[1].data["value"] == 2 - wait_started_flag.clear() + wait_started_flag_1.clear() hass.states.async_set("switch.test", "on") - await asyncio.wait_for(wait_started_flag.wait(), 1) + await asyncio.wait_for(wait_started_flag_1.wait(), 1) - await asyncio.sleep(0) assert script_obj.is_running + assert script_obj.runs == 1 assert len(events) == 3 assert events[2].data["value"] == 1 except (AssertionError, asyncio.TimeoutError): @@ -1345,10 +1369,52 @@ async def test_script_mode_queued(hass): await hass.async_block_till_done() assert not script_obj.is_running + assert script_obj.runs == 0 assert len(events) == 4 assert events[3].data["value"] == 2 +async def test_script_mode_queued_cancel(hass): + """Test canceling with a queued run.""" + script_obj = script.Script( + hass, + cv.SCRIPT_SCHEMA({"wait_template": "{{ false }}"}), + "test", + script_mode="queued", + max_runs=2, + ) + wait_started_flag = async_watch_for_action(script_obj, "wait") + + try: + assert not script_obj.is_running + assert script_obj.runs == 0 + + task1 = hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) + task2 = hass.async_create_task(script_obj.async_run()) + await asyncio.sleep(0) + + assert script_obj.is_running + assert script_obj.runs == 2 + + with pytest.raises(asyncio.CancelledError): + task2.cancel() + await task2 + + assert script_obj.is_running + assert script_obj.runs == 1 + + with pytest.raises(asyncio.CancelledError): + task1.cancel() + await task1 + + assert not script_obj.is_running + assert script_obj.runs == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + + async def test_script_logging(hass, caplog): """Test script logging.""" script_obj = script.Script(hass, [], "Script with % Name") From 11994d207aaf1b682c40afe0ac36c2d599f156c4 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Sat, 1 Aug 2020 12:18:40 -0400 Subject: [PATCH 241/362] Add zeroconf discovery for bond integration (#38448) * Add zeroconf discovery for bond integration * Add zeroconf discovery for bond integration (fix typo) * Add zeroconf discovery for bond integration (PR feedback) * Add zeroconf discovery for bond integration (PR feedback) * Add zeroconf discovery for bond integration (PR feedback) --- homeassistant/components/bond/config_flow.py | 99 ++++++++--- homeassistant/components/bond/const.py | 2 + homeassistant/components/bond/manifest.json | 1 + homeassistant/components/bond/strings.json | 7 + .../components/bond/translations/en.json | 9 +- homeassistant/generated/zeroconf.py | 3 + tests/components/bond/test_config_flow.py | 158 ++++++++++++++---- 7 files changed, 222 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index c4fb7310b88..aa22dc628da 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,23 +1,26 @@ """Config flow for Bond integration.""" import logging +from typing import Any, Dict, Optional from aiohttp import ClientConnectionError, ClientResponseError from bond_api import Bond import voluptuous as vol from homeassistant import config_entries, exceptions -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME +from .const import CONF_BOND_ID from .const import DOMAIN # pylint:disable=unused-import _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( +DATA_SCHEMA_USER = vol.Schema( {vol.Required(CONF_HOST): str, vol.Required(CONF_ACCESS_TOKEN): str} ) +DATA_SCHEMA_DISCOVERY = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) -async def validate_input(data) -> str: +async def _validate_input(data: Dict[str, Any]) -> str: """Validate the user input allows us to connect.""" try: @@ -26,11 +29,14 @@ async def validate_input(data) -> str: # call to non-version API is needed to validate authentication await bond.devices() except ClientConnectionError: - raise CannotConnect + raise InputValidationError("cannot_connect") except ClientResponseError as error: if error.status == 401: - raise InvalidAuth - raise + raise InputValidationError("invalid_auth") + raise InputValidationError("unknown") + except Exception: + _LOGGER.exception("Unexpected exception") + raise InputValidationError("unknown") # Return unique ID from the hub to be stored in the config entry. return version["bondid"] @@ -42,32 +48,73 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - async def async_step_user(self, user_input=None): - """Handle the initial step.""" + _discovered: dict = None + + async def async_step_zeroconf( + self, discovery_info: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by zeroconf discovery.""" + name: str = discovery_info[CONF_NAME] + host: str = discovery_info[CONF_HOST] + bond_id = name.partition(".")[0] + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured({CONF_HOST: host}) + + self._discovered = { + CONF_HOST: host, + CONF_BOND_ID: bond_id, + } + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": self._discovered}) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle confirmation flow for discovered bond hub.""" + errors = {} + if user_input is not None: + data = user_input.copy() + data[CONF_HOST] = self._discovered[CONF_HOST] + try: + return await self._try_create_entry(data) + except InputValidationError as error: + errors["base"] = error.base + + return self.async_show_form( + step_id="confirm", + data_schema=DATA_SCHEMA_DISCOVERY, + errors=errors, + description_placeholders=self._discovered, + ) + + async def async_step_user( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by the user.""" errors = {} if user_input is not None: try: - bond_id = await validate_input(user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(bond_id) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=bond_id, data=user_input) + return await self._try_create_entry(user_input) + except InputValidationError as error: + errors["base"] = error.base return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors ) - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" + async def _try_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]: + bond_id = await _validate_input(data) + await self.async_set_unique_id(bond_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=bond_id, data=data) -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" +class InputValidationError(exceptions.HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str): + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 4ad08991b31..843c3f9f1dc 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -1,3 +1,5 @@ """Constants for the Bond integration.""" DOMAIN = "bond" + +CONF_BOND_ID: str = "bond_id" diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index c03da96cf4e..3f62403dba7 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", "requirements": ["bond-api==0.1.8"], + "zeroconf": ["_bond._tcp.local."], "codeowners": ["@prystupa"], "quality_scale": "platinum" } diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index 6577c99456c..ba59a61d58d 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -1,6 +1,13 @@ { "config": { + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "description": "Do you want to set up {bond_id}?", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]", diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index f26c34aa917..6d47cc35c14 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -8,7 +8,14 @@ "invalid_auth": "Invalid authentication", "unknown": "Unexpected error" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Access Token" + }, + "description": "Do you want to set up {bond_id}?" + }, "user": { "data": { "access_token": "Access Token", @@ -17,4 +24,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 872b07f5c6a..a61444a42c0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -16,6 +16,9 @@ ZEROCONF = { "axis", "doorbird" ], + "_bond._tcp.local.": [ + "bond" + ], "_daap._tcp.local.": [ "forked_daapd" ], diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index fa20355f356..bd499b8ce61 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Bond config flow.""" +from typing import Any, Dict from aiohttp import ClientConnectionError, ClientResponseError @@ -12,8 +13,8 @@ from tests.async_mock import Mock, patch from tests.common import MockConfigEntry -async def test_form(hass: core.HomeAssistant): - """Test we get the form.""" +async def test_user_form(hass: core.HomeAssistant): + """Test we get the user initiated form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -23,11 +24,7 @@ async def test_form(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "test-bond-id"} - ), patch_bond_device_ids(), patch( - "homeassistant.components.bond.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.bond.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -44,7 +41,7 @@ async def test_form(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: core.HomeAssistant): +async def test_user_form_invalid_auth(hass: core.HomeAssistant): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -64,7 +61,7 @@ async def test_form_invalid_auth(hass: core.HomeAssistant): assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: core.HomeAssistant): +async def test_user_form_cannot_connect(hass: core.HomeAssistant): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -82,27 +79,27 @@ async def test_form_cannot_connect(hass: core.HomeAssistant): assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_error(hass: core.HomeAssistant): - """Test we handle unexpected error gracefully.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} +async def test_user_form_unexpected_client_error(hass: core.HomeAssistant): + """Test we handle unexpected client error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_USER, + user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + error=ClientResponseError(Mock(), Mock(), status=500), ) - with patch_bond_version( - return_value={"bond_id": "test-bond-id"} - ), patch_bond_device_ids( - side_effect=ClientResponseError(Mock(), Mock(), status=500) - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} +async def test_user_form_unexpected_error(hass: core.HomeAssistant): + """Test we handle unexpected error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_USER, + user_input={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, + error=Exception(), + ) -async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): +async def test_user_form_one_entry_per_device_allowed(hass: core.HomeAssistant): """Test that only one entry allowed per unique ID reported by Bond hub device.""" MockConfigEntry( domain=DOMAIN, @@ -118,11 +115,7 @@ async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): with patch_bond_version( return_value={"bondid": "already-registered-bond-id"} - ), patch_bond_device_ids(), patch( - "homeassistant.components.bond.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.bond.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -134,3 +127,108 @@ async def test_form_one_entry_per_device_allowed(hass: core.HomeAssistant): await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_form(hass: core.HomeAssistant): + """Test we get the discovery form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={"name": "test-bond-id.some-other-tail-info", "host": "test-host"}, + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch_bond_version( + return_value={"bondid": "test-bond-id"} + ), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_ACCESS_TOKEN: "test-token"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-bond-id" + assert result2["data"] == { + CONF_HOST: "test-host", + CONF_ACCESS_TOKEN: "test-token", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_already_configured(hass: core.HomeAssistant): + """Test starting a flow from discovery when already configured.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "test-token"}, + ) + entry.add_to_hass(hass) + + with _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "name": "already-registered-bond-id.some-other-tail-info", + "host": "updated-host", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "updated-host" + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): + """Test we handle unexpected error gracefully.""" + await _help_test_form_unexpected_error( + hass, + source=config_entries.SOURCE_ZEROCONF, + initial_input={ + "name": "test-bond-id.some-other-tail-info", + "host": "test-host", + }, + user_input={CONF_ACCESS_TOKEN: "test-token"}, + error=Exception(), + ) + + +async def _help_test_form_unexpected_error( + hass: core.HomeAssistant, + *, + source: str, + initial_input: Dict[str, Any] = None, + user_input: Dict[str, Any], + error: Exception, +): + """Test we handle unexpected error gracefully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=initial_input + ) + + with patch_bond_version( + return_value={"bond_id": "test-bond-id"} + ), patch_bond_device_ids(side_effect=error): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +def _patch_async_setup(): + return patch("homeassistant.components.bond.async_setup", return_value=True) + + +def _patch_async_setup_entry(): + return patch("homeassistant.components.bond.async_setup_entry", return_value=True,) From af97141f4fea0f903cad539fba55d8763036a945 Mon Sep 17 00:00:00 2001 From: Rob Bierbooms Date: Sat, 1 Aug 2020 18:26:26 +0200 Subject: [PATCH 242/362] Increase test coverage for rfxtrx integration (#38435) * Remove rfxtrx from coveragerc * Tweak binary sensor test * Tweak light test --- .coveragerc | 1 - tests/components/rfxtrx/test_binary_sensor.py | 18 ++++++++++++++++++ tests/components/rfxtrx/test_light.py | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index c49ac6257d8..4eb8f1e0210 100644 --- a/.coveragerc +++ b/.coveragerc @@ -692,7 +692,6 @@ omit = homeassistant/components/rest/binary_sensor.py homeassistant/components/rest/notify.py homeassistant/components/rest/switch.py - homeassistant/components/rfxtrx/* homeassistant/components/ring/camera.py homeassistant/components/ripple/sensor.py homeassistant/components/rocketchat/notify.py diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index 56aa7126a5b..11efcfb6510 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -204,6 +204,24 @@ async def test_off_delay(hass, rfxtrx, timestep): assert state assert state.state == "off" + await rfxtrx.signal("0b1100100118cdea02010f70") + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "on" + + await timestep(3) + await rfxtrx.signal("0b1100100118cdea02010f70") + + await timestep(4) + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "on" + + await timestep(4) + state = hass.states.get("binary_sensor.ac_118cdea_2") + assert state + assert state.state == "off" + async def test_panic(hass, rfxtrx_automatic): """Test panic entities.""" diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py index 3ebd1bdef39..f6f056fa16a 100644 --- a/tests/components/rfxtrx/test_light.py +++ b/tests/components/rfxtrx/test_light.py @@ -123,6 +123,7 @@ async def test_several_lights(hass, rfxtrx): }, ) await hass.async_block_till_done() + await hass.async_start() state = hass.states.get("light.ac_213c7f2_48") assert state @@ -139,6 +140,22 @@ async def test_several_lights(hass, rfxtrx): assert state.state == "off" assert state.attributes.get("friendly_name") == "AC 1118cdea:2" + await rfxtrx.signal("0b1100cd0213c7f230010f71") + state = hass.states.get("light.ac_213c7f2_48") + assert state + assert state.state == "on" + + await rfxtrx.signal("0b1100cd0213c7f230000f71") + state = hass.states.get("light.ac_213c7f2_48") + assert state + assert state.state == "off" + + await rfxtrx.signal("0b1100cd0213c7f230020f71") + state = hass.states.get("light.ac_213c7f2_48") + assert state + assert state.state == "on" + assert state.attributes.get("brightness") == 255 + @pytest.mark.parametrize("repetitions", [1, 3]) async def test_repetitions(hass, rfxtrx, repetitions): From 607ba08e239d1fcd09de25b60c99587113f2f5fb Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 1 Aug 2020 12:50:04 -0700 Subject: [PATCH 243/362] Add node neighbors to ozw websocket api (#38447) * Add node neighbors to websocket api * Update homeassistant/components/ozw/websocket_api.py Co-authored-by: Charles Garwood * Update tests/components/ozw/test_websocket_api.py Co-authored-by: Charles Garwood Co-authored-by: Charles Garwood --- homeassistant/components/ozw/websocket_api.py | 1 + tests/components/ozw/test_websocket_api.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/ozw/websocket_api.py b/homeassistant/components/ozw/websocket_api.py index 3c11acb90d2..e7c8b047f84 100644 --- a/homeassistant/components/ozw/websocket_api.py +++ b/homeassistant/components/ozw/websocket_api.py @@ -76,6 +76,7 @@ class ZWaveWebsocketApi: "node_basic_string": node.node_basic_string, "node_generic_string": node.node_generic_string, "node_specific_string": node.node_specific_string, + "neighbors": node.neighbors, OZW_INSTANCE: msg[OZW_INSTANCE], }, ) diff --git a/tests/components/ozw/test_websocket_api.py b/tests/components/ozw/test_websocket_api.py index 7067e4ecd72..13ba6f2152c 100644 --- a/tests/components/ozw/test_websocket_api.py +++ b/tests/components/ozw/test_websocket_api.py @@ -37,6 +37,7 @@ async def test_websocket_api(hass, generic_data, hass_ws_client): assert result["node_basic_string"] == "Routing Slave" assert result["node_generic_string"] == "Binary Switch" assert result["node_specific_string"] == "Binary Power Switch" + assert result["neighbors"] == [1, 33, 36, 37, 39] # Test node statistics await client.send_json({ID: 7, TYPE: "ozw/node_statistics", NODE_ID: 39}) From 6b85e23408948626bc89a2f2298327769282f736 Mon Sep 17 00:00:00 2001 From: Oncleben31 Date: Sat, 1 Aug 2020 22:56:00 +0200 Subject: [PATCH 244/362] =?UTF-8?q?Refactor=20M=C3=A9t=C3=A9o-France=20to?= =?UTF-8?q?=20use=20API=20instead=20of=20web=20scraping=20(#37737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new python library * Update requirements * Remove old libs * config flow with client.search_places * WIP: UI config + weather OK * WIP: sensors * WIP: add pressure to weather + available to sensor * WIP: coordinator next_rain + alert * Make import step working * migrate to meteofrance-api v0.0.3 * Create coordinator for rain only if data available in API * Fix avoid creation of rain sensor when not available. * Add options flow for forecast mode * Fix import config causing bug with UI * Add alert sensor * Add coastal alerts when available (#5) * Use meteofrance-api feature branch on Github * Update unit of next_rain sensor * Test different type of attibutes * Typo for attribute * Next rain sensor device class as timestamp * Better design for rain entity attributes * use master branch for meteofrance-api * time displayed in the HA server timezone. * fix bug when next_rain_date_locale is None * Add precipitation and cloud cover sensors * Add variable to avoid repeating computing * Apply suggestions from code review Co-authored-by: Quentame * Attributes names in const. * Cleaning * Cleaning: use current_forecast and today_forecast * Write state to HA after fetch * Refactor, Log messages and bug fix. (#6) * Add messages in log * Refactor using 'current_forecast'. * Use % string format with _LOGGER * Remove inconsistent path * Secure timestamp value and get current day forecast * new unique_id * Change Log message to debug * Log messages improvement * Don't try to create weather alert sensor if not in covered zone. * convert wind speed in km/h * Better list of city in config_flow * Manage initial CONF_MODE as None * Review correction * Review coorections * unique id correction * Migrate from previous config * Make config name detailed * Fix weather alert sensor unload (#7) * Unload weather alert platform * Revert "Unload weather alert platform" This reverts commit 95259fdee84f30a5be915eb1fbb2e19fcddc97e4. * second try in async_unload_entry * Make it work * isort modification * remove weather alert logic in sensor.py * Refactor to avoid too long code lines Co-authored-by: Quentin POLLET * Update config tests to Meteo-France (#18) * Update meteo_france exception name * Update MeteoFranceClient name used in tests * Update 'test_user' * Make test_user works * Add test test_user_list * Make test_import works * Quick & Dirty fix on exception managment. WIP * allow to catch MeteoFranceClient() exceptions * remove test_abort_if_already_setup_district * bump meteofrance-api version * We do not need to test Exception in flow yet * Remove unused data * Change client1 fixture name * Change client2 fixture name * Finish cities step * Test import with multiple choice * refactor places * Add option flow test Co-authored-by: Quentin POLLET * Fix errors due to missing data in the API (#22) * fix case where probability_forecast it not in API * Workaround for probabilty_forecast data null value * Fix weather alert sensor added when shouldn't * Add a partlycloudy and cloudy value options in condition map * Enable snow chance entity * fix from review * remove summary * Other fix from PR review * WIP: error if no results in city search * Add test for config_flow when no result in search * Lint fix * generate en.json * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/sensor.py * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/__init__.py * string: city input --> city field Co-authored-by: Quentin POLLET --- CODEOWNERS | 2 +- .../components/meteo_france/__init__.py | 187 ++++++++---- .../components/meteo_france/config_flow.py | 102 ++++++- .../components/meteo_france/const.py | 149 +++++---- .../components/meteo_france/manifest.json | 4 +- .../components/meteo_france/sensor.py | 289 +++++++++++------- .../components/meteo_france/strings.json | 25 +- .../meteo_france/translations/en.json | 19 ++ .../components/meteo_france/weather.py | 198 ++++++++---- requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/meteo_france/conftest.py | 7 +- .../meteo_france/test_config_flow.py | 206 +++++++++---- 13 files changed, 827 insertions(+), 371 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 10393f2ce17..e69cf26f073 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,7 +243,7 @@ homeassistant/components/mediaroom/* @dgomes homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen -homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame +homeassistant/components/meteo_france/* @hacf-fr @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/metoffice/* @MrHarcombe homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index b7eda51b955..469c66ad79f 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -1,22 +1,31 @@ """Support for Meteo-France weather data.""" import asyncio -import datetime +from datetime import timedelta import logging -from meteofrance.client import meteofranceClient, meteofranceError -from vigilancemeteo import VigilanceMeteoError, VigilanceMeteoFranceProxy +from meteofrance.client import MeteoFranceClient import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, DOMAIN, PLATFORMS +from .const import ( + CONF_CITY, + COORDINATOR_ALERT, + COORDINATOR_FORECAST, + COORDINATOR_RAIN, + DOMAIN, + PLATFORMS, +) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) +SCAN_INTERVAL_RAIN = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=15) CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) @@ -28,15 +37,14 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up Meteo-France from legacy config file.""" - conf = config.get(DOMAIN) - if conf is None: + if not conf: return True for city_conf in conf: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf.copy() + DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf ) ) @@ -47,38 +55,134 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool """Set up an Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # Weather alert - weather_alert_client = VigilanceMeteoFranceProxy() - try: - await hass.async_add_executor_job(weather_alert_client.update_data) - except VigilanceMeteoError as exp: - _LOGGER.error( - "Unexpected error when creating the vigilance_meteoFrance proxy: %s ", exp + latitude = entry.data.get(CONF_LATITUDE) + + client = MeteoFranceClient() + # Migrate from previous config + if not latitude: + places = await hass.async_add_executor_job( + client.search_places, entry.data[CONF_CITY] + ) + hass.config_entries.async_update_entry( + entry, + title=f"{places[0]}", + data={ + CONF_LATITUDE: places[0].latitude, + CONF_LONGITUDE: places[0].longitude, + }, ) - return False - hass.data[DOMAIN]["weather_alert_client"] = weather_alert_client - # Weather - city = entry.data[CONF_CITY] - try: - client = await hass.async_add_executor_job(meteofranceClient, city) - except meteofranceError as exp: - _LOGGER.error("Unexpected error when creating the meteofrance proxy: %s", exp) - return False + latitude = entry.data[CONF_LATITUDE] + longitude = entry.data[CONF_LONGITUDE] - hass.data[DOMAIN][city] = MeteoFranceUpdater(client) - await hass.async_add_executor_job(hass.data[DOMAIN][city].update) + async def _async_update_data_forecast_forecast(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(client.get_forecast, latitude, longitude) + + async def _async_update_data_rain(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(client.get_rain, latitude, longitude) + + async def _async_update_data_alert(): + """Fetch data from API endpoint.""" + return await hass.async_add_job( + client.get_warning_current_phenomenoms, department, 0, True + ) + + coordinator_forecast = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France forecast for city {entry.title}", + update_method=_async_update_data_forecast_forecast, + update_interval=SCAN_INTERVAL, + ) + coordinator_rain = None + coordinator_alert = None + + # Fetch initial data so we have data when entities subscribe + await coordinator_forecast.async_refresh() + + if not coordinator_forecast.last_update_success: + raise ConfigEntryNotReady + + # Check if rain forecast is available. + if coordinator_forecast.data.position.get("rain_product_available") == 1: + coordinator_rain = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France rain for city {entry.title}", + update_method=_async_update_data_rain, + update_interval=SCAN_INTERVAL_RAIN, + ) + await coordinator_rain.async_refresh() + + if not coordinator_rain.last_update_success: + raise ConfigEntryNotReady + else: + _LOGGER.warning( + "1 hour rain forecast not available. %s is not in covered zone", + entry.title, + ) + + department = coordinator_forecast.data.position.get("dept") + _LOGGER.debug( + "Department corresponding to %s is %s", entry.title, department, + ) + if department: + if not hass.data[DOMAIN].get(department): + coordinator_alert = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France alert for department {department}", + update_method=_async_update_data_alert, + update_interval=SCAN_INTERVAL, + ) + + await coordinator_alert.async_refresh() + + if not coordinator_alert.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][department] = True + else: + _LOGGER.warning( + "Weather alert for department %s won't be added with city %s, as it has already been added within another city", + department, + entry.title, + ) + else: + _LOGGER.warning( + "Weather alert not available: The city %s is not in France or Andorre.", + entry.title, + ) + + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR_FORECAST: coordinator_forecast, + COORDINATOR_RAIN: coordinator_rain, + COORDINATOR_ALERT: coordinator_alert, + } for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) ) - _LOGGER.debug("meteo_france sensor platform loaded for %s", city) + return True async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload a config entry.""" + if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]: + + department = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR_FORECAST + ].data.position.get("dept") + hass.data[DOMAIN][department] = False + _LOGGER.debug( + "Weather alert for depatment %s unloaded and released. It can be added now by another city.", + department, + ) + unload_ok = all( await asyncio.gather( *[ @@ -88,29 +192,8 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): ) ) if unload_ok: - hass.data[DOMAIN].pop(entry.data[CONF_CITY]) + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) return unload_ok - - -class MeteoFranceUpdater: - """Update data from Meteo-France.""" - - def __init__(self, client: meteofranceClient): - """Initialize the data object.""" - self._client = client - - def get_data(self): - """Get the latest data from Meteo-France.""" - return self._client.get_data() - - @Throttle(SCAN_INTERVAL) - def update(self): - """Get the latest data from Meteo-France.""" - - try: - self._client.update() - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when updating the meteofrance proxy: %s", exp - ) diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index c7673020360..73b1ea41089 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -1,12 +1,15 @@ """Config flow to configure the Meteo-France integration.""" import logging -from meteofrance.client import meteofranceClient, meteofranceError +from meteofrance.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.core import callback -from .const import CONF_CITY +from .const import CONF_CITY, FORECAST_MODE, FORECAST_MODE_DAILY from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -18,7 +21,13 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def _show_setup_form(self, user_input=None, errors=None): + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MeteoFranceOptionsFlowHandler(config_entry) + + async def _show_setup_form(self, user_input=None, errors=None): """Show the setup form to the user.""" if user_input is None: @@ -37,26 +46,89 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is None: - return self._show_setup_form(user_input, errors) + return await self._show_setup_form(user_input, errors) city = user_input[CONF_CITY] # Might be a city name or a postal code - city_name = None + latitude = user_input.get(CONF_LATITUDE) + longitude = user_input.get(CONF_LONGITUDE) - try: - client = await self.hass.async_add_executor_job(meteofranceClient, city) - city_name = client.get_data()["name"] - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when creating the meteofrance proxy: %s", exp - ) - return self.async_abort(reason="unknown") + if not latitude: + client = MeteoFranceClient() + places = await self.hass.async_add_executor_job(client.search_places, city) + _LOGGER.debug("places search result: %s", places) + if not places: + errors[CONF_CITY] = "empty" + return await self._show_setup_form(user_input, errors) + + return await self.async_step_cities(places=places) # Check if already configured - await self.async_set_unique_id(city_name) + await self.async_set_unique_id(f"{latitude}, {longitude}") self._abort_if_unique_id_configured() - return self.async_create_entry(title=city_name, data={CONF_CITY: city}) + return self.async_create_entry( + title=city, data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + ) async def async_step_import(self, user_input): """Import a config entry.""" return await self.async_step_user(user_input) + + async def async_step_cities(self, user_input=None, places=None): + """Step where the user choose the city from the API search results.""" + if places and len(places) > 1 and self.source != SOURCE_IMPORT: + places_for_form = {} + for place in places: + places_for_form[_build_place_key(place)] = f"{place}" + + return await self._show_cities_form(places_for_form) + # for import and only 1 city in the search result + if places and not user_input: + user_input = {CONF_CITY: _build_place_key(places[0])} + + city_infos = user_input.get(CONF_CITY).split(";") + return await self.async_step_user( + { + CONF_CITY: city_infos[0], + CONF_LATITUDE: city_infos[1], + CONF_LONGITUDE: city_infos[2], + } + ) + + async def _show_cities_form(self, cities): + """Show the form to choose the city.""" + return self.async_show_form( + step_id="cities", + data_schema=vol.Schema( + {vol.Required(CONF_CITY): vol.All(vol.Coerce(str), vol.In(cities))} + ), + ) + + +class MeteoFranceOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_MODE, + default=self.config_entry.options.get( + CONF_MODE, FORECAST_MODE_DAILY + ), + ): vol.In(FORECAST_MODE) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +def _build_place_key(place) -> str: + return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 2edbf980f36..d1decb54078 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,90 +1,127 @@ """Meteo-France component constants.""" from homeassistant.const import ( + PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - TIME_MINUTES, UNIT_PERCENTAGE, ) DOMAIN = "meteo_france" PLATFORMS = ["sensor", "weather"] +COORDINATOR_FORECAST = "coordinator_forecast" +COORDINATOR_RAIN = "coordinator_rain" +COORDINATOR_ALERT = "coordinator_alert" ATTRIBUTION = "Data provided by Météo-France" CONF_CITY = "city" +FORECAST_MODE_HOURLY = "hourly" +FORECAST_MODE_DAILY = "daily" +FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] -DEFAULT_WEATHER_CARD = True +ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" + +ENTITY_NAME = "name" +ENTITY_UNIT = "unit" +ENTITY_ICON = "icon" +ENTITY_CLASS = "device_class" +ENTITY_ENABLE = "enable" +ENTITY_API_DATA_PATH = "data_path" -SENSOR_TYPE_NAME = "name" -SENSOR_TYPE_UNIT = "unit" -SENSOR_TYPE_ICON = "icon" -SENSOR_TYPE_CLASS = "device_class" SENSOR_TYPES = { + "pressure": { + ENTITY_NAME: "Pressure", + ENTITY_UNIT: PRESSURE_HPA, + ENTITY_ICON: "mdi:gauge", + ENTITY_CLASS: "pressure", + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:sea_level", + }, "rain_chance": { - SENSOR_TYPE_NAME: "Rain chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-rainy", - SENSOR_TYPE_CLASS: None, - }, - "freeze_chance": { - SENSOR_TYPE_NAME: "Freeze chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:snowflake", - SENSOR_TYPE_CLASS: None, - }, - "thunder_chance": { - SENSOR_TYPE_NAME: "Thunder chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-lightning", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Rain chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-rainy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:rain:3h", }, "snow_chance": { - SENSOR_TYPE_NAME: "Snow chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-snowy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Snow chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-snowy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:snow:3h", }, - "weather": { - SENSOR_TYPE_NAME: "Weather", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:weather-partly-cloudy", - SENSOR_TYPE_CLASS: None, + "freeze_chance": { + ENTITY_NAME: "Freeze chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:snowflake", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:freezing", }, "wind_speed": { - SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Wind speed", + ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, + ENTITY_ICON: "mdi:weather-windy", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:wind:speed", }, "next_rain": { - SENSOR_TYPE_NAME: "Next rain", - SENSOR_TYPE_UNIT: TIME_MINUTES, - SENSOR_TYPE_ICON: "mdi:weather-rainy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Next rain", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:weather-pouring", + ENTITY_CLASS: "timestamp", + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: None, }, "temperature": { - SENSOR_TYPE_NAME: "Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_ICON: "mdi:thermometer", - SENSOR_TYPE_CLASS: "temperature", + ENTITY_NAME: "Temperature", + ENTITY_UNIT: TEMP_CELSIUS, + ENTITY_ICON: "mdi:thermometer", + ENTITY_CLASS: "temperature", + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:T:value", }, "uv": { - SENSOR_TYPE_NAME: "UV", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:sunglasses", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "UV", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:sunglasses", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "today_forecast:uv", }, "weather_alert": { - SENSOR_TYPE_NAME: "Weather Alert", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:weather-cloudy-alert", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Weather alert", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:weather-cloudy-alert", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: None, + }, + "precipitation": { + ENTITY_NAME: "Daily precipitation", + ENTITY_UNIT: "mm", + ENTITY_ICON: "mdi:cup-water", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "today_forecast:precipitation:24h", + }, + "cloud": { + ENTITY_NAME: "Cloud cover", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-partly-cloudy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "current_forecast:clouds", }, } CONDITION_CLASSES = { "clear-night": ["Nuit Claire", "Nuit claire"], - "cloudy": ["Très nuageux"], + "cloudy": ["Très nuageux", "Couvert"], "fog": [ "Brume ou bancs de brouillard", "Brume", @@ -94,7 +131,13 @@ CONDITION_CLASSES = { "hail": ["Risque de grêle"], "lightning": ["Risque d'orages", "Orages"], "lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], - "partlycloudy": ["Ciel voilé", "Ciel voilé nuit", "Éclaircies"], + "partlycloudy": [ + "Ciel voilé", + "Ciel voilé nuit", + "Éclaircies", + "Eclaircies", + "Peu nuageux", + ], "pouring": ["Pluie forte"], "rainy": [ "Bruine / Pluie faible", diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 5f12037e011..cd6f09246a6 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,6 +3,6 @@ "name": "Météo-France", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", - "requirements": ["meteofrance==0.3.7", "vigilancemeteo==3.0.1"], - "codeowners": ["@victorcerutti", "@oncleben31", "@Quentame"] + "requirements": ["meteofrance-api==0.1.0"], + "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index cf28b9ea558..39e33dafd65 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,168 +1,231 @@ """Support for Meteo-France raining forecast sensor.""" import logging -from meteofrance.client import meteofranceClient -from vigilancemeteo import DepartmentWeatherAlert, VigilanceMeteoFranceProxy +from meteofrance.helpers import ( + get_warning_text_status_from_indice_color, + readeable_phenomenoms_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from .const import ( + ATTR_NEXT_RAIN_1_HOUR_FORECAST, ATTRIBUTION, - CONF_CITY, + COORDINATOR_ALERT, + COORDINATOR_FORECAST, + COORDINATOR_RAIN, DOMAIN, - SENSOR_TYPE_CLASS, - SENSOR_TYPE_ICON, - SENSOR_TYPE_NAME, - SENSOR_TYPE_UNIT, + ENTITY_API_DATA_PATH, + ENTITY_CLASS, + ENTITY_ENABLE, + ENTITY_ICON, + ENTITY_NAME, + ENTITY_UNIT, SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -STATE_ATTR_FORECAST = "1h rain forecast" -STATE_ATTR_BULLETIN_TIME = "Bulletin date" - async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteo-France sensor platform.""" - city = entry.data[CONF_CITY] - client = hass.data[DOMAIN][city] - weather_alert_client = hass.data[DOMAIN]["weather_alert_client"] + coordinator_forecast = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST] + coordinator_rain = hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] + coordinator_alert = hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] - alert_watcher = None - datas = client.get_data() - # Check if a department code is available for this city. - if "dept" in datas: - try: - # If yes create the watcher DepartmentWeatherAlert object. - alert_watcher = await hass.async_add_executor_job( - DepartmentWeatherAlert, datas["dept"], weather_alert_client - ) - _LOGGER.info( - "Weather alert watcher added for %s in department %s", - city, - datas["dept"], - ) - except ValueError as exp: - _LOGGER.error( - "Unexpected error when creating the weather alert sensor for %s in department %s: %s", - city, - datas["dept"], - exp, - ) - else: - _LOGGER.warning( - "No 'dept' key found for '%s'. So weather alert information won't be available", - city, - ) - # Exit and don't create the sensor if no department code available. - return + entities = [] + for sensor_type in SENSOR_TYPES: + if sensor_type == "next_rain": + if coordinator_rain: + entities.append(MeteoFranceRainSensor(sensor_type, coordinator_rain)) + + elif sensor_type == "weather_alert": + if coordinator_alert: + entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) + + elif sensor_type in ["rain_chance", "freeze_chance", "snow_chance"]: + if coordinator_forecast.data.probability_forecast: + entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) + else: + _LOGGER.warning( + "Sensor %s skipped for %s as data is missing in the API", + sensor_type, + coordinator_forecast.data.position["name"], + ) + + else: + entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) async_add_entities( - [ - MeteoFranceSensor(sensor_type, client, alert_watcher) - for sensor_type in SENSOR_TYPES - ], - True, + entities, False, ) class MeteoFranceSensor(Entity): """Representation of a Meteo-France sensor.""" - def __init__( - self, - sensor_type: str, - client: meteofranceClient, - alert_watcher: VigilanceMeteoFranceProxy, - ): + def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator): """Initialize the Meteo-France sensor.""" self._type = sensor_type - self._client = client - self._alert_watcher = alert_watcher - self._state = None - self._data = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data['name']} {SENSOR_TYPES[self._type][SENSOR_TYPE_NAME]}" + self.coordinator = coordinator + city_name = self.coordinator.data.position["name"] + self._name = f"{city_name} {SENSOR_TYPES[self._type][ENTITY_NAME]}" + self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}_{self._type}" @property def unique_id(self): - """Return the unique id of the sensor.""" - return self.name + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name.""" + return self._name @property def state(self): - """Return the state of the sensor.""" - return self._state + """Return the state.""" + path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") + data = getattr(self.coordinator.data, path[0]) - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - # Attributes for next_rain sensor. - if self._type == "next_rain" and "rain_forecast" in self._data: - return { - **{STATE_ATTR_FORECAST: self._data["rain_forecast"]}, - **self._data["next_rain_intervals"], - **{ATTR_ATTRIBUTION: ATTRIBUTION}, - } + # Specific case for probability forecast + if path[0] == "probability_forecast": + if len(path) == 3: + # This is a fix compared to other entitty as first index is always null in API result for unknown reason + value = _find_first_probability_forecast_not_null(data, path) + else: + value = data[0][path[1]] - # Attributes for weather_alert sensor. - if self._type == "weather_alert" and self._alert_watcher is not None: - return { - **{STATE_ATTR_BULLETIN_TIME: self._alert_watcher.bulletin_date}, - **self._alert_watcher.alerts_list, - ATTR_ATTRIBUTION: ATTRIBUTION, - } + # General case + else: + if len(path) == 3: + value = data[path[1]][path[2]] + else: + value = data[path[1]] - # Attributes for all other sensors. - return {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._type == "wind_speed": + # convert API wind speed from m/s to km/h + value = round(value * 3.6) + return value @property def unit_of_measurement(self): """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_UNIT] + return SENSOR_TYPES[self._type][ENTITY_UNIT] @property def icon(self): """Return the icon.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_ICON] + return SENSOR_TYPES[self._type][ENTITY_ICON] @property def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_CLASS] + """Return the device class.""" + return SENSOR_TYPES[self._type][ENTITY_CLASS] - def update(self): - """Fetch new state data for the sensor.""" - try: - self._client.update() - self._data = self._client.get_data() + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return SENSOR_TYPES[self._type][ENTITY_ENABLE] - if self._type == "weather_alert": - if self._alert_watcher is not None: - self._alert_watcher.update_department_status() - self._state = self._alert_watcher.department_color - _LOGGER.debug( - "weather alert watcher for %s updated. Proxy have the status: %s", - self._data["name"], - self._alert_watcher.proxy.status, - ) - else: - _LOGGER.warning( - "No weather alert data for location %s", self._data["name"] - ) - else: - self._state = self._data[self._type] - except KeyError: - _LOGGER.error( - "No condition %s for location %s", self._type, self._data["name"] - ) - self._state = None + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def available(self): + """Return if state is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + +class MeteoFranceRainSensor(MeteoFranceSensor): + """Representation of a Meteo-France rain sensor.""" + + @property + def state(self): + """Return the state.""" + next_rain_date_locale = self.coordinator.data.next_rain_date_locale() + return ( + dt_util.as_local(next_rain_date_locale) if next_rain_date_locale else None + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_NEXT_RAIN_1_HOUR_FORECAST: [ + { + dt_util.as_local( + self.coordinator.data.timestamp_to_locale_time(item["dt"]) + ).strftime("%H:%M"): item["desc"] + } + for item in self.coordinator.data.forecast + ], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +class MeteoFranceAlertSensor(MeteoFranceSensor): + """Representation of a Meteo-France alert sensor.""" + + # pylint: disable=super-init-not-called + def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator): + """Initialize the Meteo-France sensor.""" + self._type = sensor_type + self.coordinator = coordinator + dept_code = self.coordinator.data.domain_id + self._name = f"{dept_code} {SENSOR_TYPES[self._type][ENTITY_NAME]}" + self._unique_id = self._name + + @property + def state(self): + """Return the state.""" + return get_warning_text_status_from_indice_color( + self.coordinator.data.get_domain_max_color() + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +def _find_first_probability_forecast_not_null( + probability_forecast: list, path: list +) -> int: + """Search the first not None value in the first forecast elements.""" + for forecast in probability_forecast[0:3]: + if forecast[path[1]][path[2]] is not None: + return forecast[path[1]][path[2]] + + # Default return value if no value founded + return None diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json index fc6e426b8d4..611d1ca054c 100644 --- a/homeassistant/components/meteo_france/strings.json +++ b/homeassistant/components/meteo_france/strings.json @@ -4,12 +4,33 @@ "user": { "title": "M\u00e9t\u00e9o-France", "description": "Enter the postal code (only for France, recommended) or city name", - "data": { "city": "City" } + "data": { + "city": "City" + } + }, + "cities": { + "title": "M\u00e9t\u00e9o-France", + "description": "Choose your city from the list", + "data": { + "city": "City" + } } }, + "error": { + "empty": "No result in city search: please check the city field" + }, "abort": { "already_configured": "City already configured", "unknown": "Unknown error: please retry later" } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Forecast mode" + } + } + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/en.json b/homeassistant/components/meteo_france/translations/en.json index 7b161dcda07..979f705cc5b 100644 --- a/homeassistant/components/meteo_france/translations/en.json +++ b/homeassistant/components/meteo_france/translations/en.json @@ -4,7 +4,17 @@ "already_configured": "City already configured", "unknown": "Unknown error: please retry later" }, + "error": { + "empty": "No result in city search: please check the city field" + }, "step": { + "cities": { + "data": { + "city": "City" + }, + "description": "Choose your city from the list", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "City" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Forecast mode" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 2983c6b7d59..a9c4840901b 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -1,88 +1,172 @@ """Support for Meteo-France weather service.""" -from datetime import timedelta import logging - -from meteofrance.client import meteofranceClient +import time from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import CONF_MODE, TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DOMAIN +from .const import ( + ATTRIBUTION, + CONDITION_CLASSES, + COORDINATOR_FORECAST, + DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +) _LOGGER = logging.getLogger(__name__) +def format_condition(condition: str): + """Return condition from dict CONDITION_CLASSES.""" + for key, value in CONDITION_CLASSES.items(): + if condition in value: + return key + return condition + + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteo-France weather platform.""" - city = entry.data[CONF_CITY] - client = hass.data[DOMAIN][city] + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST] - async_add_entities([MeteoFranceWeather(client)], True) + async_add_entities( + [ + MeteoFranceWeather( + coordinator, entry.options.get(CONF_MODE, FORECAST_MODE_DAILY), + ) + ], + True, + ) + _LOGGER.debug( + "Weather entity (%s) added for %s.", + entry.options.get(CONF_MODE, FORECAST_MODE_DAILY), + coordinator.data.position["name"], + ) class MeteoFranceWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, client: meteofranceClient): + def __init__(self, coordinator: DataUpdateCoordinator, mode: str): """Initialise the platform with a data instance and station name.""" - self._client = client - self._data = {} - - def update(self): - """Update current conditions.""" - self._client.update() - self._data = self._client.get_data() - - @property - def name(self): - """Return the name of the sensor.""" - return self._data["name"] + self.coordinator = coordinator + self._city_name = self.coordinator.data.position["name"] + self._mode = mode + self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" @property def unique_id(self): """Return the unique id of the sensor.""" - return self.name + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._city_name @property def condition(self): """Return the current condition.""" - return self.format_condition(self._data["weather"]) + return format_condition( + self.coordinator.data.current_forecast["weather"]["desc"] + ) @property def temperature(self): """Return the temperature.""" - return self._data["temperature"] - - @property - def humidity(self): - """Return the humidity.""" - return None + return self.coordinator.data.current_forecast["T"]["value"] @property def temperature_unit(self): """Return the unit of measurement.""" return TEMP_CELSIUS + @property + def pressure(self): + """Return the pressure.""" + return self.coordinator.data.current_forecast["sea_level"] + + @property + def humidity(self): + """Return the humidity.""" + return self.coordinator.data.current_forecast["humidity"] + @property def wind_speed(self): """Return the wind speed.""" - return self._data["wind_speed"] + # convert from API m/s to km/h + return round(self.coordinator.data.current_forecast["wind"]["speed"] * 3.6) @property def wind_bearing(self): """Return the wind bearing.""" - return self._data["wind_bearing"] + wind_bearing = self.coordinator.data.current_forecast["wind"]["direction"] + if wind_bearing != -1: + return wind_bearing + + @property + def forecast(self): + """Return the forecast.""" + forecast_data = [] + + if self._mode == FORECAST_MODE_HOURLY: + today = time.time() + for forecast in self.coordinator.data.forecast: + # Can have data in the past + if forecast["dt"] < today: + _LOGGER.debug( + "remove forecast in the past: %s %s", self._mode, forecast + ) + continue + forecast_data.append( + { + ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + forecast["dt"] + ), + ATTR_FORECAST_CONDITION: format_condition( + forecast["weather"]["desc"] + ), + ATTR_FORECAST_TEMP: forecast["T"]["value"], + ATTR_FORECAST_PRECIPITATION: forecast["rain"].get("1h"), + ATTR_FORECAST_WIND_SPEED: forecast["wind"]["speed"], + ATTR_FORECAST_WIND_BEARING: forecast["wind"]["direction"] + if forecast["wind"]["direction"] != -1 + else None, + } + ) + else: + for forecast in self.coordinator.data.daily_forecast: + # stop when we don't have a weather condition (can happen around last days of forcast, max 14) + if not forecast.get("weather12H"): + break + forecast_data.append( + { + ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + forecast["dt"] + ), + ATTR_FORECAST_CONDITION: format_condition( + forecast["weather12H"]["desc"] + ), + ATTR_FORECAST_TEMP: forecast["T"]["max"], + ATTR_FORECAST_TEMP_LOW: forecast["T"]["min"], + ATTR_FORECAST_PRECIPITATION: forecast["precipitation"]["24h"], + } + ) + return forecast_data @property def attribution(self): @@ -90,36 +174,24 @@ class MeteoFranceWeather(WeatherEntity): return ATTRIBUTION @property - def forecast(self): - """Return the forecast.""" - reftime = dt_util.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) - reftime += timedelta(hours=24) - _LOGGER.debug("reftime used for %s forecast: %s", self._data["name"], reftime) - forecast_data = [] - for key in self._data["forecast"]: - value = self._data["forecast"][key] - data_dict = { - ATTR_FORECAST_TIME: reftime.isoformat(), - ATTR_FORECAST_TEMP: int(value["max_temp"]), - ATTR_FORECAST_TEMP_LOW: int(value["min_temp"]), - ATTR_FORECAST_CONDITION: self.format_condition(value["weather"]), - } - reftime = reftime + timedelta(hours=24) - forecast_data.append(data_dict) - return forecast_data - - @staticmethod - def format_condition(condition): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key - return condition + def available(self): + """Return if state is available.""" + return self.coordinator.last_update_success @property - def device_state_attributes(self): - """Return the state attributes.""" - data = {} - if self._data and "next_rain" in self._data: - data["next_rain"] = self._data["next_rain"] - return data + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) diff --git a/requirements_all.txt b/requirements_all.txt index 6e5d32d25a0..1b24a561380 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -902,7 +902,7 @@ messagebird==1.2.0 meteoalertapi==0.1.6 # homeassistant.components.meteo_france -meteofrance==0.3.7 +meteofrance-api==0.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -2170,9 +2170,6 @@ vallox-websocket-api==2.4.0 # homeassistant.components.venstar venstarcolortouch==0.12 -# homeassistant.components.meteo_france -vigilancemeteo==3.0.1 - # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc43253bc2a..7030b066848 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -421,7 +421,7 @@ mbddns==0.1.2 mcstatus==2.3.0 # homeassistant.components.meteo_france -meteofrance==0.3.7 +meteofrance-api==0.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -960,9 +960,6 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 -# homeassistant.components.meteo_france -vigilancemeteo==3.0.1 - # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index 75c294775ed..06a65b6ba87 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -7,10 +7,7 @@ from tests.async_mock import patch @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteo_france.meteofranceClient") - patch_weather_alert = patch( - "homeassistant.components.meteo_france.VigilanceMeteoFranceProxy" - ) + patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient") - with patch_client, patch_weather_alert: + with patch_client: yield diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index 8a5c734a0ed..650a88df84e 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -1,29 +1,82 @@ """Tests for the Meteo-France config flow.""" -from meteofrance.client import meteofranceError +from meteofrance.model import Place import pytest from homeassistant import data_entry_flow -from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN +from homeassistant.components.meteo_france.const import ( + CONF_CITY, + DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.helpers.typing import HomeAssistantType from tests.async_mock import patch from tests.common import MockConfigEntry CITY_1_POSTAL = "74220" CITY_1_NAME = "La Clusaz" -CITY_2_POSTAL_DISTRICT_1 = "69001" -CITY_2_POSTAL_DISTRICT_4 = "69004" -CITY_2_NAME = "Lyon" +CITY_1_LAT = 45.90417 +CITY_1_LON = 6.42306 +CITY_1_COUNTRY = "FR" +CITY_1_ADMIN = "Rhône-Alpes" +CITY_1_ADMIN2 = "74" +CITY_1 = Place( + { + "name": CITY_1_NAME, + "lat": CITY_1_LAT, + "lon": CITY_1_LON, + "country": CITY_1_COUNTRY, + "admin": CITY_1_ADMIN, + "admin2": CITY_1_ADMIN2, + } +) + +CITY_2_NAME = "Auch" +CITY_2_LAT = 43.64528 +CITY_2_LON = 0.58861 +CITY_2_COUNTRY = "FR" +CITY_2_ADMIN = "Midi-Pyrénées" +CITY_2_ADMIN2 = "32" +CITY_2 = Place( + { + "name": CITY_2_NAME, + "lat": CITY_2_LAT, + "lon": CITY_2_LON, + "country": CITY_2_COUNTRY, + "admin": CITY_2_ADMIN, + "admin2": CITY_2_ADMIN2, + } +) + +CITY_3_NAME = "Auchel" +CITY_3_LAT = 50.50833 +CITY_3_LON = 2.47361 +CITY_3_COUNTRY = "FR" +CITY_3_ADMIN = "Nord-Pas-de-Calais" +CITY_3_ADMIN2 = "62" +CITY_3 = Place( + { + "name": CITY_3_NAME, + "lat": CITY_3_LAT, + "lon": CITY_3_LON, + "country": CITY_3_COUNTRY, + "admin": CITY_3_ADMIN, + "admin2": CITY_3_ADMIN2, + } +) -@pytest.fixture(name="client_1") -def mock_controller_client_1(): +@pytest.fixture(name="client_single") +def mock_controller_client_single(): """Mock a successful client.""" with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", update=False, ) as service_mock: - service_mock.return_value.get_data.return_value = {"name": CITY_1_NAME} + service_mock.return_value.search_places.return_value = [CITY_1] yield service_mock @@ -38,18 +91,29 @@ def mock_setup(): yield -@pytest.fixture(name="client_2") -def mock_controller_client_2(): +@pytest.fixture(name="client_multiple") +def mock_controller_client_multiple(): """Mock a successful client.""" with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", update=False, ) as service_mock: - service_mock.return_value.get_data.return_value = {"name": CITY_2_NAME} + service_mock.return_value.search_places.return_value = [CITY_2, CITY_3] yield service_mock -async def test_user(hass, client_1): +@pytest.fixture(name="client_empty") +def mock_controller_client_empty(): + """Mock a successful client.""" + with patch( + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", + update=False, + ) as service_mock: + service_mock.return_value.search_places.return_value = [] + yield service_mock + + +async def test_user(hass, client_single): """Test user config.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -57,32 +121,67 @@ async def test_user(hass, client_1): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # test with all provided + # test with all provided with search returning only 1 place result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == CITY_1_NAME - assert result["title"] == CITY_1_NAME - assert result["data"][CONF_CITY] == CITY_1_POSTAL + assert result["result"].unique_id == f"{CITY_1_LAT}, {CITY_1_LON}" + assert result["title"] == f"{CITY_1}" + assert result["data"][CONF_LATITUDE] == str(CITY_1_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_1_LON) -async def test_import(hass, client_1): +async def test_user_list(hass, client_multiple): + """Test user config.""" + + # test with all provided with search returning more than 1 place + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_2_NAME}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "cities" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CITY: f"{CITY_3};{CITY_3_LAT};{CITY_3_LON}"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == f"{CITY_3_LAT}, {CITY_3_LON}" + assert result["title"] == f"{CITY_3}" + assert result["data"][CONF_LATITUDE] == str(CITY_3_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_3_LON) + + +async def test_import(hass, client_multiple): """Test import step.""" # import with all result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_1_POSTAL}, + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_2_NAME}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == CITY_1_NAME - assert result["title"] == CITY_1_NAME - assert result["data"][CONF_CITY] == CITY_1_POSTAL + assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}" + assert result["title"] == f"{CITY_2}" + assert result["data"][CONF_LATITUDE] == str(CITY_2_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_2_LON) -async def test_abort_if_already_setup(hass, client_1): +async def test_search_failed(hass, client_empty): + """Test error displayed if no result in search.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_CITY: "empty"} + + +async def test_abort_if_already_setup(hass, client_single): """Test we abort if already setup.""" MockConfigEntry( - domain=DOMAIN, data={CONF_CITY: CITY_1_POSTAL}, unique_id=CITY_1_NAME + domain=DOMAIN, + data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, + unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ).add_to_hass(hass) # Should fail, same CITY same postal code (import) @@ -100,39 +199,32 @@ async def test_abort_if_already_setup(hass, client_1): assert result["reason"] == "already_configured" -async def test_abort_if_already_setup_district(hass, client_2): - """Test we abort if already setup.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_CITY: CITY_2_POSTAL_DISTRICT_1}, unique_id=CITY_2_NAME - ).add_to_hass(hass) - - # Should fail, same CITY different postal code (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, +async def test_options_flow(hass: HomeAssistantType): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, + unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + config_entry.add_to_hass(hass) - # Should fail, same CITY different postal code (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, + assert config_entry.options == {} + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY - -async def test_client_failed(hass): - """Test when we have errors during client fetch.""" - with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", - side_effect=meteofranceError(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "unknown" + # Manual + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_MODE: FORECAST_MODE_HOURLY}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY From f09a9abc1c93533ede60681f23207de6dc871cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Sun, 2 Aug 2020 00:45:55 +0200 Subject: [PATCH 245/362] Add optional unique_id attribute to the template platforms (#38011) * Add unique_id to template platforms * Update test_binary_sensor.py * Update test_binary_sensor.py --- .../template/alarm_control_panel.py | 11 +++++ .../components/template/binary_sensor.py | 11 +++++ homeassistant/components/template/cover.py | 11 +++++ homeassistant/components/template/fan.py | 12 +++++ homeassistant/components/template/light.py | 11 +++++ homeassistant/components/template/lock.py | 10 +++++ homeassistant/components/template/sensor.py | 11 +++++ homeassistant/components/template/switch.py | 11 +++++ homeassistant/components/template/vacuum.py | 12 +++++ homeassistant/const.py | 1 + .../template/test_alarm_control_panel.py | 29 ++++++++++++ .../components/template/test_binary_sensor.py | 31 +++++++++++++ tests/components/template/test_cover.py | 45 +++++++++++++++++++ tests/components/template/test_fan.py | 45 +++++++++++++++++++ tests/components/template/test_light.py | 43 ++++++++++++++++++ tests/components/template/test_lock.py | 45 +++++++++++++++++++ tests/components/template/test_sensor.py | 29 ++++++++++++ tests/components/template/test_switch.py | 45 +++++++++++++++++++ tests/components/template/test_vacuum.py | 31 +++++++++++++ 19 files changed, 444 insertions(+) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index b7ad219eff7..4209388ae8a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -17,6 +17,7 @@ from homeassistant.components.alarm_control_panel.const import ( from homeassistant.const import ( ATTR_CODE, CONF_NAME, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -62,6 +63,7 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -86,6 +88,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= arm_home_action = device_config.get(CONF_ARM_HOME_ACTION) arm_night_action = device_config.get(CONF_ARM_NIGHT_ACTION) code_arm_required = device_config[CONF_CODE_ARM_REQUIRED] + unique_id = device_config.get(CONF_UNIQUE_ID) template_entity_ids = set() @@ -111,6 +114,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= arm_night_action, code_arm_required, template_entity_ids, + unique_id, ) ) @@ -132,6 +136,7 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity): arm_night_action, code_arm_required, template_entity_ids, + unique_id, ): """Initialize the panel.""" self.hass = hass @@ -156,6 +161,7 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity): self._state = None self._entities = template_entity_ids + self._unique_id = unique_id if self._template is not None: self._template.hass = self.hass @@ -165,6 +171,11 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity): """Return the display name of this alarm control panel.""" return self._name + @property + def unique_id(self): + """Return the unique id of this alarm control panel.""" + return self._unique_id + @property def should_poll(self): """Return the polling state.""" diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 101651fabd5..22eb8b9d242 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SENSORS, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -50,6 +51,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_DELAY_ON): vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_DELAY_OFF): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -73,6 +75,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device_class = device_config.get(CONF_DEVICE_CLASS) delay_on = device_config.get(CONF_DELAY_ON) delay_off = device_config.get(CONF_DELAY_OFF) + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: value_template, @@ -104,6 +107,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= delay_on, delay_off, attribute_templates, + unique_id, ) ) @@ -127,6 +131,7 @@ class BinarySensorTemplate(BinarySensorEntity): delay_on, delay_off, attribute_templates, + unique_id, ): """Initialize the Template binary sensor.""" self.hass = hass @@ -146,6 +151,7 @@ class BinarySensorTemplate(BinarySensorEntity): self._available = True self._attribute_templates = attribute_templates self._attributes = {} + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -175,6 +181,11 @@ class BinarySensorTemplate(BinarySensorEntity): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the unique id of this binary sensor.""" + return self._unique_id + @property def icon(self): """Return the icon to use in the frontend, if any.""" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e2f67acf2bd..08dd18ae3a4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ICON_TEMPLATE, CONF_OPTIMISTIC, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -90,6 +91,7 @@ COVER_SCHEMA = vol.All( vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), @@ -121,6 +123,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= tilt_action = device_config.get(TILT_ACTION) optimistic = device_config.get(CONF_OPTIMISTIC) tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -156,6 +159,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= optimistic, tilt_optimistic, entity_ids, + unique_id, ) ) @@ -185,6 +189,7 @@ class CoverTemplate(CoverEntity): optimistic, tilt_optimistic, entity_ids, + unique_id, ): """Initialize the Template cover.""" self.hass = hass @@ -222,6 +227,7 @@ class CoverTemplate(CoverEntity): self._tilt_value = None self._entities = entity_ids self._available = True + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -251,6 +257,11 @@ class CoverTemplate(CoverEntity): """Return the name of the cover.""" return self._name + @property + def unique_id(self): + """Return the unique id of this cover.""" + return self._unique_id + @property def is_closed(self): """Return if the cover is closed.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 1f5c433bf89..a6a0f6b8135 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -21,6 +21,7 @@ from homeassistant.components.fan import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -72,6 +73,7 @@ FAN_SCHEMA = vol.Schema( CONF_SPEED_LIST, default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] ): cv.ensure_list, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -100,6 +102,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) speed_list = device_config[CONF_SPEED_LIST] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -129,6 +132,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= set_direction_action, speed_list, entity_ids, + unique_id, ) ) @@ -155,6 +159,7 @@ class TemplateFan(FanEntity): set_direction_action, speed_list, entity_ids, + unique_id, ): """Initialize the fan.""" self.hass = hass @@ -199,6 +204,8 @@ class TemplateFan(FanEntity): self._supported_features |= SUPPORT_DIRECTION self._entities = entity_ids + self._unique_id = unique_id + # List of valid speeds self._speed_list = speed_list @@ -207,6 +214,11 @@ class TemplateFan(FanEntity): """Return the display name of this fan.""" return self._name + @property + def unique_id(self): + """Return the unique id of this fan.""" + return self._unique_id + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 6832ca04017..b85aa6f3a95 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_ICON_TEMPLATE, CONF_LIGHTS, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -70,6 +71,7 @@ LIGHT_SCHEMA = vol.Schema( vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_WHITE_VALUE_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -89,6 +91,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= icon_template = device_config.get(CONF_ICON_TEMPLATE) entity_picture_template = device_config.get(CONF_ENTITY_PICTURE_TEMPLATE) availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) + unique_id = device_config.get(CONF_UNIQUE_ID) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] @@ -141,6 +144,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= color_template, white_value_action, white_value_template, + unique_id, ) ) @@ -170,6 +174,7 @@ class LightTemplate(LightEntity): color_template, white_value_action, white_value_template, + unique_id, ): """Initialize the light.""" self.hass = hass @@ -209,6 +214,7 @@ class LightTemplate(LightEntity): self._white_value = None self._entities = entity_ids self._available = True + self._unique_id = unique_id @property def brightness(self): @@ -235,6 +241,11 @@ class LightTemplate(LightEntity): """Return the display name of this light.""" return self._name + @property + def unique_id(self): + """Return the unique id of this light.""" + return self._unique_id + @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 0d8cdd7d290..07aeda70be1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -7,6 +7,7 @@ from homeassistant.components.lock import PLATFORM_SCHEMA, LockEntity from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -38,6 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -67,6 +69,7 @@ async def async_setup_platform(hass, config, async_add_devices, discovery_info=N config.get(CONF_LOCK), config.get(CONF_UNLOCK), config.get(CONF_OPTIMISTIC), + config.get(CONF_UNIQUE_ID), ) ] ) @@ -85,6 +88,7 @@ class TemplateLock(LockEntity): command_lock, command_unlock, optimistic, + unique_id, ): """Initialize the lock.""" self._state = None @@ -97,6 +101,7 @@ class TemplateLock(LockEntity): self._command_unlock = Script(hass, command_unlock) self._optimistic = optimistic self._available = True + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -135,6 +140,11 @@ class TemplateLock(LockEntity): """Return the name of the lock.""" return self._name + @property + def unique_id(self): + """Return the unique id of this lock.""" + return self._unique_id + @property def is_locked(self): """Return true if lock is locked.""" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index d4977d626ca..53736050ed3 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SENSORS, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -49,6 +50,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -71,6 +73,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT) device_class = device_config.get(CONF_DEVICE_CLASS) attribute_templates = device_config[CONF_ATTRIBUTE_TEMPLATES] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -103,6 +106,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_ids, device_class, attribute_templates, + unique_id, ) ) @@ -128,6 +132,7 @@ class SensorTemplate(Entity): entity_ids, device_class, attribute_templates, + unique_id, ): """Initialize the sensor.""" self.hass = hass @@ -149,6 +154,7 @@ class SensorTemplate(Entity): self._available = True self._attribute_templates = attribute_templates self._attributes = {} + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -178,6 +184,11 @@ class SensorTemplate(Entity): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the unique id of this sensor.""" + return self._unique_id + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 124d12d194f..f9b07fa1dec 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_SWITCHES, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -47,6 +48,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -67,6 +69,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= availability_template = device_config.get(CONF_AVAILABILITY_TEMPLATE) on_action = device_config[ON_ACTION] off_action = device_config[OFF_ACTION] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -92,6 +95,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= on_action, off_action, entity_ids, + unique_id, ) ) @@ -113,6 +117,7 @@ class SwitchTemplate(SwitchEntity, RestoreEntity): on_action, off_action, entity_ids, + unique_id, ): """Initialize the Template switch.""" self.hass = hass @@ -131,6 +136,7 @@ class SwitchTemplate(SwitchEntity, RestoreEntity): self._entity_picture = None self._entities = entity_ids self._available = True + self._unique_id = unique_id async def async_added_to_hass(self): """Register callbacks.""" @@ -172,6 +178,11 @@ class SwitchTemplate(SwitchEntity, RestoreEntity): """Return the name of the switch.""" return self._name + @property + def unique_id(self): + """Return the unique id of this switch.""" + return self._unique_id + @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1209e617a7e..a61a1690e5a 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -33,6 +33,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_START, MATCH_ALL, @@ -84,6 +85,7 @@ VACUUM_SCHEMA = vol.Schema( vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -114,6 +116,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= set_fan_speed_action = device_config.get(SERVICE_SET_FAN_SPEED) fan_speed_list = device_config[CONF_FAN_SPEED_LIST] + unique_id = device_config.get(CONF_UNIQUE_ID) templates = { CONF_VALUE_TEMPLATE: state_template, @@ -146,6 +149,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= fan_speed_list, entity_ids, attribute_templates, + unique_id, ) ) @@ -174,6 +178,7 @@ class TemplateVacuum(StateVacuumEntity): fan_speed_list, entity_ids, attribute_templates, + unique_id, ): """Initialize the vacuum.""" self.hass = hass @@ -233,6 +238,8 @@ class TemplateVacuum(StateVacuumEntity): self._supported_features |= SUPPORT_BATTERY self._entities = entity_ids + self._unique_id = unique_id + # List of valid fan speeds self._fan_speed_list = fan_speed_list @@ -241,6 +248,11 @@ class TemplateVacuum(StateVacuumEntity): """Return the display name of this vacuum.""" return self._name + @property + def unique_id(self): + """Return the unique id of this vacuum.""" + return self._unique_id + @property def supported_features(self) -> int: """Flag supported features.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 22e24e69c25..cedfced2f7c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -171,6 +171,7 @@ CONF_TOKEN = "token" CONF_TRIGGER_TIME = "trigger_time" CONF_TTL = "ttl" CONF_TYPE = "type" +CONF_UNIQUE_ID = "unique_id" CONF_UNIT_OF_MEASUREMENT = "unit_of_measurement" CONF_UNIT_SYSTEM = "unit_system" CONF_UNTIL = "until" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1126f6b60a1..e27359fd56e 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -586,3 +586,32 @@ async def test_disarm_action(hass): await hass.async_block_till_done() assert len(service_calls) == 1 + + +async def test_unique_id(hass): + """Test unique_id option only creates one alarm control panel per id.""" + await setup.async_setup_component( + hass, + "alarm_control_panel", + { + "alarm_control_panel": { + "platform": "template", + "panels": { + "test_template_alarm_control_panel_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + }, + "test_template_alarm_control_panel_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + }, + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 482a72082cd..aeee08dc757 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -253,6 +253,7 @@ class TestBinarySensorTemplate(unittest.TestCase): None, None, None, + None, ).result() assert not vs.should_poll assert "motion" == vs.device_class @@ -315,6 +316,7 @@ class TestBinarySensorTemplate(unittest.TestCase): None, None, None, + None, ).result() mock_render.side_effect = TemplateError("foo") run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() @@ -640,3 +642,32 @@ async def test_no_update_template_match_all(hass, caplog): assert hass.states.get("binary_sensor.all_icon").state == "off" assert hass.states.get("binary_sensor.all_entity_picture").state == "off" assert hass.states.get("binary_sensor.all_attribute").state == "off" + + +async def test_unique_id(hass): + """Test unique_id option only creates one binary sensor per id.""" + await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "template", + "sensors": { + "test_template_cover_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + }, + "test_template_cover_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + }, + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 56db2445f6b..d51d71648bf 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -1034,3 +1034,48 @@ async def test_invalid_device_class(hass, calls): state = hass.states.get("cover.test_template_cover") assert not state + + +async def test_unique_id(hass): + """Test unique_id option only creates one cover per id.""" + await setup.async_setup_component( + hass, + "cover", + { + "cover": { + "platform": "template", + "covers": { + "test_template_cover_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + }, + "test_template_cover_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + }, + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 58fa80c10d5..a56b55fa123 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -758,3 +758,48 @@ async def _register_components(hass, speed_list=None): await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +async def test_unique_id(hass): + """Test unique_id option only creates one fan per id.""" + await setup.async_setup_component( + hass, + "fan", + { + "fan": { + "platform": "template", + "fans": { + "test_template_fan_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "turn_on": { + "service": "fan.turn_on", + "entity_id": "fan.test_state", + }, + "turn_off": { + "service": "fan.turn_off", + "entity_id": "fan.test_state", + }, + }, + "test_template_fan_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "turn_on": { + "service": "fan.turn_on", + "entity_id": "fan.test_state", + }, + "turn_off": { + "service": "fan.turn_off", + "entity_id": "fan.test_state", + }, + }, + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 8e27a6ba15d..5353aaa7d17 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -1164,3 +1164,46 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap assert hass.states.get("light.test_template_light").state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option only creates one light per id.""" + await setup.async_setup_component( + hass, + "light", + { + "light": { + "platform": "template", + "lights": { + "test_template_light_01": { + "unique_id": "not-so-unique-anymore", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + }, + "test_template_light_02": { + "unique_id": "not-so-unique-anymore", + "turn_on": { + "service": "light.turn_on", + "entity_id": "light.test_state", + }, + "turn_off": { + "service": "light.turn_off", + "entity_id": "light.test_state", + }, + }, + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 1389040c4bb..4c15babdfe2 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -406,3 +406,48 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap assert hass.states.get("lock.template_lock").state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option only creates one lock per id.""" + await setup.async_setup_component( + hass, + "lock", + { + "lock": { + "platform": "template", + "name": "test_template_lock_01", + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "lock": {"service": "switch.turn_on", "entity_id": "switch.test_state"}, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + ) + + await setup.async_setup_component( + hass, + "lock", + { + "lock": { + "platform": "template", + "name": "test_template_lock_02", + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "lock": {"service": "switch.turn_on", "entity_id": "switch.test_state"}, + "unlock": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index d61b1be4a7f..8a3a731f953 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -639,3 +639,32 @@ async def test_no_template_match_all(hass, caplog): assert hass.states.get("sensor.invalid_entity_picture").state == "hello" assert hass.states.get("sensor.invalid_friendly_name").state == "hello" assert hass.states.get("sensor.invalid_attribute").state == "hello" + + +async def test_unique_id(hass): + """Test unique_id option only creates one sensor per id.""" + await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "template", + "sensors": { + "test_template_sensor_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + }, + "test_template_sensor_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index a252bea9758..191e26a4266 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -650,3 +650,48 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap assert hass.states.get("switch.test_template_switch").state != STATE_UNAVAILABLE assert ("UndefinedError: 'x' is undefined") in caplog.text + + +async def test_unique_id(hass): + """Test unique_id option only creates one switch per id.""" + await setup.async_setup_component( + hass, + "switch", + { + "switch": { + "platform": "template", + "switches": { + "test_template_switch_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "turn_on": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "turn_off": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + "test_template_switch_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "turn_on": { + "service": "switch.turn_on", + "entity_id": "switch.test_state", + }, + "turn_off": { + "service": "switch.turn_off", + "entity_id": "switch.test_state", + }, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 19cd9f0a8ee..fd77e5455c6 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -612,3 +612,34 @@ async def _register_components(hass): await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() + + +async def test_unique_id(hass): + """Test unique_id option only creates one vacuum per id.""" + await setup.async_setup_component( + hass, + "vacuum", + { + "vacuum": { + "platform": "template", + "vacuums": { + "test_template_vacuum_01": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ true }}", + "start": {"service": "script.vacuum_start"}, + }, + "test_template_vacuum_02": { + "unique_id": "not-so-unique-anymore", + "value_template": "{{ false }}", + "start": {"service": "script.vacuum_start"}, + }, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 From 1c7cc63f4c96e5769d089131d697438f421e8ece Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sun, 2 Aug 2020 00:02:48 +0000 Subject: [PATCH 246/362] [ci skip] Translation update --- homeassistant/components/bond/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bond/translations/en.json b/homeassistant/components/bond/translations/en.json index 6d47cc35c14..2e636bb8999 100644 --- a/homeassistant/components/bond/translations/en.json +++ b/homeassistant/components/bond/translations/en.json @@ -24,4 +24,4 @@ } } } -} +} \ No newline at end of file From 9e12e3f96c097f37b02039743a9b17f44283d4b8 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sat, 1 Aug 2020 21:31:47 -0500 Subject: [PATCH 247/362] Allow automation to be turned off without stopping actions (#38436) --- .../components/automation/__init__.py | 24 ++++++++++++----- .../components/automation/services.yaml | 5 +++- homeassistant/components/script/__init__.py | 3 +-- tests/components/automation/test_init.py | 26 ++++++++++++++++--- 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 0a2cd9c3b51..f7a6aecf03b 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -67,6 +67,7 @@ CONF_TRIGGER = "trigger" CONF_CONDITION_TYPE = "condition_type" CONF_INITIAL_STATE = "initial_state" CONF_SKIP_CONDITION = "skip_condition" +CONF_STOP_ACTIONS = "stop_actions" CONDITION_USE_TRIGGER_VALUES = "use_trigger_values" CONDITION_TYPE_AND = "and" @@ -75,6 +76,7 @@ CONDITION_TYPE_OR = "or" DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND DEFAULT_INITIAL_STATE = True +DEFAULT_STOP_ACTIONS = True EVENT_AUTOMATION_RELOADED = "automation_reloaded" EVENT_AUTOMATION_TRIGGERED = "automation_triggered" @@ -225,7 +227,11 @@ async def async_setup(hass, config): ) component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") + component.async_register_entity_service( + SERVICE_TURN_OFF, + {vol.Optional(CONF_STOP_ACTIONS, default=DEFAULT_STOP_ACTIONS): cv.boolean}, + "async_turn_off", + ) async def reload_service_handler(service_call): """Remove all automations and load new ones from config.""" @@ -261,6 +267,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._async_detach_triggers = None self._cond_func = cond_func self.action_script = action_script + self.action_script.change_listener = self.async_write_ha_state self._last_triggered = None self._initial_state = initial_state self._is_enabled = False @@ -289,11 +296,10 @@ class AutomationEntity(ToggleEntity, RestoreEntity): attrs = { ATTR_LAST_TRIGGERED: self._last_triggered, ATTR_MODE: self.action_script.script_mode, + ATTR_CUR: self.action_script.runs, } if self.action_script.supports_max: attrs[ATTR_MAX] = self.action_script.max_runs - if self.is_on: - attrs[ATTR_CUR] = self.action_script.runs return attrs @property @@ -388,7 +394,10 @@ class AutomationEntity(ToggleEntity, RestoreEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self.async_disable() + if CONF_STOP_ACTIONS in kwargs: + await self.async_disable(kwargs[CONF_STOP_ACTIONS]) + else: + await self.async_disable() async def async_trigger(self, variables, skip_condition=False, context=None): """Trigger automation. @@ -456,9 +465,9 @@ class AutomationEntity(ToggleEntity, RestoreEntity): ) self.async_write_ha_state() - async def async_disable(self): + async def async_disable(self, stop_actions=DEFAULT_STOP_ACTIONS): """Disable the automation entity.""" - if not self._is_enabled: + if not self._is_enabled and not self.action_script.runs: return self._is_enabled = False @@ -467,7 +476,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._async_detach_triggers() self._async_detach_triggers = None - await self.action_script.async_stop() + if stop_actions: + await self.action_script.async_stop() self.async_write_ha_state() diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index 867dc8e89cd..2f5b0a231e4 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -12,6 +12,9 @@ turn_off: entity_id: description: Name of the automation to turn off. example: "automation.notify_home" + stop_actions: + description: Stop currently running actions (defaults to true). + example: false toggle: description: Toggle an automation. @@ -27,7 +30,7 @@ trigger: description: Name of the automation to trigger. example: "automation.notify_home" skip_condition: - description: Whether or not the condition will be skipped (defaults to True). + description: Whether or not the condition will be skipped (defaults to true). example: true reload: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 95696981cca..e12e2abd312 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -278,11 +278,10 @@ class ScriptEntity(ToggleEntity): attrs = { ATTR_LAST_TRIGGERED: self.script.last_triggered, ATTR_MODE: self.script.script_mode, + ATTR_CUR: self.script.runs, } if self.script.supports_max: attrs[ATTR_MAX] = self.script.max_runs - if self.is_on: - attrs[ATTR_CUR] = self.script.runs if self.script.last_action: attrs[ATTR_LAST_ACTION] = self.script.last_action return attrs diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index bdae9a1f326..a832b26d752 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -390,6 +390,17 @@ async def test_services(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 + await common.async_toggle(hass, entity_id) + await hass.async_block_till_done() + + assert not automation.is_on(hass, entity_id) + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 2 + + await common.async_toggle(hass, entity_id) + await hass.async_block_till_done() + await common.async_trigger(hass, entity_id) await hass.async_block_till_done() assert len(calls) == 3 @@ -556,9 +567,9 @@ async def test_reload_config_handles_load_fails(hass, calls): assert len(calls) == 2 -@pytest.mark.parametrize("service", ["turn_off", "reload"]) +@pytest.mark.parametrize("service", ["turn_off_stop", "turn_off_no_stop", "reload"]) async def test_automation_stops(hass, calls, service): - """Test that turning off / reloading an automation stops any running actions.""" + """Test that turning off / reloading stops any running actions as appropriate.""" entity_id = "automation.hello" test_entity = "test.entity" @@ -587,13 +598,20 @@ async def test_automation_stops(hass, calls, service): hass.bus.async_fire("test_event") await running.wait() - if service == "turn_off": + if service == "turn_off_stop": await hass.services.async_call( automation.DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + elif service == "turn_off_no_stop": + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id, automation.CONF_STOP_ACTIONS: False}, + blocking=True, + ) else: with patch( "homeassistant.config.load_yaml_config_file", @@ -605,7 +623,7 @@ async def test_automation_stops(hass, calls, service): hass.states.async_set(test_entity, "goodbye") await hass.async_block_till_done() - assert len(calls) == 0 + assert len(calls) == (1 if service == "turn_off_no_stop" else 0) async def test_automation_restore_state(hass): From 72b0f957195b572409e2c677256beb60da26f9d3 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 1 Aug 2020 21:39:55 -0500 Subject: [PATCH 248/362] Optimize directv config flow tests. (#38460) --- tests/components/directv/test_config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index ca9e3f41dbf..0082f9a5439 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -103,7 +103,7 @@ async def test_user_device_exists_abort( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort user flow if DirecTV receiver already configured.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -118,7 +118,7 @@ async def test_ssdp_device_exists_abort( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow if DirecTV receiver already configured.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() result = await hass.config_entries.flow.async_init( @@ -133,7 +133,7 @@ async def test_ssdp_with_receiver_id_device_exists_abort( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort SSDP flow if DirecTV receiver already configured.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, skip_entry_setup=True) discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy() discovery_info[ATTR_UPNP_SERIAL] = UPNP_SERIAL From 071b8ed8a55d231fda74c3a4290610ee3ec2f4f0 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Sun, 2 Aug 2020 07:08:12 -0700 Subject: [PATCH 249/362] Fix Android TV ADB authorization (#38471) --- homeassistant/components/androidtv/media_player.py | 6 ++---- tests/components/androidtv/patchers.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 6bf44d1a16b..e2468247a72 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -5,7 +5,6 @@ import logging import os from adb_shell.auth.keygen import keygen -from adb_shell.auth.sign_pythonrsa import PythonRSASigner from adb_shell.exceptions import ( AdbTimeoutError, InvalidChecksumError, @@ -14,6 +13,7 @@ from adb_shell.exceptions import ( TcpTimeoutException, ) from androidtv import ha_state_detection_rules_validator +from androidtv.adb_manager.adb_manager_sync import ADBPythonSync from androidtv.constants import APPS, KEYS from androidtv.exceptions import LockNotAcquiredException from androidtv.setup_async import setup @@ -176,9 +176,7 @@ def setup_androidtv(hass, config): keygen(adbkey) # Load the ADB key - with open(adbkey) as priv_key: - priv = priv_key.read() - signer = PythonRSASigner("", priv) + signer = ADBPythonSync.load_adbkey(adbkey) adb_log = f"using Python ADB implementation with adbkey='{adbkey}'" else: diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index 6918e47adb3..98c38719cf0 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -141,7 +141,7 @@ PATCH_ANDROIDTV_OPEN = patch( ) PATCH_KEYGEN = patch("homeassistant.components.androidtv.media_player.keygen") PATCH_SIGNER = patch( - "homeassistant.components.androidtv.media_player.PythonRSASigner", + "homeassistant.components.androidtv.media_player.ADBPythonSync.load_adbkey", return_value="signer for testing", ) From 2c887dfe12107e99da23f3b6a8c3361368b41984 Mon Sep 17 00:00:00 2001 From: RogerSelwyn Date: Sun, 2 Aug 2020 15:13:17 +0100 Subject: [PATCH 250/362] Update pyskyqhu to 0.1.1 (#38461) * Fix module pinning in pyskyhub * Bump pyskyqhub to 0.1.1 --- homeassistant/components/sky_hub/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json index da9197899e7..e663820a5ef 100644 --- a/homeassistant/components/sky_hub/manifest.json +++ b/homeassistant/components/sky_hub/manifest.json @@ -2,6 +2,6 @@ "domain": "sky_hub", "name": "Sky Hub", "documentation": "https://www.home-assistant.io/integrations/sky_hub", - "requirements": ["pyskyqhub==0.1.0"], + "requirements": ["pyskyqhub==0.1.1"], "codeowners": ["@rogerselwyn"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b24a561380..0b56ae2cf4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1611,7 +1611,7 @@ pysher==1.0.1 pysignalclirestapi==0.3.4 # homeassistant.components.sky_hub -pyskyqhub==0.1.0 +pyskyqhub==0.1.1 # homeassistant.components.sma pysma==0.3.5 From a57dca1e11226fb7550f9bc416d2b0f402c1d8ad Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 2 Aug 2020 16:22:51 +0200 Subject: [PATCH 251/362] Add sensor platform for AccuWeather integration (#38312) * Add sensor platform * Fix typo --- .coveragerc | 1 + .../components/accuweather/__init__.py | 2 +- homeassistant/components/accuweather/const.py | 254 ++++++++++++++++++ .../components/accuweather/sensor.py | 177 ++++++++++++ .../components/accuweather/strings.json | 2 +- .../accuweather/strings.sensor.json | 9 + 6 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/accuweather/sensor.py create mode 100644 homeassistant/components/accuweather/strings.sensor.json diff --git a/.coveragerc b/.coveragerc index 4eb8f1e0210..92ad9555d5d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,7 @@ omit = # omit pieces of code that rely on external devices being present homeassistant/components/accuweather/__init__.py homeassistant/components/accuweather/const.py + homeassistant/components/accuweather/sensor.py homeassistant/components/accuweather/weather.py homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 0107262e490..3dbb713ab2b 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -23,7 +23,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["weather"] +PLATFORMS = ["sensor", "weather"] async def async_setup(hass: HomeAssistant, config: Config) -> bool: diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index 2b903d8aa6e..c1b09ebd7b2 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -1,8 +1,30 @@ """Constants for AccuWeather integration.""" +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + DEVICE_CLASS_TEMPERATURE, + LENGTH_FEET, + LENGTH_INCHES, + LENGTH_METERS, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_HOURS, + UNIT_PERCENTAGE, + UV_INDEX, + VOLUME_CUBIC_METERS, +) + ATTRIBUTION = "Data provided by AccuWeather" +ATTR_ICON = "icon" ATTR_FORECAST = CONF_FORECAST = "forecast" +ATTR_LABEL = "label" +ATTR_UNIT_IMPERIAL = "Imperial" +ATTR_UNIT_METRIC = "Metric" +CONCENTRATION_PARTS_PER_CUBIC_METER = f"p/{VOLUME_CUBIC_METERS}" COORDINATOR = "coordinator" DOMAIN = "accuweather" +LENGTH_MILIMETERS = "mm" UNDO_UPDATE_LISTENER = "undo_update_listener" CONDITION_CLASSES = { @@ -21,3 +43,235 @@ CONDITION_CLASSES = { "sunny": [1, 2, 3, 5], "windy": [32], } + +FORECAST_DAYS = [0, 1, 2, 3, 4] + +FORECAST_SENSOR_TYPES = { + "CloudCoverDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover Day", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "CloudCoverNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover Night", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "Grass": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:grass", + ATTR_LABEL: "Grass Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "HoursOfSun": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-partly-cloudy", + ATTR_LABEL: "Hours Of Sun", + ATTR_UNIT_METRIC: TIME_HOURS, + ATTR_UNIT_IMPERIAL: TIME_HOURS, + }, + "Mold": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_LABEL: "Mold Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "Ozone": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:vector-triangle", + ATTR_LABEL: "Ozone", + ATTR_UNIT_METRIC: None, + ATTR_UNIT_IMPERIAL: None, + }, + "Ragweed": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:sprout", + ATTR_LABEL: "Ragweed Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "RealFeelTemperatureMax": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Max", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureMin": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Min", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShadeMax": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade Max", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShadeMin": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade Min", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "ThunderstormProbabilityDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-lightning", + ATTR_LABEL: "Thunderstorm Probability Day", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "ThunderstormProbabilityNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-lightning", + ATTR_LABEL: "Thunderstorm Probability Night", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "Tree": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:tree-outline", + ATTR_LABEL: "Tree Pollen", + ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "UVIndex": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-sunny", + ATTR_LABEL: "UV Index", + ATTR_UNIT_METRIC: UV_INDEX, + ATTR_UNIT_IMPERIAL: UV_INDEX, + }, + "WindGustDay": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust Day", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, + "WindGustNight": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust Night", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, +} + +OPTIONAL_SENSORS = ( + "ApparentTemperature", + "CloudCover", + "CloudCoverDay", + "CloudCoverNight", + "DewPoint", + "Grass", + "Mold", + "Ozone", + "Ragweed", + "RealFeelTemperatureShade", + "RealFeelTemperatureShadeMax", + "RealFeelTemperatureShadeMin", + "Tree", + "WetBulbTemperature", + "WindChillTemperature", + "WindGust", + "WindGustDay", + "WindGustNight", +) + +SENSOR_TYPES = { + "ApparentTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Apparent Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "Ceiling": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-fog", + ATTR_LABEL: "Cloud Ceiling", + ATTR_UNIT_METRIC: LENGTH_METERS, + ATTR_UNIT_IMPERIAL: LENGTH_FEET, + }, + "CloudCover": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-cloudy", + ATTR_LABEL: "Cloud Cover", + ATTR_UNIT_METRIC: UNIT_PERCENTAGE, + ATTR_UNIT_IMPERIAL: UNIT_PERCENTAGE, + }, + "DewPoint": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Dew Point", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "RealFeelTemperatureShade": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "RealFeel Temperature Shade", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "Precipitation": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-rainy", + ATTR_LABEL: "Precipitation", + ATTR_UNIT_METRIC: LENGTH_MILIMETERS, + ATTR_UNIT_IMPERIAL: LENGTH_INCHES, + }, + "PressureTendency": { + ATTR_DEVICE_CLASS: "accuweather__pressure_tendency", + ATTR_ICON: "mdi:gauge", + ATTR_LABEL: "Pressure Tendency", + ATTR_UNIT_METRIC: None, + ATTR_UNIT_IMPERIAL: None, + }, + "UVIndex": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-sunny", + ATTR_LABEL: "UV Index", + ATTR_UNIT_METRIC: UV_INDEX, + ATTR_UNIT_IMPERIAL: UV_INDEX, + }, + "WetBulbTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wet Bulb Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "WindChillTemperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wind Chill Temperature", + ATTR_UNIT_METRIC: TEMP_CELSIUS, + ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, + }, + "WindGust": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust", + ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, + }, +} diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py new file mode 100644 index 00000000000..4c0634876ef --- /dev/null +++ b/homeassistant/components/accuweather/sensor.py @@ -0,0 +1,177 @@ +"""Support for the AccuWeather service.""" +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + CONF_NAME, + DEVICE_CLASS_TEMPERATURE, +) +from homeassistant.helpers.entity import Entity + +from .const import ( + ATTR_FORECAST, + ATTR_ICON, + ATTR_LABEL, + ATTRIBUTION, + COORDINATOR, + DOMAIN, + FORECAST_DAYS, + FORECAST_SENSOR_TYPES, + OPTIONAL_SENSORS, + SENSOR_TYPES, +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add AccuWeather entities from a config_entry.""" + name = config_entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + sensors = [] + for sensor in SENSOR_TYPES: + sensors.append(AccuWeatherSensor(name, sensor, coordinator)) + + if coordinator.forecast: + for sensor in FORECAST_SENSOR_TYPES: + for day in FORECAST_DAYS: + # Some air quality/allergy sensors are only available for certain + # locations. + if sensor in coordinator.data[ATTR_FORECAST][0]: + sensors.append( + AccuWeatherSensor(name, sensor, coordinator, forecast_day=day) + ) + + async_add_entities(sensors, False) + + +class AccuWeatherSensor(Entity): + """Define an AccuWeather entity.""" + + def __init__(self, name, kind, coordinator, forecast_day=None): + """Initialize.""" + self._name = name + self.kind = kind + self.coordinator = coordinator + self._device_class = None + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._unit_system = "Metric" if self.coordinator.is_metric else "Imperial" + self.forecast_day = forecast_day + + @property + def name(self): + """Return the name.""" + if self.forecast_day is not None: + return f"{self._name} {FORECAST_SENSOR_TYPES[self.kind][ATTR_LABEL]} {self.forecast_day}d" + return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + if self.forecast_day is not None: + return f"{self.coordinator.location_key}-{self.kind}-{self.forecast_day}".lower() + return f"{self.coordinator.location_key}-{self.kind}".lower() + + @property + def should_poll(self): + """Return the polling requirement of the entity.""" + return False + + @property + def available(self): + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def state(self): + """Return the state.""" + if self.forecast_day is not None: + if ( + FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + == DEVICE_CLASS_TEMPERATURE + ): + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Value"] + if self.kind in ["WindGustDay", "WindGustNight"]: + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Speed"]["Value"] + if self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][ + self.kind + ]["Value"] + return self.coordinator.data[ATTR_FORECAST][self.forecast_day][self.kind] + if self.kind == "Ceiling": + return round(self.coordinator.data[self.kind][self._unit_system]["Value"]) + if self.kind == "PressureTendency": + return self.coordinator.data[self.kind]["LocalizedText"].lower() + if SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] == DEVICE_CLASS_TEMPERATURE: + return self.coordinator.data[self.kind][self._unit_system]["Value"] + if self.kind == "Precipitation": + return self.coordinator.data["PrecipitationSummary"][self.kind][ + self._unit_system + ]["Value"] + if self.kind == "WindGust": + return self.coordinator.data[self.kind]["Speed"][self._unit_system]["Value"] + return self.coordinator.data[self.kind] + + @property + def icon(self): + """Return the icon.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][ATTR_ICON] + return SENSOR_TYPES[self.kind][ATTR_ICON] + + @property + def device_class(self): + """Return the device_class.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if self.forecast_day is not None: + return FORECAST_SENSOR_TYPES[self.kind][self._unit_system] + return SENSOR_TYPES[self.kind][self._unit_system] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.forecast_day is not None: + if self.kind == "WindGustDay": + self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Direction"]["English"] + elif self.kind == "WindGustNight": + self._attrs["direction"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Direction"]["English"] + elif self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + self._attrs["level"] = self.coordinator.data[ATTR_FORECAST][ + self.forecast_day + ][self.kind]["Category"] + return self._attrs + if self.kind == "UVIndex": + self._attrs["level"] = self.coordinator.data["UVIndexText"] + elif self.kind == "Precipitation": + self._attrs["type"] = self.coordinator.data["PrecipitationType"] + return self._attrs + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return bool(self.kind not in OPTIONAL_SENSORS) + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update AccuWeather entity.""" + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 80f3159ad9e..89228cd0692 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "AccuWeather", - "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nSome sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options.", "data": { "name": "Name of the integration", "api_key": "[%key:common::config_flow::data::api_key%]", diff --git a/homeassistant/components/accuweather/strings.sensor.json b/homeassistant/components/accuweather/strings.sensor.json new file mode 100644 index 00000000000..57cb89bcecf --- /dev/null +++ b/homeassistant/components/accuweather/strings.sensor.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + } +} From c913d1791302f26b0cd74f4a4abc2a9f29b7673f Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Sun, 2 Aug 2020 07:36:14 -0700 Subject: [PATCH 252/362] Add bed sensor availability for withings (#37906) --- homeassistant/components/withings/common.py | 6 ++++++ tests/components/withings/test_binary_sensor.py | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 89bc56dc77c..2f3f4bf849a 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -926,6 +926,12 @@ class BaseWithingsSensor(Entity): if self._attribute.update_type == UpdateType.POLL: return self._data_manager.poll_data_update_coordinator.last_update_success + if self._attribute.update_type == UpdateType.WEBHOOK: + return self._data_manager.webhook_config.enabled and ( + self._attribute.measurement + in self._data_manager.webhook_update_coordinator.data + ) + return True @property diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index b646c667472..8f3347c8867 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.components.withings.common import ( async_get_entity_id, ) from homeassistant.components.withings.const import Measurement -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -37,7 +37,7 @@ async def test_binary_sensor( assert entity_id1 assert entity_registry.async_is_registered(entity_id0) - assert hass.states.get(entity_id0).state == STATE_OFF + assert hass.states.get(entity_id0).state == STATE_UNAVAILABLE resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN) assert resp.message_code == 0 @@ -50,7 +50,7 @@ async def test_binary_sensor( assert hass.states.get(entity_id0).state == STATE_OFF # person 1 - assert hass.states.get(entity_id1).state == STATE_OFF + assert hass.states.get(entity_id1).state == STATE_UNAVAILABLE resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN) assert resp.message_code == 0 From e8b6ed5a2765fcad4af674c78ed28ee2cbb7cf62 Mon Sep 17 00:00:00 2001 From: Xiaonan Shen Date: Sun, 2 Aug 2020 22:37:31 +0800 Subject: [PATCH 253/362] Add platform tests to yeelight (#37745) * Add platform tests to yeelight * Update requirements * Break long string --- .coveragerc | 1 - requirements_test_all.txt | 3 + tests/components/yeelight/__init__.py | 87 +++ .../components/yeelight/test_binary_sensor.py | 32 + tests/components/yeelight/test_light.py | 546 ++++++++++++++++++ 5 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 tests/components/yeelight/__init__.py create mode 100644 tests/components/yeelight/test_binary_sensor.py create mode 100644 tests/components/yeelight/test_light.py diff --git a/.coveragerc b/.coveragerc index 92ad9555d5d..1d37b7bc055 100644 --- a/.coveragerc +++ b/.coveragerc @@ -972,7 +972,6 @@ omit = homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yandex_transport/* - homeassistant/components/yeelight/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py homeassistant/components/zabbix/* diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7030b066848..ee6d4a77fcd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -995,6 +995,9 @@ wolf_smartset==0.1.4 # homeassistant.components.zestimate xmltodict==0.12.0 +# homeassistant.components.yeelight +yeelight==0.5.2 + # homeassistant.components.zeroconf zeroconf==0.28.0 diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py new file mode 100644 index 00000000000..7f1f7d7d236 --- /dev/null +++ b/tests/components/yeelight/__init__.py @@ -0,0 +1,87 @@ +"""Tests for the Yeelight integration.""" +from yeelight import BulbType +from yeelight.main import _MODEL_SPECS + +from homeassistant.components.yeelight import ( + CONF_MODE_MUSIC, + CONF_NIGHTLIGHT_SWITCH_TYPE, + CONF_SAVE_ON_CHANGE, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, +) +from homeassistant.const import CONF_DEVICES, CONF_NAME + +from tests.async_mock import MagicMock + +IP_ADDRESS = "192.168.1.239" +MODEL = "color" +ID = "0x000000000015243f" +FW_VER = "18" + +CAPABILITIES = { + "id": ID, + "model": MODEL, + "fw_ver": FW_VER, + "support": "get_prop set_default set_power toggle set_bright start_cf stop_cf" + " set_scene cron_add cron_get cron_del set_ct_abx set_rgb", + "name": "", +} + +NAME = f"yeelight_{MODEL}_{ID}" + +MODULE = "homeassistant.components.yeelight" +MODULE_CONFIG_FLOW = f"{MODULE}.config_flow" + +PROPERTIES = { + "power": "on", + "main_power": "on", + "bright": "50", + "ct": "4000", + "rgb": "16711680", + "hue": "100", + "sat": "35", + "color_mode": "1", + "flowing": "0", + "bg_power": "on", + "bg_lmode": "1", + "bg_flowing": "0", + "bg_ct": "5000", + "bg_bright": "80", + "bg_rgb": "16711680", + "nl_br": "23", + "active_mode": "0", + "current_brightness": "30", +} + +ENTITY_BINARY_SENSOR = f"binary_sensor.{NAME}_nightlight" +ENTITY_LIGHT = f"light.{NAME}" +ENTITY_NIGHTLIGHT = f"light.{NAME}_nightlight" + +YAML_CONFIGURATION = { + DOMAIN: { + CONF_DEVICES: { + IP_ADDRESS: { + CONF_NAME: NAME, + CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT, + CONF_MODE_MUSIC: True, + CONF_SAVE_ON_CHANGE: True, + } + } + } +} + + +def _mocked_bulb(cannot_connect=False): + bulb = MagicMock() + type(bulb).get_capabilities = MagicMock( + return_value=None if cannot_connect else CAPABILITIES + ) + type(bulb).get_model_specs = MagicMock(return_value=_MODEL_SPECS[MODEL]) + + bulb.capabilities = CAPABILITIES + bulb.model = MODEL + bulb.bulb_type = BulbType.Color + bulb.last_properties = PROPERTIES + bulb.music_mode = False + + return bulb diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py new file mode 100644 index 00000000000..bf20a7ec5b0 --- /dev/null +++ b/tests/components/yeelight/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test the Yeelight binary sensor.""" +from homeassistant.components.yeelight import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_component +from homeassistant.setup import async_setup_component + +from . import ENTITY_BINARY_SENSOR, MODULE, PROPERTIES, YAML_CONFIGURATION, _mocked_bulb + +from tests.async_mock import patch + + +async def test_nightlight(hass: HomeAssistant): + """Test nightlight sensor.""" + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) + await hass.async_block_till_done() + + # active_mode + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "off" + + # nl_br + properties = {**PROPERTIES} + properties.pop("active_mode") + mocked_bulb.last_properties = properties + await entity_component.async_update_entity(hass, ENTITY_BINARY_SENSOR) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "on" + + # default + properties.pop("nl_br") + await entity_component.async_update_entity(hass, ENTITY_BINARY_SENSOR) + assert hass.states.get(ENTITY_BINARY_SENSOR).state == "off" diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py new file mode 100644 index 00000000000..c44c343e51b --- /dev/null +++ b/tests/components/yeelight/test_light.py @@ -0,0 +1,546 @@ +"""Test the Yeelight light.""" +import logging + +from yeelight import ( + BulbException, + BulbType, + HSVTransition, + LightType, + PowerMode, + RGBTransition, + SceneClass, + SleepTransition, + TemperatureTransition, + transitions, +) +from yeelight.flow import Flow +from yeelight.main import _MODEL_SPECS + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_KELVIN, + ATTR_RGB_COLOR, + ATTR_TRANSITION, + FLASH_LONG, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.yeelight import ( + ATTR_COUNT, + ATTR_TRANSITIONS, + CONF_CUSTOM_EFFECTS, + CONF_FLOW_PARAMS, + CONF_NIGHTLIGHT_SWITCH_TYPE, + DEFAULT_TRANSITION, + DOMAIN, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, + YEELIGHT_HSV_TRANSACTION, + YEELIGHT_RGB_TRANSITION, + YEELIGHT_SLEEP_TRANSACTION, + YEELIGHT_TEMPERATURE_TRANSACTION, +) +from homeassistant.components.yeelight.light import ( + ATTR_MINUTES, + ATTR_MODE, + EFFECT_DISCO, + EFFECT_FACEBOOK, + EFFECT_FAST_RANDOM_LOOP, + EFFECT_STOP, + EFFECT_TWITTER, + EFFECT_WHATSAPP, + SERVICE_SET_AUTO_DELAY_OFF_SCENE, + SERVICE_SET_COLOR_FLOW_SCENE, + SERVICE_SET_COLOR_SCENE, + SERVICE_SET_COLOR_TEMP_SCENE, + SERVICE_SET_HSV_SCENE, + SERVICE_SET_MODE, + SERVICE_START_FLOW, + SUPPORT_YEELIGHT, + SUPPORT_YEELIGHT_RGB, + SUPPORT_YEELIGHT_WHITE_TEMP, + YEELIGHT_COLOR_EFFECT_LIST, + YEELIGHT_MONO_EFFECT_LIST, + YEELIGHT_TEMP_ONLY_EFFECT_LIST, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util.color import ( + color_hs_to_RGB, + color_hs_to_xy, + color_RGB_to_hs, + color_RGB_to_xy, + color_temperature_kelvin_to_mired, + color_temperature_mired_to_kelvin, +) + +from . import ( + CAPABILITIES, + ENTITY_LIGHT, + ENTITY_NIGHTLIGHT, + MODULE, + NAME, + PROPERTIES, + YAML_CONFIGURATION, + _mocked_bulb, +) + +from tests.async_mock import MagicMock, patch + + +async def test_services(hass: HomeAssistant, caplog): + """Test Yeelight services.""" + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION) + await hass.async_block_till_done() + + async def _async_test_service(service, data, method, payload=None, domain=DOMAIN): + err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) + + # success + mocked_method = MagicMock() + setattr(type(mocked_bulb), method, mocked_method) + await hass.services.async_call(domain, service, data, blocking=True) + if payload is None: + mocked_method.assert_called_once() + elif type(payload) == list: + mocked_method.assert_called_once_with(*payload) + else: + mocked_method.assert_called_once_with(**payload) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + ) + + # failure + mocked_method = MagicMock(side_effect=BulbException) + setattr(type(mocked_bulb), method, mocked_method) + await hass.services.async_call(domain, service, data, blocking=True) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) + == err_count + 1 + ) + + # turn_on + brightness = 100 + color_temp = 200 + transition = 1 + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_BRIGHTNESS: brightness, + ATTR_COLOR_TEMP: color_temp, + ATTR_FLASH: FLASH_LONG, + ATTR_EFFECT: EFFECT_STOP, + ATTR_TRANSITION: transition, + }, + blocking=True, + ) + mocked_bulb.turn_on.assert_called_once_with( + duration=transition * 1000, + light_type=LightType.Main, + power_mode=PowerMode.NORMAL, + ) + mocked_bulb.turn_on.reset_mock() + mocked_bulb.start_music.assert_called_once() + mocked_bulb.set_brightness.assert_called_once_with( + brightness / 255 * 100, duration=transition * 1000, light_type=LightType.Main + ) + mocked_bulb.set_color_temp.assert_called_once_with( + color_temperature_mired_to_kelvin(color_temp), + duration=transition * 1000, + light_type=LightType.Main, + ) + mocked_bulb.start_flow.assert_called_once() # flash + mocked_bulb.stop_flow.assert_called_once_with(light_type=LightType.Main) + + # turn_on nightlight + await _async_test_service( + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_NIGHTLIGHT}, + "turn_on", + payload={ + "duration": DEFAULT_TRANSITION, + "light_type": LightType.Main, + "power_mode": PowerMode.MOONLIGHT, + }, + domain="light", + ) + + # turn_off + await _async_test_service( + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: transition}, + "turn_off", + domain="light", + payload={"duration": transition * 1000, "light_type": LightType.Main}, + ) + + # set_mode + mode = "rgb" + await _async_test_service( + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MODE: "rgb"}, + "set_power_mode", + [PowerMode[mode.upper()]], + ) + + # start_flow + await _async_test_service( + SERVICE_START_FLOW, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], + }, + "start_flow", + ) + + # set_color_scene + await _async_test_service( + SERVICE_SET_COLOR_SCENE, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_RGB_COLOR: [10, 20, 30], + ATTR_BRIGHTNESS: 50, + }, + "set_scene", + [SceneClass.COLOR, 10, 20, 30, 50], + ) + + # set_hsv_scene + await _async_test_service( + SERVICE_SET_HSV_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_HS_COLOR: [180, 50], ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.HSV, 180, 50, 50], + ) + + # set_color_temp_scene + await _async_test_service( + SERVICE_SET_COLOR_TEMP_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_KELVIN: 4000, ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.CT, 4000, 50], + ) + + # set_color_flow_scene + await _async_test_service( + SERVICE_SET_COLOR_FLOW_SCENE, + { + ATTR_ENTITY_ID: ENTITY_LIGHT, + ATTR_TRANSITIONS: [{YEELIGHT_TEMPERATURE_TRANSACTION: [1900, 2000, 60]}], + }, + "set_scene", + ) + + # set_auto_delay_off_scene + await _async_test_service( + SERVICE_SET_AUTO_DELAY_OFF_SCENE, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_MINUTES: 1, ATTR_BRIGHTNESS: 50}, + "set_scene", + [SceneClass.AUTO_DELAY_OFF, 50, 1], + ) + + # test _cmd wrapper error handler + err_count = len([x for x in caplog.records if x.levelno == logging.ERROR]) + type(mocked_bulb).turn_on = MagicMock() + type(mocked_bulb).set_brightness = MagicMock(side_effect=BulbException) + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 50}, + blocking=True, + ) + assert ( + len([x for x in caplog.records if x.levelno == logging.ERROR]) == err_count + 1 + ) + + +async def test_device_types(hass: HomeAssistant): + """Test different device types.""" + properties = {**PROPERTIES} + properties.pop("active_mode") + properties["color_mode"] = "3" + + def _create_mocked_bulb(bulb_type, model, unique_id): + capabilities = {**CAPABILITIES} + capabilities["id"] = f"yeelight.{unique_id}" + mocked_bulb = _mocked_bulb() + mocked_bulb.bulb_type = bulb_type + mocked_bulb.last_properties = properties + mocked_bulb.capabilities = capabilities + model_specs = _MODEL_SPECS.get(model) + type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs) + return mocked_bulb + + types = { + "default": (None, "mono"), + "white": (BulbType.White, "mono"), + "color": (BulbType.Color, "color"), + "white_temp": (BulbType.WhiteTemp, "ceiling1"), + "white_temp_mood": (BulbType.WhiteTempMood, "ceiling4"), + "ambient": (BulbType.WhiteTempMood, "ceiling4"), + } + + devices = {} + mocked_bulbs = [] + unique_id = 0 + for name, (bulb_type, model) in types.items(): + devices[f"{name}.yeelight"] = {CONF_NAME: name} + devices[f"{name}_nightlight.yeelight"] = { + CONF_NAME: f"{name}_nightlight", + CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT, + } + mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id)) + mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id + 1)) + unique_id += 2 + + with patch(f"{MODULE}.Bulb", side_effect=mocked_bulbs): + await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DEVICES: devices}}) + await hass.async_block_till_done() + + async def _async_test( + name, + bulb_type, + model, + target_properties, + nightlight_properties=None, + entity_name=None, + entity_id=None, + ): + if entity_id is None: + entity_id = f"light.{name}" + state = hass.states.get(entity_id) + assert state.state == "on" + target_properties["friendly_name"] = entity_name or name + target_properties["flowing"] = False + target_properties["night_light"] = True + assert dict(state.attributes) == target_properties + + # nightlight + if nightlight_properties is None: + return + name += "_nightlight" + entity_id = f"light.{name}" + assert hass.states.get(entity_id).state == "off" + state = hass.states.get(f"{entity_id}_nightlight") + assert state.state == "on" + nightlight_properties["friendly_name"] = f"{name} nightlight" + nightlight_properties["icon"] = "mdi:weather-night" + nightlight_properties["flowing"] = False + nightlight_properties["night_light"] = True + assert dict(state.attributes) == nightlight_properties + + bright = round(255 * int(PROPERTIES["bright"]) / 100) + current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100) + ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"])) + hue = int(PROPERTIES["hue"]) + sat = int(PROPERTIES["sat"]) + hs_color = (round(hue / 360 * 65536, 3), round(sat / 100 * 255, 3)) + rgb_color = color_hs_to_RGB(*hs_color) + xy_color = color_hs_to_xy(*hs_color) + bg_bright = round(255 * int(PROPERTIES["bg_bright"]) / 100) + bg_ct = color_temperature_kelvin_to_mired(int(PROPERTIES["bg_ct"])) + bg_rgb = int(PROPERTIES["bg_rgb"]) + bg_rgb_color = ((bg_rgb >> 16) & 0xFF, (bg_rgb >> 8) & 0xFF, bg_rgb & 0xFF) + bg_hs_color = color_RGB_to_hs(*bg_rgb_color) + bg_xy_color = color_RGB_to_xy(*bg_rgb_color) + nl_br = round(255 * int(PROPERTIES["nl_br"]) / 100) + + # Default + await _async_test( + "default", + None, + "mono", + { + "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": bright, + }, + ) + + # White + await _async_test( + "white", + BulbType.White, + "mono", + { + "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": bright, + }, + ) + + # Color + model_specs = _MODEL_SPECS["color"] + await _async_test( + "color", + BulbType.Color, + "color", + { + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_RGB, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + "hs_color": hs_color, + "rgb_color": rgb_color, + "xy_color": xy_color, + }, + {"supported_features": 0}, + ) + + # WhiteTemp + model_specs = _MODEL_SPECS["ceiling1"] + await _async_test( + "white_temp", + BulbType.WhiteTemp, + "ceiling1", + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_WHITE_TEMP, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + }, + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": nl_br, + }, + ) + + # WhiteTempMood + model_specs = _MODEL_SPECS["ceiling4"] + await _async_test( + "white_temp_mood", + BulbType.WhiteTempMood, + "ceiling4", + { + "friendly_name": NAME, + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "flowing": False, + "night_light": True, + "supported_features": SUPPORT_YEELIGHT_WHITE_TEMP, + "min_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["max"] + ), + "max_mireds": color_temperature_kelvin_to_mired( + model_specs["color_temp"]["min"] + ), + "brightness": current_brightness, + "color_temp": ct, + }, + { + "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT, + "brightness": nl_br, + }, + ) + await _async_test( + "ambient", + BulbType.WhiteTempMood, + "ceiling4", + { + "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "supported_features": SUPPORT_YEELIGHT_RGB, + "min_mireds": color_temperature_kelvin_to_mired(6500), + "max_mireds": color_temperature_kelvin_to_mired(1700), + "brightness": bg_bright, + "color_temp": bg_ct, + "hs_color": bg_hs_color, + "rgb_color": bg_rgb_color, + "xy_color": bg_xy_color, + }, + entity_name="ambient ambilight", + entity_id="light.ambient_ambilight", + ) + + +async def test_effects(hass: HomeAssistant): + """Test effects.""" + yaml_configuration = { + DOMAIN: { + CONF_DEVICES: YAML_CONFIGURATION[DOMAIN][CONF_DEVICES], + CONF_CUSTOM_EFFECTS: [ + { + CONF_NAME: "mock_effect", + CONF_FLOW_PARAMS: { + ATTR_COUNT: 3, + ATTR_TRANSITIONS: [ + {YEELIGHT_HSV_TRANSACTION: [300, 50, 500, 50]}, + {YEELIGHT_RGB_TRANSITION: [100, 100, 100, 300, 30]}, + {YEELIGHT_TEMPERATURE_TRANSACTION: [3000, 200, 20]}, + {YEELIGHT_SLEEP_TRANSACTION: [800]}, + ], + }, + }, + ], + } + } + + mocked_bulb = _mocked_bulb() + with patch(f"{MODULE}.Bulb", return_value=mocked_bulb): + assert await async_setup_component(hass, DOMAIN, yaml_configuration) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).attributes.get( + "effect_list" + ) == YEELIGHT_COLOR_EFFECT_LIST + ["mock_effect"] + + async def _async_test_effect(name, target=None, called=True): + mocked_start_flow = MagicMock() + type(mocked_bulb).start_flow = mocked_start_flow + await hass.services.async_call( + "light", + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_EFFECT: name}, + blocking=True, + ) + if not called: + return + mocked_start_flow.assert_called_once() + if target is None: + return + args, _ = mocked_start_flow.call_args + flow = args[0] + assert flow.count == target.count + assert flow.action == target.action + assert str(flow.transitions) == str(target.transitions) + + effects = { + "mock_effect": Flow( + count=3, + transitions=[ + HSVTransition(300, 50, 500, 50), + RGBTransition(100, 100, 100, 300, 30), + TemperatureTransition(3000, 200, 20), + SleepTransition(800), + ], + ), + EFFECT_DISCO: Flow(transitions=transitions.disco()), + EFFECT_FAST_RANDOM_LOOP: None, + EFFECT_WHATSAPP: Flow(count=2, transitions=transitions.pulse(37, 211, 102)), + EFFECT_FACEBOOK: Flow(count=2, transitions=transitions.pulse(59, 89, 152)), + EFFECT_TWITTER: Flow(count=2, transitions=transitions.pulse(0, 172, 237)), + } + + for name, target in effects.items(): + await _async_test_effect(name, target) + await _async_test_effect("not_existed", called=False) From 34b911203cfcaa34080012d829d5950b57ec7ff4 Mon Sep 17 00:00:00 2001 From: Matthias Weiss Date: Sun, 2 Aug 2020 17:55:17 +0200 Subject: [PATCH 254/362] Add homematic IPWKeyBlindMulti device (#38345) --- homeassistant/components/homematic/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index e4a481f62d2..92091930d32 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -159,6 +159,7 @@ HM_DEVICE_TYPES = { "IPKeyBlindTilt", "IPGarage", "IPKeyBlindMulti", + "IPWKeyBlindMulti", ], DISCOVER_LOCKS: ["KeyMatic"], } From 03a0114e1082e4918e1d8ccdb8b6e9fa11b90784 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Aug 2020 08:32:07 -1000 Subject: [PATCH 255/362] Avoid shutdown delays when emulated_hue is enabled (#38472) We would have to wait for the select to timeout for emulated_hue upnp thread to shutdown We now close the socket so the select unblocks right away --- homeassistant/components/emulated_hue/upnp.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index ecb78241771..8adcac1a52f 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -74,11 +74,14 @@ class UPNPResponderThread(threading.Thread): self.upnp_bind_multicast = upnp_bind_multicast self.advertise_ip = advertise_ip self.advertise_port = advertise_port + self._ssdp_socket = None def run(self): """Run the server.""" # Listen for UDP port 1900 packets sent to SSDP multicast address - ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._ssdp_socket = ssdp_socket = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM + ) ssdp_socket.setblocking(False) # Required for receiving multicast @@ -101,7 +104,6 @@ class UPNPResponderThread(threading.Thread): while True: if self._interrupted: - clean_socket_close(ssdp_socket) return try: @@ -114,7 +116,6 @@ class UPNPResponderThread(threading.Thread): continue except OSError as ex: if self._interrupted: - clean_socket_close(ssdp_socket) return _LOGGER.error( @@ -138,6 +139,8 @@ class UPNPResponderThread(threading.Thread): """Stop the server.""" # Request for server self._interrupted = True + if self._ssdp_socket: + clean_socket_close(self._ssdp_socket) self.join() def _handle_request(self, data): From c403c77cff0ea684027bf99f472e4fab5a52d34d Mon Sep 17 00:00:00 2001 From: Villhellm Date: Sun, 2 Aug 2020 12:06:16 -0700 Subject: [PATCH 256/362] Catch AssertionError when onkyo zone 3 detection fails (#38374) This error would cause the entire integration to fail. This at least catches it gracefully. --- homeassistant/components/onkyo/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index da33ff5f018..2b67f06ac3c 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -134,6 +134,8 @@ def determine_zones(receiver): if str(error) != TIMEOUT_MESSAGE: raise error _LOGGER.debug("Zone 3 timed out, assuming no functionality") + except AssertionError: + _LOGGER.error("Zone 3 detection failed") return out From 428c376fe42849e68d3909e05a06bdf29fee0b34 Mon Sep 17 00:00:00 2001 From: clssn Date: Sun, 2 Aug 2020 23:35:21 +0200 Subject: [PATCH 257/362] Update numato-gpio to 0.8.0 (#38415) * Bump the numato-gpio dependency This relaxes the pyserial dependency to >=3.1.1 as requested by the project with respect to the upcoming, stricter pip resolver. * Update numato-gpio due to deprecation of class BinarySensorDevice --- homeassistant/components/numato/binary_sensor.py | 4 ++-- homeassistant/components/numato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index ff61cb3cbb0..be8d3f62afa 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -4,7 +4,7 @@ import logging from numato_gpio import NumatoGpioError -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -63,7 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(binary_sensors, True) -class NumatoGpioBinarySensor(BinarySensorDevice): +class NumatoGpioBinarySensor(BinarySensorEntity): """Represents a binary sensor (input) port of a Numato GPIO expander.""" def __init__(self, name, device_id, port, invert_logic, api): diff --git a/homeassistant/components/numato/manifest.json b/homeassistant/components/numato/manifest.json index 8696151eecc..4b7dcd9e372 100644 --- a/homeassistant/components/numato/manifest.json +++ b/homeassistant/components/numato/manifest.json @@ -2,6 +2,6 @@ "domain": "numato", "name": "Numato USB GPIO Expander", "documentation": "https://www.home-assistant.io/integrations/numato", - "requirements": ["numato-gpio==0.7.1"], + "requirements": ["numato-gpio==0.8.0"], "codeowners": ["@clssn"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b56ae2cf4d..79838059827 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -975,7 +975,7 @@ nsw-fuel-api-client==1.0.10 nuheat==0.3.0 # homeassistant.components.numato -numato-gpio==0.7.1 +numato-gpio==0.8.0 # homeassistant.components.iqvia # homeassistant.components.opencv diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee6d4a77fcd..4a23e0dd8ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -452,7 +452,7 @@ nsw-fuel-api-client==1.0.10 nuheat==0.3.0 # homeassistant.components.numato -numato-gpio==0.7.1 +numato-gpio==0.8.0 # homeassistant.components.iqvia # homeassistant.components.opencv From 1e685a4a0087ed2acc31b7c13ad627493a7e9000 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 2 Aug 2020 18:02:47 -0500 Subject: [PATCH 258/362] Optimize ipp tests (#38485) * optimize ipp tests * Update test_config_flow.py --- tests/components/ipp/__init__.py | 17 +++++++++-------- tests/components/ipp/test_config_flow.py | 6 +++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index 515543f3cf5..1e269438ad5 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -143,15 +143,16 @@ async def init_integration( entry.add_to_hass(hass) + mock_connection( + aioclient_mock, + host=host, + port=port, + ssl=ssl, + base_path=base_path, + conn_error=conn_error, + ) + if not skip_setup: - mock_connection( - aioclient_mock, - host=host, - port=port, - ssl=ssl, - base_path=base_path, - conn_error=conn_error, - ) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index b9a1d833eda..7133bf3cde7 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -218,7 +218,7 @@ async def test_user_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort user flow if printer already configured.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, skip_setup=True) user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -233,7 +233,7 @@ async def test_zeroconf_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow if printer already configured.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, skip_setup=True) discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() result = await hass.config_entries.flow.async_init( @@ -248,7 +248,7 @@ async def test_zeroconf_with_uuid_device_exists_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow if printer already configured.""" - await init_integration(hass, aioclient_mock) + await init_integration(hass, aioclient_mock, skip_setup=True) discovery_info = { **MOCK_ZEROCONF_IPP_SERVICE_INFO, From ce7177572294e42337904a974f2d078f929a9009 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Mon, 3 Aug 2020 00:02:55 +0000 Subject: [PATCH 259/362] [ci skip] Translation update --- .../accuweather/translations/en.json | 2 +- .../accuweather/translations/it.json | 2 +- .../accuweather/translations/ru.json | 2 +- .../accuweather/translations/sensor.en.json | 9 +++++++++ .../accuweather/translations/sensor.it.json | 9 +++++++++ .../accuweather/translations/sensor.ru.json | 9 +++++++++ .../components/bond/translations/it.json | 7 +++++++ .../components/bond/translations/ru.json | 7 +++++++ .../components/bond/translations/zh-Hant.json | 10 ++++++++++ .../components/cover/translations/it.json | 2 +- .../cover/translations/zh-Hant.json | 3 ++- .../meteo_france/translations/it.json | 19 +++++++++++++++++++ .../meteo_france/translations/ru.json | 19 +++++++++++++++++++ .../meteo_france/translations/zh-Hant.json | 19 +++++++++++++++++++ 14 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/sensor.en.json create mode 100644 homeassistant/components/accuweather/translations/sensor.it.json create mode 100644 homeassistant/components/accuweather/translations/sensor.ru.json diff --git a/homeassistant/components/accuweather/translations/en.json b/homeassistant/components/accuweather/translations/en.json index 1b6a5052fc0..8382236e7a0 100644 --- a/homeassistant/components/accuweather/translations/en.json +++ b/homeassistant/components/accuweather/translations/en.json @@ -16,7 +16,7 @@ "longitude": "Longitude", "name": "Name of the integration" }, - "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nWeather forecast is not enabled by default. You can enable it in the integration options.", + "description": "If you need help with the configuration have a look here: https://www.home-assistant.io/integrations/accuweather/\n\nSome sensors are not enabled by default. You can enable them in the entity registry after the integration configuration.\nWeather forecast is not enabled by default. You can enable it in the integration options.", "title": "AccuWeather" } } diff --git a/homeassistant/components/accuweather/translations/it.json b/homeassistant/components/accuweather/translations/it.json index c091725efb1..398f7e1e663 100644 --- a/homeassistant/components/accuweather/translations/it.json +++ b/homeassistant/components/accuweather/translations/it.json @@ -16,7 +16,7 @@ "longitude": "Logitudine", "name": "Nome dell'integrazione" }, - "description": "Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/accuweather/ \n\nLe previsioni meteo non sono abilitate per impostazione predefinita. Puoi abilitarlo nelle opzioni di integrazione.", + "description": "Se hai bisogno di aiuto con la configurazione dai un'occhiata qui: https://www.home-assistant.io/integrations/accuweather/ \n\nAlcuni sensori non sono abilitati per impostazione predefinita. \u00c8 possibile abilitarli nel registro entit\u00e0 dopo la configurazione di integrazione. \nLe previsioni meteo non sono abilitate per impostazione predefinita. Puoi abilitarle nelle opzioni di integrazione.", "title": "AccuWeather" } } diff --git a/homeassistant/components/accuweather/translations/ru.json b/homeassistant/components/accuweather/translations/ru.json index 5c1c5863831..8803659ccbb 100644 --- a/homeassistant/components/accuweather/translations/ru.json +++ b/homeassistant/components/accuweather/translations/ru.json @@ -16,7 +16,7 @@ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438, \u0435\u0441\u043b\u0438 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439:\nhttps://www.home-assistant.io/integrations/accuweather/ \n\n\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442 \u043f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0435\u0433\u043e \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438, \u0435\u0441\u043b\u0438 \u0412\u0430\u043c \u043d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c \u0441 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439:\nhttps://www.home-assistant.io/integrations/accuweather/ \n\n\u041f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e \u043d\u0435\u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b \u0441\u043a\u0440\u044b\u0442\u044b \u0438 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d \u043f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435 \u043d\u0443\u0436\u043d\u044b\u0445 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0432 \u0440\u0435\u0435\u0441\u0442\u0440\u0435 \u043e\u0431\u044a\u0435\u043a\u0442\u043e\u0432 \u0438 \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u043e\u0433\u043d\u043e\u0437 \u043f\u043e\u0433\u043e\u0434\u044b \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.", "title": "AccuWeather" } } diff --git a/homeassistant/components/accuweather/translations/sensor.en.json b/homeassistant/components/accuweather/translations/sensor.en.json new file mode 100644 index 00000000000..8786583686b --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.en.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Falling", + "rising": "Rising", + "steady": "Steady" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.it.json b/homeassistant/components/accuweather/translations/sensor.it.json new file mode 100644 index 00000000000..9252821b8de --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.it.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Diminuzione", + "rising": "Aumento", + "steady": "Stabile" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.ru.json b/homeassistant/components/accuweather/translations/sensor.ru.json new file mode 100644 index 00000000000..fd791040d9f --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.ru.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u041f\u043e\u043d\u0438\u0436\u0430\u044e\u0449\u0435\u0435\u0441\u044f", + "rising": "\u041f\u043e\u0432\u044b\u0448\u0430\u044e\u0449\u0435\u0435\u0441\u044f", + "steady": "\u041f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/it.json b/homeassistant/components/bond/translations/it.json index 8c813c64226..5b1434e9b63 100644 --- a/homeassistant/components/bond/translations/it.json +++ b/homeassistant/components/bond/translations/it.json @@ -8,7 +8,14 @@ "invalid_auth": "Autenticazione non valida", "unknown": "Errore imprevisto" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Token di accesso" + }, + "description": "Vuoi configurare {bond_id}?" + }, "user": { "data": { "access_token": "Token di accesso", diff --git a/homeassistant/components/bond/translations/ru.json b/homeassistant/components/bond/translations/ru.json index 7da16b7bab7..566b04e1af8 100644 --- a/homeassistant/components/bond/translations/ru.json +++ b/homeassistant/components/bond/translations/ru.json @@ -8,7 +8,14 @@ "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, + "flow_title": "Bond {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430" + }, + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {bond_id}?" + }, "user": { "data": { "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430", diff --git a/homeassistant/components/bond/translations/zh-Hant.json b/homeassistant/components/bond/translations/zh-Hant.json index 0a4e3dc061e..915ff9b6a05 100644 --- a/homeassistant/components/bond/translations/zh-Hant.json +++ b/homeassistant/components/bond/translations/zh-Hant.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" }, + "flow_title": "Bond\uff1a{bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "\u5b58\u53d6\u5bc6\u9470" + }, + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {bond_id}\uff1f" + }, "user": { "data": { "access_token": "\u5b58\u53d6\u5bc6\u9470", diff --git a/homeassistant/components/cover/translations/it.json b/homeassistant/components/cover/translations/it.json index ebba7da0e9c..90322b9f122 100644 --- a/homeassistant/components/cover/translations/it.json +++ b/homeassistant/components/cover/translations/it.json @@ -7,7 +7,7 @@ "open_tilt": "Apri l'inclinazione di {entity_name}", "set_position": "Imposta la posizione di {entity_name}", "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}", - "stop": "Stop {entity_name}" + "stop": "Ferma {entity_name}" }, "condition_type": { "is_closed": "{entity_name} \u00e8 chiuso", diff --git a/homeassistant/components/cover/translations/zh-Hant.json b/homeassistant/components/cover/translations/zh-Hant.json index 31c0900af9a..a8752b13f00 100644 --- a/homeassistant/components/cover/translations/zh-Hant.json +++ b/homeassistant/components/cover/translations/zh-Hant.json @@ -6,7 +6,8 @@ "open": "\u958b\u555f{entity_name}", "open_tilt": "\u958b\u555f{entity_name}\u7a97\u7c3e", "set_position": "\u8a2d\u5b9a{entity_name}\u4f4d\u7f6e", - "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e" + "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e", + "stop": "\u505c\u6b62 {entity_name}" }, "condition_type": { "is_closed": "{entity_name}\u5df2\u95dc\u9589", diff --git a/homeassistant/components/meteo_france/translations/it.json b/homeassistant/components/meteo_france/translations/it.json index 23b40164c7b..df5cf4a6375 100644 --- a/homeassistant/components/meteo_france/translations/it.json +++ b/homeassistant/components/meteo_france/translations/it.json @@ -4,7 +4,17 @@ "already_configured": "Citt\u00e0 gi\u00e0 configurata", "unknown": "Errore sconosciuto: riprovare pi\u00f9 tardi" }, + "error": { + "empty": "Nessun risultato nella ricerca della citt\u00e0: si prega di controllare il campo citt\u00e0" + }, "step": { + "cities": { + "data": { + "city": "Citt\u00e0" + }, + "description": "Scegli la tua citt\u00e0 dall'elenco", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "Citt\u00e0" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Modalit\u00e0 previsione" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/ru.json b/homeassistant/components/meteo_france/translations/ru.json index 47e2eda63af..ba0bf1df3c2 100644 --- a/homeassistant/components/meteo_france/translations/ru.json +++ b/homeassistant/components/meteo_france/translations/ru.json @@ -4,7 +4,17 @@ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c\u0438 \u0436\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043c\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435." }, + "error": { + "empty": "\u041d\u0435\u0442 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442\u043e\u0432 \u043f\u043e\u0438\u0441\u043a\u0430. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \"\u0413\u043e\u0440\u043e\u0434\"." + }, "step": { + "cities": { + "data": { + "city": "\u0413\u043e\u0440\u043e\u0434" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0433\u043e\u0440\u043e\u0434 \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "\u0413\u043e\u0440\u043e\u0434" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "\u0420\u0435\u0436\u0438\u043c \u043f\u0440\u043e\u0433\u043d\u043e\u0437\u0430" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/zh-Hant.json b/homeassistant/components/meteo_france/translations/zh-Hant.json index afece58eef4..0179f5ad7d1 100644 --- a/homeassistant/components/meteo_france/translations/zh-Hant.json +++ b/homeassistant/components/meteo_france/translations/zh-Hant.json @@ -4,7 +4,17 @@ "already_configured": "\u57ce\u5e02\u5df2\u8a2d\u5b9a\u5b8c\u6210", "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66" }, + "error": { + "empty": "\u627e\u4e0d\u5230\u76f8\u7b26\u7684\u57ce\u5e02\uff1a\u8acb\u78ba\u8a8d\u57ce\u5e02\u6b04\u4f4d" + }, "step": { + "cities": { + "data": { + "city": "\u57ce\u5e02\u540d\u7a31" + }, + "description": "\u7531\u5217\u8868\u4e2d\u9078\u64c7\u57ce\u5e02", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "\u57ce\u5e02\u540d\u7a31" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "\u9810\u5831\u6a21\u5f0f" + } + } + } } } \ No newline at end of file From 542c6cce257c33e3f218b101b090efe61e25c519 Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Sun, 2 Aug 2020 22:50:03 -0400 Subject: [PATCH 260/362] Ensure bond unique ids are unique across hubs (#38496) --- homeassistant/components/bond/entity.py | 4 +++- tests/components/bond/common.py | 2 +- tests/components/bond/test_cover.py | 11 +++++++++-- tests/components/bond/test_fan.py | 11 +++++++++-- tests/components/bond/test_light.py | 11 +++++++++-- tests/components/bond/test_switch.py | 11 +++++++++-- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index aa5a7564b3f..716f121f1a1 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -27,7 +27,9 @@ class BondEntity(Entity): @property def unique_id(self) -> Optional[str]: """Get unique ID for the entity.""" - return self._device.device_id + hub_id = self._hub.bond_id + device_id = self._device.device_id + return f"{hub_id}_{device_id}" @property def name(self) -> Optional[str]: diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index bb3329b6a2d..31308746555 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -58,7 +58,7 @@ async def setup_platform( """Set up the specified Bond platform.""" mock_entry = MockConfigEntry( domain=BOND_DOMAIN, - data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"}, + data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, ) mock_entry.add_to_hass(hass) diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index a9d55ce593c..a9083a900e6 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -34,10 +34,17 @@ def shades(name: str): async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, COVER_DOMAIN, shades("name-1")) + await setup_platform( + hass, + COVER_DOMAIN, + shades("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["cover.name_1"] + entity = registry.entities["cover.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" async def test_open_cover(hass: core.HomeAssistant): diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 91f0c21e77a..fa7e59e30e6 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -52,10 +52,17 @@ async def turn_fan_on( async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, FAN_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["fan.name_1"] + entity = registry.entities["fan.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" async def test_non_standard_speed_list(hass: core.HomeAssistant): diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index e1167eac107..57ea859240a 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -57,10 +57,17 @@ def fireplace(name: str): async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, LIGHT_DOMAIN, ceiling_fan("name-1")) + await setup_platform( + hass, + LIGHT_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["light.name_1"] + entity = registry.entities["light.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" async def test_sbb_trust_state(hass: core.HomeAssistant): diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 8a5803d4eee..2755a491a86 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -29,10 +29,17 @@ def generic_device(name: str): async def test_entity_registry(hass: core.HomeAssistant): """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, SWITCH_DOMAIN, generic_device("name-1")) + await setup_platform( + hass, + SWITCH_DOMAIN, + generic_device("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry() - assert [key for key in registry.entities] == ["switch.name_1"] + entity = registry.entities["switch.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" async def test_turn_on_switch(hass: core.HomeAssistant): From 76b46b9175345cedf685553b954e76443a02c6bd Mon Sep 17 00:00:00 2001 From: Matthew Garrett Date: Sun, 2 Aug 2020 19:51:01 -0700 Subject: [PATCH 261/362] Provide a unique entity ID for lgsoundbar (#38494) The device gives us a UUID, so let's just use that to construct a unique ID. --- homeassistant/components/lg_soundbar/media_player.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 0b38bc1ab8d..448b4d3bb97 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -35,6 +35,8 @@ class LGDevice(MediaPlayerEntity): """Initialize the LG speakers.""" self._host = discovery_info.get("host") self._port = discovery_info.get("port") + properties = discovery_info.get("properties") + self._uuid = properties.get("UUID") self._name = "" self._volume = 0 @@ -128,6 +130,11 @@ class LGDevice(MediaPlayerEntity): if equaliser >= len(temescal.equalisers): temescal.equalisers.append("unknown " + str(equaliser)) + @property + def unique_id(self): + """Return the device's unique ID.""" + return self._uuid + @property def name(self): """Return the name of the device.""" From 064cc52ad6e9de6e0f657eccae3ce9ddc0e7ab6d Mon Sep 17 00:00:00 2001 From: James Hilliard Date: Sun, 2 Aug 2020 21:52:53 -0600 Subject: [PATCH 262/362] Add config flow to HLK-SW16 (#37190) * Add config flow to HLK-SW16 * Use entry_id for unique_id * Add options update capability * Refactor entry_id under domain * Remove name from config * Set options * Remove options flow * remove unneccesary else block from validate_input and move domain cleanup to async_unload_entry * Add tests and config import * Add back config schema * Remove config import * Refactor unload * Add back config import * Update coveragerc * Don't mock validate_input * Test duplicate configs * Add import test * Use patch for timeout test * Use mock for testing timeout * Use MockSW16Client for tests * Check mock_calls count * Remove unused NameExists exception * Remove title from strings.json * Mock setup for import test * Set PARALLEL_UPDATES for switch * Move hass.data.setdefault(DOMAIN, {}) to async_setup_entry --- .coveragerc | 3 +- CODEOWNERS | 1 + homeassistant/components/hlk_sw16/__init__.py | 171 +++++++++------- .../components/hlk_sw16/config_flow.py | 96 +++++++++ homeassistant/components/hlk_sw16/const.py | 9 + homeassistant/components/hlk_sw16/errors.py | 14 ++ .../components/hlk_sw16/manifest.json | 11 +- .../components/hlk_sw16/strings.json | 21 ++ homeassistant/components/hlk_sw16/switch.py | 26 +-- .../components/hlk_sw16/translations/en.json | 22 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/hlk_sw16/__init__.py | 1 + tests/components/hlk_sw16/test_config_flow.py | 193 ++++++++++++++++++ 14 files changed, 482 insertions(+), 90 deletions(-) create mode 100644 homeassistant/components/hlk_sw16/config_flow.py create mode 100644 homeassistant/components/hlk_sw16/const.py create mode 100644 homeassistant/components/hlk_sw16/errors.py create mode 100644 homeassistant/components/hlk_sw16/strings.json create mode 100644 homeassistant/components/hlk_sw16/translations/en.json create mode 100644 tests/components/hlk_sw16/__init__.py create mode 100644 tests/components/hlk_sw16/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 1d37b7bc055..81f00ab6968 100644 --- a/.coveragerc +++ b/.coveragerc @@ -351,7 +351,8 @@ omit = homeassistant/components/hisense_aehw4a1/* homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/* - homeassistant/components/hlk_sw16/* + homeassistant/components/hlk_sw16/__init__.py + homeassistant/components/hlk_sw16/switch.py homeassistant/components/home_connect/* homeassistant/components/homematic/* homeassistant/components/homematic/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index e69cf26f073..1d4d38fa4e1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -172,6 +172,7 @@ homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core homeassistant/components/hive/* @Rendili @KJonline +homeassistant/components/hlk_sw16/* @jameshilliard homeassistant/components/home_connect/* @DavidMStraub homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @bdraco diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index 3319ce6bee7..91b269cc520 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -4,31 +4,28 @@ import logging from hlk_sw16 import create_hlk_sw16_connection import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_SWITCHES, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from .const import ( + CONNECTION_TIMEOUT, + DEFAULT_KEEP_ALIVE_INTERVAL, + DEFAULT_PORT, + DEFAULT_RECONNECT_INTERVAL, + DOMAIN, +) + _LOGGER = logging.getLogger(__name__) DATA_DEVICE_REGISTER = "hlk_sw16_device_register" -DEFAULT_RECONNECT_INTERVAL = 10 -DEFAULT_KEEP_ALIVE_INTERVAL = 3 -CONNECTION_TIMEOUT = 10 -DEFAULT_PORT = 8080 - -DOMAIN = "hlk_sw16" +DATA_DEVICE_LISTENER = "hlk_sw16_device_listener" SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) @@ -57,84 +54,112 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): - """Set up the HLK-SW16 switch.""" - # Allow platform to specify function to register new unknown devices + """Component setup, do nothing.""" + if DOMAIN not in config: + return True - hass.data[DATA_DEVICE_REGISTER] = {} - - def add_device(device): - switches = config[DOMAIN][device][CONF_SWITCHES] - - host = config[DOMAIN][device][CONF_HOST] - port = config[DOMAIN][device][CONF_PORT] - - @callback - def disconnected(): - """Schedule reconnect after connection has been lost.""" - _LOGGER.warning("HLK-SW16 %s disconnected", device) - async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", False) - - @callback - def reconnected(): - """Schedule reconnect after connection has been lost.""" - _LOGGER.warning("HLK-SW16 %s connected", device) - async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", True) - - async def connect(): - """Set up connection and hook it into HA for reconnect/shutdown.""" - _LOGGER.info("Initiating HLK-SW16 connection to %s", device) - - client = await create_hlk_sw16_connection( - host=host, - port=port, - disconnect_callback=disconnected, - reconnect_callback=reconnected, - loop=hass.loop, - timeout=CONNECTION_TIMEOUT, - reconnect_interval=DEFAULT_RECONNECT_INTERVAL, - keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + for device_id in config[DOMAIN]: + conf = config[DOMAIN][device_id] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: conf[CONF_HOST], CONF_PORT: conf[CONF_PORT]}, ) - - hass.data[DATA_DEVICE_REGISTER][device] = client - - # Load platforms - hass.async_create_task( - async_load_platform(hass, "switch", DOMAIN, (switches, device), config) - ) - - # handle shutdown of HLK-SW16 asyncio transport - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, lambda x: client.stop() - ) - - _LOGGER.info("Connected to HLK-SW16 device: %s", device) - - hass.loop.create_task(connect()) - - for device in config[DOMAIN]: - add_device(device) + ) return True +async def async_setup_entry(hass, entry): + """Set up the HLK-SW16 switch.""" + hass.data.setdefault(DOMAIN, {}) + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + address = f"{host}:{port}" + + hass.data[DOMAIN][entry.entry_id] = {} + + @callback + def disconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 %s disconnected", address) + async_dispatcher_send( + hass, f"hlk_sw16_device_available_{entry.entry_id}", False + ) + + @callback + def reconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning("HLK-SW16 %s connected", address) + async_dispatcher_send(hass, f"hlk_sw16_device_available_{entry.entry_id}", True) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info("Initiating HLK-SW16 connection to %s", address) + + client = await create_hlk_sw16_connection( + host=host, + port=port, + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) + + hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] = client + + # Load entities + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "switch") + ) + + _LOGGER.info("Connected to HLK-SW16 device: %s", address) + + hass.loop.create_task(connect()) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + client = hass.data[DOMAIN][entry.entry_id].pop(DATA_DEVICE_REGISTER) + client.stop() + unload_ok = await hass.config_entries.async_forward_entry_unload(entry, "switch") + + if unload_ok: + if hass.data[DOMAIN][entry.entry_id]: + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok + + class SW16Device(Entity): """Representation of a HLK-SW16 device. Contains the common logic for HLK-SW16 entities. """ - def __init__(self, relay_name, device_port, device_id, client): + def __init__(self, device_port, entry_id, client): """Initialize the device.""" # HLK-SW16 specific attributes for every component type - self._device_id = device_id + self._entry_id = entry_id self._device_port = device_port self._is_on = None self._client = client - self._name = relay_name + self._name = device_port + + @property + def unique_id(self): + """Return a unique ID.""" + return f"{self._entry_id}_{self._device_port}" @callback def handle_event_callback(self, event): """Propagate changes through ha.""" - _LOGGER.debug("Relay %s new state callback: %r", self._device_port, event) + _LOGGER.debug("Relay %s new state callback: %r", self.unique_id, event) self._is_on = event self.async_write_ha_state() @@ -167,7 +192,7 @@ class SW16Device(Entity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"hlk_sw16_device_available_{self._device_id}", + f"hlk_sw16_device_available_{self._entry_id}", self._availability_callback, ) ) diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py new file mode 100644 index 00000000000..0a9ac79d1b7 --- /dev/null +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for HLK-SW16.""" +import asyncio + +from hlk_sw16 import create_hlk_sw16_connection +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from .const import ( + CONNECTION_TIMEOUT, + DEFAULT_KEEP_ALIVE_INTERVAL, + DEFAULT_PORT, + DEFAULT_RECONNECT_INTERVAL, + DOMAIN, +) +from .errors import AlreadyConfigured, CannotConnect + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): vol.Coerce(int), + } +) + + +async def connect_client(hass, user_input): + """Connect the HLK-SW16 client.""" + client_aw = create_hlk_sw16_connection( + host=user_input[CONF_HOST], + port=user_input[CONF_PORT], + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL, + keep_alive_interval=DEFAULT_KEEP_ALIVE_INTERVAL, + ) + return await asyncio.wait_for(client_aw, timeout=CONNECTION_TIMEOUT) + + +async def validate_input(hass: HomeAssistant, user_input): + """Validate the user input allows us to connect.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if ( + entry.data[CONF_HOST] == user_input[CONF_HOST] + and entry.data[CONF_PORT] == user_input[CONF_PORT] + ): + raise AlreadyConfigured + + try: + client = await connect_client(hass, user_input) + except asyncio.TimeoutError: + raise CannotConnect + try: + + def disconnect_callback(): + if client.in_transaction: + client.active_transaction.set_exception(CannotConnect) + + client.disconnect_callback = disconnect_callback + await client.status() + except CannotConnect: + client.disconnect_callback = None + client.stop() + raise CannotConnect + else: + client.disconnect_callback = None + client.stop() + + +class SW16FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a HLK-SW16 config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + address = f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}" + return self.async_create_entry(title=address, data=user_input) + except AlreadyConfigured: + errors["base"] = "already_configured" + except CannotConnect: + errors["base"] = "cannot_connect" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/hlk_sw16/const.py b/homeassistant/components/hlk_sw16/const.py new file mode 100644 index 00000000000..22bc29e7599 --- /dev/null +++ b/homeassistant/components/hlk_sw16/const.py @@ -0,0 +1,9 @@ +"""Constants for HLK-SW16 component.""" + +DOMAIN = "hlk_sw16" + +DEFAULT_NAME = "HLK-SW16" +DEFAULT_PORT = 8080 +DEFAULT_RECONNECT_INTERVAL = 10 +DEFAULT_KEEP_ALIVE_INTERVAL = 3 +CONNECTION_TIMEOUT = 10 diff --git a/homeassistant/components/hlk_sw16/errors.py b/homeassistant/components/hlk_sw16/errors.py new file mode 100644 index 00000000000..5b29587deba --- /dev/null +++ b/homeassistant/components/hlk_sw16/errors.py @@ -0,0 +1,14 @@ +"""Errors for the HLK-SW16 component.""" +from homeassistant.exceptions import HomeAssistantError + + +class SW16Exception(HomeAssistantError): + """Base class for HLK-SW16 exceptions.""" + + +class AlreadyConfigured(SW16Exception): + """HLK-SW16 is already configured.""" + + +class CannotConnect(SW16Exception): + """Unable to connect to the HLK-SW16.""" diff --git a/homeassistant/components/hlk_sw16/manifest.json b/homeassistant/components/hlk_sw16/manifest.json index 7574076fd43..aee829f593a 100644 --- a/homeassistant/components/hlk_sw16/manifest.json +++ b/homeassistant/components/hlk_sw16/manifest.json @@ -2,6 +2,11 @@ "domain": "hlk_sw16", "name": "Hi-Link HLK-SW16", "documentation": "https://www.home-assistant.io/integrations/hlk_sw16", - "requirements": ["hlk-sw16==0.0.8"], - "codeowners": [] -} + "requirements": [ + "hlk-sw16==0.0.8" + ], + "codeowners": [ + "@jameshilliard" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/strings.json b/homeassistant/components/hlk_sw16/strings.json new file mode 100644 index 00000000000..2480ac60918 --- /dev/null +++ b/homeassistant/components/hlk_sw16/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index e9c190678a6..9bd10ea765d 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -1,30 +1,30 @@ """Support for HLK-SW16 switches.""" -import logging - from homeassistant.components.switch import ToggleEntity -from homeassistant.const import CONF_NAME from . import DATA_DEVICE_REGISTER, SW16Device +from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 -def devices_from_config(hass, domain_config): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the HLK-SW16 switches.""" + + +def devices_from_entities(hass, entry): """Parse configuration and add HLK-SW16 switch devices.""" - switches = domain_config[0] - device_id = domain_config[1] - device_client = hass.data[DATA_DEVICE_REGISTER][device_id] + device_client = hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_REGISTER] devices = [] - for device_port, device_config in switches.items(): - device_name = device_config.get(CONF_NAME, device_port) - device = SW16Switch(device_name, device_port, device_id, device_client) + for i in range(16): + device_port = f"{i:01x}" + device = SW16Switch(device_port, entry.entry_id, device_client) devices.append(device) return devices -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the HLK-SW16 platform.""" - async_add_entities(devices_from_config(hass, discovery_info)) + async_add_entities(devices_from_entities(hass, entry)) class SW16Switch(SW16Device, ToggleEntity): diff --git a/homeassistant/components/hlk_sw16/translations/en.json b/homeassistant/components/hlk_sw16/translations/en.json new file mode 100644 index 00000000000..75ec99a5512 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + } + }, + "title": "Hi-Link HLK-SW16" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index df56e071923..9fab383d718 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -71,6 +71,7 @@ FLOWS = [ "harmony", "heos", "hisense_aehw4a1", + "hlk_sw16", "home_connect", "homekit", "homekit_controller", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a23e0dd8ec..506136abe32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,6 +352,9 @@ hdate==0.9.5 # homeassistant.components.here_travel_time herepy==2.0.0 +# homeassistant.components.hlk_sw16 +hlk-sw16==0.0.8 + # homeassistant.components.pi_hole hole==0.5.1 diff --git a/tests/components/hlk_sw16/__init__.py b/tests/components/hlk_sw16/__init__.py new file mode 100644 index 00000000000..3b8278ee353 --- /dev/null +++ b/tests/components/hlk_sw16/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hi-Link HLK-SW16 integration.""" diff --git a/tests/components/hlk_sw16/test_config_flow.py b/tests/components/hlk_sw16/test_config_flow.py new file mode 100644 index 00000000000..6f9d5592893 --- /dev/null +++ b/tests/components/hlk_sw16/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the Hi-Link HLK-SW16 config flow.""" +import asyncio + +from homeassistant import config_entries, setup +from homeassistant.components.hlk_sw16.const import DOMAIN + +from tests.async_mock import patch + + +class MockSW16Client: + """Class to mock the SW16Client client.""" + + def __init__(self, fail): + """Initialise client with failure modes.""" + self.fail = fail + self.disconnect_callback = None + self.in_transaction = False + self.active_transaction = None + + async def setup(self): + """Mock successful setup.""" + fut = asyncio.Future() + fut.set_result(True) + return fut + + async def status(self): + """Mock status based on failure mode.""" + self.in_transaction = True + self.active_transaction = asyncio.Future() + if self.fail: + if self.disconnect_callback: + self.disconnect_callback() + return await self.active_transaction + else: + self.active_transaction.set_result(True) + return self.active_transaction + + def stop(self): + """Mock client stop.""" + self.in_transaction = False + self.active_transaction = None + + +async def create_mock_hlk_sw16_connection(fail): + """Create a mock HLK-SW16 client.""" + client = MockSW16Client(fail) + await client.setup() + return client + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False) + + with patch( + "homeassistant.components.hlk_sw16.config_flow.create_hlk_sw16_connection", + return_value=mock_hlk_sw16_connection, + ), patch( + "homeassistant.components.hlk_sw16.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hlk_sw16.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "127.0.0.1:8080" + assert result2["data"] == { + "host": "127.0.0.1", + "port": 8080, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False) + + with patch( + "homeassistant.components.hlk_sw16.config_flow.create_hlk_sw16_connection", + return_value=mock_hlk_sw16_connection, + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result3["type"] == "form" + assert result3["errors"] == {} + + result4 = await hass.config_entries.flow.async_configure(result3["flow_id"], conf,) + + assert result4["type"] == "form" + assert result4["errors"] == {"base": "already_configured"} + await hass.async_block_till_done() + + +async def test_import(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(False) + + with patch( + "homeassistant.components.hlk_sw16.config_flow.connect_client", + return_value=mock_hlk_sw16_connection, + ), patch( + "homeassistant.components.hlk_sw16.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.hlk_sw16.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "127.0.0.1:8080" + assert result2["data"] == { + "host": "127.0.0.1", + "port": 8080, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_data(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_hlk_sw16_connection = await create_mock_hlk_sw16_connection(True) + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + with patch( + "homeassistant.components.hlk_sw16.config_flow.connect_client", + return_value=mock_hlk_sw16_connection, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + conf = { + "host": "127.0.0.1", + "port": 8080, + } + + with patch( + "homeassistant.components.hlk_sw16.config_flow.connect_client", + side_effect=asyncio.TimeoutError, + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], conf, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} From 364aaceb1c011131b1899eb80e30a8bcdf07eafe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2020 10:12:37 +0200 Subject: [PATCH 263/362] Bump actions/upload-artifact from v2.1.1 to v2.1.2 (#38505) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from v2.1.1 to v2.1.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v2.1.1...c8879bf5aef7bef66f9b82b197f34c4eeeb1731b) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1f7434e6aca..a3062e5cc4c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -737,7 +737,7 @@ jobs: -p no:sugar \ tests - name: Upload coverage artifact - uses: actions/upload-artifact@v2.1.1 + uses: actions/upload-artifact@v2.1.2 with: name: coverage-${{ matrix.python-version }}-group${{ matrix.group }} path: .coverage From 67312e2d424928803c9fe1bec9848a62c2db46cf Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 3 Aug 2020 05:40:48 -0500 Subject: [PATCH 264/362] Fix lookup by Plex media key when playing on Sonos (#38119) --- homeassistant/components/plex/__init__.py | 5 ++++- tests/components/plex/test_playback.py | 23 +++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 4556422dd00..85d4b43b532 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -222,6 +222,9 @@ def play_on_sonos(hass, service_call): if isinstance(content, int): content = {"plex_key": content} + content_type = PLEX_DOMAIN + else: + content_type = "music" plex_server_name = content.get("plex_server") shuffle = content.pop("shuffle", 0) @@ -246,7 +249,7 @@ def play_on_sonos(hass, service_call): ) return - media = plex_server.lookup_media("music", **content) + media = plex_server.lookup_media(content_type, **content) if media is None: _LOGGER.error("Media could not be found: %s", content) return diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index 82682ea0ac2..b031aff25cd 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -1,4 +1,6 @@ """Tests for Plex player playback methods/services.""" +from plexapi.exceptions import NotFound + from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -52,7 +54,7 @@ async def test_sonos_playback(hass): True, ) - # Test success with dict + # Test success with plex_key with patch.object( hass.components.sonos, "get_coordinator_name", @@ -69,7 +71,7 @@ async def test_sonos_playback(hass): True, ) - # Test success with plex_key + # Test success with dict with patch.object( hass.components.sonos, "get_coordinator_name", @@ -86,6 +88,23 @@ async def test_sonos_playback(hass): True, ) + # Test media lookup failure + with patch.object( + hass.components.sonos, + "get_coordinator_name", + return_value="media_player.sonos_kitchen", + ), patch.object(mock_plex_server, "fetchItem", side_effect=NotFound): + assert await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_ON_SONOS, + { + ATTR_ENTITY_ID: "media_player.sonos_kitchen", + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: "999", + }, + True, + ) + # Test invalid Plex server requested with patch.object( hass.components.sonos, From e940811a8b5f8e1433c215246cb2ae3204982e0f Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 3 Aug 2020 05:41:24 -0500 Subject: [PATCH 265/362] Clean up Plex clip handling (#38500) --- homeassistant/components/plex/media_player.py | 23 ++++++++----------- homeassistant/components/plex/sensor.py | 6 +++-- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index d467b962dad..1b7db505e29 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -304,7 +305,7 @@ class PlexMediaPlayer(MediaPlayerEntity): self._state = STATE_OFF def _set_media_type(self): - if self._session_type in ["clip", "episode"]: + if self._session_type == "episode": self._media_content_type = MEDIA_TYPE_TVSHOW # season number (00) @@ -334,6 +335,12 @@ class PlexMediaPlayer(MediaPlayerEntity): ) self._media_artist = self._media_album_artist + elif self._session_type == "clip": + _LOGGER.debug( + "Clip content type detected, compatibility may vary: %s", self.name + ) + self._media_content_type = MEDIA_TYPE_VIDEO + def force_idle(self): """Force client to idle.""" self._player_state = STATE_IDLE @@ -397,19 +404,7 @@ class PlexMediaPlayer(MediaPlayerEntity): @property def media_content_type(self): """Return the content type of current playing media.""" - if self._session_type == "clip": - _LOGGER.debug( - "Clip content type detected, compatibility may vary: %s", self.name - ) - return MEDIA_TYPE_TVSHOW - if self._session_type == "episode": - return MEDIA_TYPE_TVSHOW - if self._session_type == "movie": - return MEDIA_TYPE_MOVIE - if self._session_type == "track": - return MEDIA_TYPE_MUSIC - - return None + return self._media_content_type @property def media_artist(self): diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 47d12fb35d2..a3b465dfdb0 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -82,7 +82,7 @@ class PlexSensor(Entity): now_playing_user = f"{user} - {device}" now_playing_title = "" - if sess.TYPE in ["clip", "episode"]: + if sess.TYPE == "episode": # example: # "Supernatural (2005) - s01e13 - Route 666" @@ -111,7 +111,7 @@ class PlexSensor(Entity): track_album = sess.parentTitle track_title = sess.title now_playing_title = f"{track_artist} - {track_album} - {track_title}" - else: + elif sess.TYPE == "movie": # example: # "picture_of_last_summer_camp (2015)" # "The Incredible Hulk (2008)" @@ -119,6 +119,8 @@ class PlexSensor(Entity): year = await self.hass.async_add_executor_job(getattr, sess, "year") if year is not None: now_playing_title += f" ({year})" + else: + now_playing_title = sess.title now_playing.append((now_playing_user, now_playing_title)) self._state = len(self.sessions) From 8f2abc2ee10a8e99b616b1b22bab8b2f47dc4822 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2020 00:55:15 -1000 Subject: [PATCH 266/362] Fix harmony activity starting initial state (#38439) --- homeassistant/components/harmony/remote.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 1ebcfbaa760..fe2f0535308 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -140,7 +140,7 @@ class HarmonyRemote(remote.RemoteEntity, RestoreEntity): self._current_activity = ACTIVITY_POWER_OFF self.default_activity = activity self._activity_starting = None - self._is_initial_update = False + self._is_initial_update = True self._client = HarmonyClient(ip_address=host) self._config_path = out_path self.delay_secs = delay_secs From 2f1d989df2b12d9792fc9f17377e32e7f02f3984 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2020 00:55:30 -1000 Subject: [PATCH 267/362] Bump hass-nabucasa to avoid the performance penalty loading ecdsa (#38056) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 8d58e98c0e5..e4ca94d6b38 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.34.7"], + "requirements": ["hass-nabucasa==0.35.0"], "dependencies": ["http", "webhook", "alexa"], "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6066ee71715..f06f6f6fee3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.9.2 defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 -hass-nabucasa==0.34.7 +hass-nabucasa==0.35.0 home-assistant-frontend==20200716.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 diff --git a/requirements_all.txt b/requirements_all.txt index 79838059827..af8f872369e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -706,7 +706,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.7 +hass-nabucasa==0.35.0 # homeassistant.components.jewish_calendar hdate==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 506136abe32..dd5279bba02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.7 +hass-nabucasa==0.35.0 # homeassistant.components.jewish_calendar hdate==0.9.5 From b3fd8a834301b3e9a9d7abb84f0c0926753c87f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2020 03:01:15 -1000 Subject: [PATCH 268/362] Fix flapping chained task logging test (#38492) --- tests/test_core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 12ed00fde2c..167eda3f6cb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1436,14 +1436,14 @@ async def test_chained_logging_hits_log_timeout(hass, caplog): async def _task_chain_1(): nonlocal created created += 1 - if created > 10: + if created > 1000: return hass.async_create_task(_task_chain_2()) async def _task_chain_2(): nonlocal created created += 1 - if created > 10: + if created > 1000: return hass.async_create_task(_task_chain_1()) From 85497c6b7559bae31fe181a2a80b2007cafc1dd6 Mon Sep 17 00:00:00 2001 From: Shane Qi Date: Mon, 3 Aug 2020 09:50:47 -0500 Subject: [PATCH 269/362] Fix Lutron Caseta devices loading when missing serials (#38255) Co-authored-by: Martin Hjelmare --- .../components/lutron_caseta/binary_sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 4295e3bb367..b517dda1ba3 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -56,6 +56,16 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): """Return a unique identifier.""" return f"occupancygroup_{self.device_id}" + @property + def device_info(self): + """Return the device info. + + Sensor entities are aggregated from one or more physical + sensors by each room. Therefore, there shouldn't be devices + related to any sensor entities. + """ + return None # pylint: disable=useless-return + @property def device_state_attributes(self): """Return the state attributes.""" From a187183bd11cbe902565d65c799bb9ce63436dbc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 3 Aug 2020 17:52:58 +0200 Subject: [PATCH 270/362] Update frontend to 20200803.0 (#38514) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ad68adfd490..41b9ad9a591 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200716.0"], + "requirements": ["home-assistant-frontend==20200803.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f06f6f6fee3..c8f1e81cc2c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 hass-nabucasa==0.35.0 -home-assistant-frontend==20200716.0 +home-assistant-frontend==20200803.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index af8f872369e..2c653137676 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -733,7 +733,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200716.0 +home-assistant-frontend==20200803.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd5279bba02..aab52d02c15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200716.0 +home-assistant-frontend==20200803.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 86e38a84677586614c7120a89706781a674f10fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Aug 2020 17:54:09 +0200 Subject: [PATCH 271/362] Add missing MQTT strings (#38513) --- homeassistant/components/mqtt/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index d10bc8bc4e6..75c3fdec260 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -63,10 +63,12 @@ "description": "Please select MQTT options.", "data": { "discovery": "Enable discovery", + "birth_enable": "Enable birth message", "birth_topic": "Birth message topic", "birth_payload": "Birth message payload", "birth_qos": "Birth message QoS", "birth_retain": "Birth message retain", + "will_enable": "Enable birth message", "will_topic": "Will message topic", "will_payload": "Will message payload", "will_qos": "Will message QoS", From 809c2980dfa50c5606fb807b37d37e729d95f2be Mon Sep 17 00:00:00 2001 From: Eugene Prystupa Date: Mon, 3 Aug 2020 11:55:04 -0400 Subject: [PATCH 272/362] =?UTF-8?q?Log=20the=20version=20reported=20by=20B?= =?UTF-8?q?ond=20hub=20upon=20startup=20to=20facilitate=20troub=E2=80=A6?= =?UTF-8?q?=20(#38508)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/bond/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index b45ca9bd251..5a9fff692fa 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -78,6 +78,7 @@ class BondHub: async def setup(self): """Read hub version information.""" self._version = await self.bond.version() + _LOGGER.debug("Bond reported the following version info: %s", self._version) # Fetch all available devices using Bond API. device_ids = await self.bond.devices() From 0b0e323632682aa0a61fff25859b283ce3fd3c6a Mon Sep 17 00:00:00 2001 From: Cooper Dale Date: Mon, 3 Aug 2020 18:22:52 +0200 Subject: [PATCH 273/362] Fix missing .name at entity_id in service example (#38515) for propper filename --- homeassistant/components/camera/services.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 14f94976984..70d33da884c 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -36,7 +36,7 @@ snapshot: example: "camera.living_room_camera" filename: description: Template of a Filename. Variable is entity_id. - example: "/tmp/snapshot_{{ entity_id }}" + example: "/tmp/snapshot_{{ entity_id.name }}.jpg" play_stream: description: Play camera stream on supported media player. @@ -59,7 +59,7 @@ record: example: "camera.living_room_camera" filename: description: Template of a Filename. Variable is entity_id. Must be mp4. - example: "/tmp/snapshot_{{ entity_id }}.mp4" + example: "/tmp/snapshot_{{ entity_id.name }}.mp4" duration: description: (Optional) Target recording length (in seconds). default: 30 From 53e162c922b913fe40205f17cb524122a7138e29 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 3 Aug 2020 11:33:49 -0600 Subject: [PATCH 274/362] Remove deprecated Slack attachments framework (#38139) --- homeassistant/components/slack/notify.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index b7f3d81feb0..d5c784398cf 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -24,7 +24,6 @@ import homeassistant.helpers.template as template _LOGGER = logging.getLogger(__name__) -ATTR_ATTACHMENTS = "attachments" ATTR_BLOCKS = "blocks" ATTR_BLOCKS_TEMPLATE = "blocks_template" ATTR_FILE = "file" @@ -52,11 +51,7 @@ DATA_FILE_SCHEMA = vol.Schema( ) DATA_TEXT_ONLY_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ATTACHMENTS): list, - vol.Optional(ATTR_BLOCKS): list, - vol.Optional(ATTR_BLOCKS_TEMPLATE): list, - } + {vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list} ) DATA_SCHEMA = vol.All( @@ -196,15 +191,12 @@ class SlackNotificationService(BaseNotificationService): except ClientError as err: _LOGGER.error("Error while uploading file message: %s", err) - async def _async_send_text_only_message( - self, targets, message, title, attachments, blocks - ): + async def _async_send_text_only_message(self, targets, message, title, blocks): """Send a text-only message.""" tasks = { target: self._client.chat_postMessage( channel=target, text=message, - attachments=attachments, blocks=blocks, icon_emoji=self._icon, link_names=True, @@ -242,15 +234,6 @@ class SlackNotificationService(BaseNotificationService): # Message Type 1: A text-only message if ATTR_FILE not in data: - attachments = data.get(ATTR_ATTACHMENTS, {}) - if attachments: - _LOGGER.warning( - "Attachments are deprecated and part of Slack's legacy API; " - "support for them will be dropped in 0.114.0. In most cases, " - "Blocks should be used instead: " - "https://www.home-assistant.io/integrations/slack/" - ) - if ATTR_BLOCKS_TEMPLATE in data: blocks = _async_templatize_blocks(self.hass, data[ATTR_BLOCKS_TEMPLATE]) elif ATTR_BLOCKS in data: @@ -259,7 +242,7 @@ class SlackNotificationService(BaseNotificationService): blocks = {} return await self._async_send_text_only_message( - targets, message, title, attachments, blocks + targets, message, title, blocks ) # Message Type 2: A message that uploads a remote file From 6fd39f57eed13e06ed2407363b3b80b1bce07dc6 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 3 Aug 2020 11:35:36 -0600 Subject: [PATCH 275/362] Remove YAML configuration support for IQVIA (#38141) --- homeassistant/components/iqvia/__init__.py | 45 +++----------- homeassistant/components/iqvia/config_flow.py | 44 ++++---------- homeassistant/components/iqvia/strings.json | 8 ++- .../components/iqvia/translations/en.json | 4 +- tests/components/iqvia/test_config_flow.py | 60 +++++++++---------- 5 files changed, 55 insertions(+), 106 deletions(-) diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index cd75e88bb44..3e67e2639e2 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -5,12 +5,10 @@ import logging from pyiqvia import Client from pyiqvia.errors import InvalidZipError, IQVIAError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import callback -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -18,13 +16,11 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from .config_flow import configured_instances from .const import ( CONF_ZIP_CODE, DATA_CLIENT, DATA_LISTENER, DOMAIN, - SENSORS, TOPIC_DATA_UPDATE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX, @@ -56,23 +52,6 @@ DATA_CONFIG = "config" DEFAULT_ATTRIBUTION = "Data provided by IQVIA™" DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(CONF_MONITORED_CONDITIONS, invalidation_version="0.114.0"), - vol.Schema( - { - vol.Required(CONF_ZIP_CODE): str, - vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(SENSORS) - ): vol.All(cv.ensure_list, [vol.In(SENSORS)]), - } - ), - ) - }, - extra=vol.ALLOW_EXTRA, -) - @callback def async_get_api_category(sensor_type): @@ -86,20 +65,6 @@ async def async_setup(hass, config): hass.data[DOMAIN][DATA_CLIENT] = {} hass.data[DOMAIN][DATA_LISTENER] = {} - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - if conf[CONF_ZIP_CODE] in configured_instances(hass): - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - return True @@ -107,6 +72,12 @@ async def async_setup_entry(hass, config_entry): """Set up IQVIA as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) + if not config_entry.unique_id: + # If the config entry doesn't already have a unique ID, set one: + hass.config_entries.async_update_entry( + config_entry, **{"unique_id": config_entry.data[CONF_ZIP_CODE]} + ) + iqvia = IQVIAData(hass, Client(config_entry.data[CONF_ZIP_CODE], websession)) try: diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py index 6a57f0f24d4..e43c61985d6 100644 --- a/homeassistant/components/iqvia/config_flow.py +++ b/homeassistant/components/iqvia/config_flow.py @@ -1,28 +1,15 @@ """Config flow to configure the IQVIA component.""" - -from collections import OrderedDict - from pyiqvia import Client from pyiqvia.errors import InvalidZipError import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import CONF_ZIP_CODE, DOMAIN +from .const import CONF_ZIP_CODE, DOMAIN # pylint:disable=unused-import -@callback -def configured_instances(hass): - """Return a set of configured IQVIA instances.""" - return { - entry.data[CONF_ZIP_CODE] for entry in hass.config_entries.async_entries(DOMAIN) - } - - -@config_entries.HANDLERS.register(DOMAIN) -class IQVIAFlowHandler(config_entries.ConfigFlow): +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle an IQVIA config flow.""" VERSION = 1 @@ -30,34 +17,25 @@ class IQVIAFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the config flow.""" - self.data_schema = OrderedDict() - self.data_schema[vol.Required(CONF_ZIP_CODE)] = str - - async def _show_form(self, errors=None): - """Show the form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(self.data_schema), - errors=errors if errors else {}, - ) - - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + self.data_schema = vol.Schema({vol.Required(CONF_ZIP_CODE): str}) async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" if not user_input: - return await self._show_form() + return self.async_show_form(step_id="user", data_schema=self.data_schema) - if user_input[CONF_ZIP_CODE] in configured_instances(self.hass): - return await self._show_form({CONF_ZIP_CODE: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_ZIP_CODE]) + self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) try: Client(user_input[CONF_ZIP_CODE], websession) except InvalidZipError: - return await self._show_form({CONF_ZIP_CODE: "invalid_zip_code"}) + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + errors={CONF_ZIP_CODE: "invalid_zip_code"}, + ) return self.async_create_entry(title=user_input[CONF_ZIP_CODE], data=user_input) diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json index efc9582e20a..b0d82430ef7 100644 --- a/homeassistant/components/iqvia/strings.json +++ b/homeassistant/components/iqvia/strings.json @@ -4,12 +4,16 @@ "user": { "title": "IQVIA", "description": "Fill out your U.S. or Canadian ZIP code.", - "data": { "zip_code": "ZIP Code" } + "data": { + "zip_code": "ZIP Code" + } } }, "error": { - "identifier_exists": "ZIP code already registered", "invalid_zip_code": "ZIP code is invalid" + }, + "abort": { + "already_configured": "This ZIP code has already been configured." } } } diff --git a/homeassistant/components/iqvia/translations/en.json b/homeassistant/components/iqvia/translations/en.json index 63ffc145594..c9b3526102b 100644 --- a/homeassistant/components/iqvia/translations/en.json +++ b/homeassistant/components/iqvia/translations/en.json @@ -1,7 +1,9 @@ { "config": { + "abort": { + "already_configured": "This ZIP code has already been configured." + }, "error": { - "identifier_exists": "ZIP code already registered", "invalid_zip_code": "ZIP code is invalid" }, "step": { diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py index 4cc30958b23..6b6872d5f67 100644 --- a/tests/components/iqvia/test_config_flow.py +++ b/tests/components/iqvia/test_config_flow.py @@ -1,7 +1,9 @@ """Define tests for the IQVIA config flow.""" from homeassistant import data_entry_flow -from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN, config_flow +from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from tests.async_mock import patch from tests.common import MockConfigEntry @@ -9,57 +11,49 @@ async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = {CONF_ZIP_CODE: "12345"} - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id="12345", data=conf).add_to_hass(hass) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_ZIP_CODE: "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_invalid_zip_code(hass): """Test that an invalid ZIP code key throws an error.""" conf = {CONF_ZIP_CODE: "abcde"} - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - result = await flow.async_step_user(user_input=conf) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {CONF_ZIP_CODE: "invalid_zip_code"} async def test_show_form(hass): """Test that the form is served with no input.""" - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" -async def test_step_import(hass): - """Test that the import step works.""" - conf = {CONF_ZIP_CODE: "12345"} - - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass - - result = await flow.async_step_import(import_config=conf) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345" - assert result["data"] == {CONF_ZIP_CODE: "12345"} - - async def test_step_user(hass): - """Test that the user step works.""" + """Test that the user step works (without MFA).""" conf = {CONF_ZIP_CODE: "12345"} - flow = config_flow.IQVIAFlowHandler() - flow.hass = hass + with patch( + "homeassistant.components.simplisafe.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - result = await flow.async_step_user(user_input=conf) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "12345" - assert result["data"] == {CONF_ZIP_CODE: "12345"} + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "12345" + assert result["data"] == {CONF_ZIP_CODE: "12345"} From d498246fb6b8c61b9f645eb24d02898f561fc3e5 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 3 Aug 2020 12:30:46 -0600 Subject: [PATCH 276/362] Bump aioambient to 1.2.1 (#38519) --- homeassistant/components/ambient_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index cd2a0f5605f..916f1378fd0 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,6 +3,6 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.0"], + "requirements": ["aioambient==1.2.1"], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2c653137676..2f47ecaa98c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -142,7 +142,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.2.0 +aioambient==1.2.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aab52d02c15..50655192e92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.2.0 +aioambient==1.2.1 # homeassistant.components.asuswrt aioasuswrt==1.2.7 From 63403f894dd146a92925695a854a73344d348810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 3 Aug 2020 22:20:12 +0300 Subject: [PATCH 277/362] Fix run-in-env.sh sh options (#38520) Shebang takes only one arg, regression in f6540e30023e9f1abe7f018bc93ac4b4e2954db9 --- script/run-in-env.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/run-in-env.sh b/script/run-in-env.sh index cc4d3784693..0f531f235b6 100755 --- a/script/run-in-env.sh +++ b/script/run-in-env.sh @@ -1,4 +1,5 @@ -#!/usr/bin/env sh -eu +#!/usr/bin/env sh +set -eu # Activate pyenv and virtualenv if present, then run the specified command From 62c664fbbda774d46b146ef22354ebd3ef753472 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Aug 2020 15:00:44 -1000 Subject: [PATCH 278/362] Reduce time to reload yaml and check configuration (#38469) * Reduce time to reload yaml and check configuration We spend a significant amount of time compiling templates that we have already compiled. Use an LRU cache to avoid re-compiling templates that we frequently use. * pylint * switch to WeakValueDictionary * preen --- homeassistant/helpers/template.py | 22 ++++++++++++++++++++++ tests/helpers/test_template.py | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 618bc6ea4f7..ef0b578811e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -10,6 +10,7 @@ import random import re from typing import Any, Dict, Iterable, List, Optional, Union from urllib.parse import urlencode as urllib_urlencode +import weakref import jinja2 from jinja2 import contextfilter, contextfunction @@ -958,6 +959,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): """Initialise template environment.""" super().__init__() self.hass = hass + self.template_cache = weakref.WeakValueDictionary() self.filters["round"] = forgiving_round self.filters["multiply"] = multiply self.filters["log"] = logarithm @@ -1042,5 +1044,25 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): """Test if attribute is safe.""" return isinstance(obj, Namespace) or super().is_safe_attribute(obj, attr, value) + def compile(self, source, name=None, filename=None, raw=False, defer_init=False): + """Compile the template.""" + if ( + name is not None + or filename is not None + or raw is not False + or defer_init is not False + ): + # If there are any non-default keywords args, we do + # not cache. In prodution we currently do not have + # any instance of this. + return super().compile(source, name, filename, raw, defer_init) + + cached = self.template_cache.get(source) + + if cached is None: + cached = self.template_cache[source] = super().compile(source) + + return cached + _NO_HASS_ENV = TemplateEnvironment(None) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f755e4e1084..89486129760 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1885,3 +1885,30 @@ def test_urlencode(hass): hass, ) assert tpl.async_render() == "the%20quick%20brown%20fox%20%3D%20true" + + +async def test_cache_garbage_collection(): + """Test caching a template.""" + template_string = ( + "{% set dict = {'foo': 'x&y', 'bar': 42} %} {{ dict | urlencode }}" + ) + tpl = template.Template((template_string),) + tpl.ensure_valid() + assert template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access + + tpl2 = template.Template((template_string),) + tpl2.ensure_valid() + assert template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access + + del tpl + assert template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access + del tpl2 + assert not template._NO_HASS_ENV.template_cache.get( + template_string + ) # pylint: disable=protected-access From 988cbf12ce3b856861af0bfa72cdb9912f95fc26 Mon Sep 17 00:00:00 2001 From: Jean-Yves Avenard Date: Tue, 4 Aug 2020 11:30:16 +1000 Subject: [PATCH 279/362] Cache emulated hue states attributes between get and put calls to avoid unexpected alexa errors (#38451) * Wait for the state of the entity to actually change before resolving PUT request Additionally, we cache the entity's properties for up to two seconds for the successive GET state request When Alexa issues a command to a Hue hub; it immediately queries the hub for the entity's state to confirm if the command was successful. It expects the state to be effective immediately after the PUT request has been completed. There may be a delay for the new state to actually be active, this is particularly obvious when using group lights. This leads Alexa to report that the light had an error. So we wait for the state of the entity to actually change before responding to the PUT request. Due to rounding issue when converting the HA range (0..255) to Hue range (1..254) we now cache the state sets by Alexa and return those cached values for up to two seconds so that Alexa gets the same value as it originally set. Fixes #38446 * Add new tests verifying emulated_hue behaviour. * Increase code test coverage. The remaining uncovered lines can't be tested as they mostly check that the hass framework or the http server properly work. This commit doesn't attempt to fix exposed issues as it would be out of scope ; it merely create the tests to exercise the whole code. * Update homeassistant/components/emulated_hue/hue_api.py * Add test for state change wait timeout * Preserve the cache long enough for groups to change * Update tests/components/emulated_hue/test_hue_api.py Co-authored-by: J. Nick Koston --- .../components/emulated_hue/hue_api.py | 72 ++- tests/components/emulated_hue/test_hue_api.py | 455 +++++++++++++++++- 2 files changed, 504 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 739baf0c425..b84e64e6cc6 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ import asyncio import hashlib import logging +import time from homeassistant import core from homeassistant.components import ( @@ -66,10 +67,16 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.network import is_local _LOGGER = logging.getLogger(__name__) +# How long to wait for a state change to happen +STATE_CHANGE_WAIT_TIMEOUT = 5.0 +# How long an entry state's cache will be valid for in seconds. +STATE_CACHED_TIMEOUT = 2.0 + STATE_BRIGHTNESS = "bri" STATE_COLORMODE = "colormode" STATE_HUE = "hue" @@ -515,13 +522,6 @@ class HueOneLightChangeView(HomeAssistantView): if entity.domain in config.off_maps_to_on_domains: service = SERVICE_TURN_ON - # Caching is required because things like scripts and scenes won't - # report as "off" to Alexa if an "off" command is received, because - # they'll map to "on". Thus, instead of reporting its actual - # status, we report what Alexa will want to see, which is the same - # as the actual requested command. - config.cached_states[entity_id] = parsed - # Separate call to turn on needed if turn_on_needed: hass.async_create_task( @@ -534,10 +534,18 @@ class HueOneLightChangeView(HomeAssistantView): ) if service is not None: + state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) ) + if state_will_change: + # Wait for the state to change. + await wait_for_state_change_or_timeout( + hass, entity_id, STATE_CACHED_TIMEOUT + ) + # Create success responses for all received keys json_response = [ create_hue_success_response( @@ -556,16 +564,40 @@ class HueOneLightChangeView(HomeAssistantView): create_hue_success_response(entity_number, val, parsed[key]) ) - # Echo fetches the state immediately after the PUT method returns. - # Waiting for a short time allows the changes to propagate. - await asyncio.sleep(0.25) + if entity.domain in config.off_maps_to_on_domains: + # Caching is required because things like scripts and scenes won't + # report as "off" to Alexa if an "off" command is received, because + # they'll map to "on". Thus, instead of reporting its actual + # status, we report what Alexa will want to see, which is the same + # as the actual requested command. + config.cached_states[entity_id] = [parsed, None] + else: + config.cached_states[entity_id] = [parsed, time.time()] return self.json(json_response) def get_entity_state(config, entity): """Retrieve and convert state and brightness values for an entity.""" - cached_state = config.cached_states.get(entity.entity_id, None) + cached_state_entry = config.cached_states.get(entity.entity_id, None) + cached_state = None + + # Check if we have a cached entry, and if so if it hasn't expired. + if cached_state_entry is not None: + entry_state, entry_time = cached_state_entry + if entry_time is None: + # Handle the case where the entity is listed in config.off_maps_to_on_domains. + cached_state = entry_state + elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ + STATE_ON + ] == (entity.state != STATE_OFF): + # We only want to use the cache if the actual state of the entity + # is in sync so that it can be detected as an error by Alexa. + cached_state = entry_state + else: + # Remove the now stale cached entry. + config.cached_states.pop(entity.entity_id) + data = { STATE_ON: False, STATE_BRIGHTNESS: None, @@ -791,3 +823,21 @@ def hue_brightness_to_hass(value): def hass_to_hue_brightness(value): """Convert hass brightness 0..255 to hue 1..254 scale.""" return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) + + +async def wait_for_state_change_or_timeout(hass, entity_id, timeout): + """Wait for an entity to change state.""" + ev = asyncio.Event() + + @core.callback + def _async_event_changed(_): + ev.set() + + unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) + + try: + await asyncio.wait_for(ev.wait(), timeout=STATE_CHANGE_WAIT_TIMEOUT) + except asyncio.TimeoutError: + pass + finally: + unsub() diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 99940e47133..510aa0ef8ee 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1,4 +1,5 @@ """The tests for the emulated Hue component.""" +import asyncio from datetime import timedelta from ipaddress import ip_address import json @@ -18,9 +19,10 @@ from homeassistant.components import ( media_player, script, ) -from homeassistant.components.emulated_hue import Config +from homeassistant.components.emulated_hue import Config, hue_api from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_BRI, + HUE_API_STATE_CT, HUE_API_STATE_HUE, HUE_API_STATE_ON, HUE_API_STATE_SAT, @@ -43,14 +45,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.core import callback import homeassistant.util.dt as dt_util from tests.async_mock import patch -from tests.common import ( - async_fire_time_changed, - async_mock_service, - get_test_instance_port, -) +from tests.common import async_fire_time_changed, get_test_instance_port HTTP_SERVER_PORT = get_test_instance_port() BRIDGE_SERVER_PORT = get_test_instance_port() @@ -77,6 +76,8 @@ ENTITY_IDS_BY_NUMBER = { "16": "humidifier.humidifier", "17": "humidifier.dehumidifier", "18": "humidifier.hygrostat", + "19": "scene.light_on", + "20": "scene.light_off", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} @@ -164,6 +165,28 @@ def hass_hue(loop, hass): ) ) + # setup a dummy scene + loop.run_until_complete( + setup.async_setup_component( + hass, + "scene", + { + "scene": [ + { + "id": "light_on", + "name": "Light on", + "entities": {"light.kitchen_lights": {"state": "on"}}, + }, + { + "id": "light_off", + "name": "Light off", + "entities": {"light.kitchen_lights": {"state": "off"}}, + }, + ] + }, + ) + ) + # create a lamp without brightness support hass.states.async_set("light.no_brightness", "on", {}) @@ -197,6 +220,9 @@ def hue_client(loop, hass_hue, aiohttp_client): "humidifier.dehumidifier": {emulated_hue.CONF_ENTITY_HIDDEN: False}, # No expose setting (use default of not exposed) "climate.nosetting": {}, + # Expose scenes + "scene.light_on": {emulated_hue.CONF_ENTITY_HIDDEN: False}, + "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, ) @@ -243,6 +269,8 @@ async def test_discover_lights(hue_client): assert "00:78:eb:f8:d5:0c:14:85-e7" in devices # humidifier.humidifier assert "00:67:19:bd:ea:e4:2d:ef-22" in devices # humidifier.dehumidifier assert "00:61:bf:ab:08:b1:a6:18-43" not in devices # humidifier.hygrostat + assert "00:62:5c:3e:df:58:40:01-43" in devices # scene.light_on + assert "00:1c:72:08:ed:09:e7:89-77" in devices # scene.light_off async def test_light_without_brightness_supported(hass_hue, hue_client): @@ -258,9 +286,18 @@ async def test_light_without_brightness_supported(hass_hue, hue_client): async def test_light_without_brightness_can_be_turned_off(hass_hue, hue_client): """Test that light without brightness can be turned off.""" hass_hue.states.async_set("light.no_brightness", "on", {}) + turn_off_calls = [] # Check if light can be turned off - turn_off_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_OFF) + @callback + def mock_service_call(call): + """Mock service call.""" + turn_off_calls.append(call) + hass_hue.states.async_set("light.no_brightness", "off", {}) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_OFF, mock_service_call, schema=None + ) no_brightness_result = await perform_put_light_state( hass_hue, hue_client, "light.no_brightness", False @@ -286,7 +323,17 @@ async def test_light_without_brightness_can_be_turned_on(hass_hue, hue_client): hass_hue.states.async_set("light.no_brightness", "off", {}) # Check if light can be turned on - turn_on_calls = async_mock_service(hass_hue, light.DOMAIN, SERVICE_TURN_ON) + turn_on_calls = [] + + @callback + def mock_service_call(call): + """Mock service call.""" + turn_on_calls.append(call) + hass_hue.states.async_set("light.no_brightness", "on", {}) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_ON, mock_service_call, schema=None + ) no_brightness_result = await perform_put_light_state( hass_hue, @@ -423,7 +470,7 @@ async def test_discover_config(hue_client): async def test_get_light_state(hass_hue, hue_client): """Test the getting of light state.""" - # Turn office light on and set to 127 brightness, and set light color + # Turn ceiling lights on and set to 127 brightness, and set light color await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_ON, @@ -591,6 +638,23 @@ async def test_put_light_state(hass, hass_hue, hue_client): ) assert kitchen_result.status == HTTP_UNAUTHORIZED + # Turn the ceiling lights on first and color temp. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_COLOR_TEMP: 20}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, color_temp=50 + ) + + assert ( + hass_hue.states.get("light.ceiling_lights").attributes[light.ATTR_COLOR_TEMP] + == 50 + ) + async def test_put_light_state_script(hass, hass_hue, hue_client): """Test the setting of script variables.""" @@ -832,6 +896,71 @@ async def test_put_light_state_fan(hass_hue, hue_client): assert living_room_fan.state == "on" assert living_room_fan.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM + # Check setting the brightness of a fan to 0, 33%, 66% and 100% will respectively turn it off, low, medium or high + # We also check non-cached GET value to exercise the code. + await perform_put_light_state( + hass_hue, hue_client, "fan.living_room_fan", True, brightness=0 + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_OFF + ) + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(33 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_LOW + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 + + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(66 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_MEDIUM + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert ( + round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 67 + ) # small rounding error in inverse operation + + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + True, + brightness=round(100 * 254 / 100), + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_SPEED] + == fan.SPEED_HIGH + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTP_OK + ) + assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + # pylint: disable=invalid-name async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client): @@ -952,9 +1081,8 @@ async def perform_put_test_on_ceiling_lights( assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 56 -async def perform_get_light_state(client, entity_id, expected_status): +async def perform_get_light_state_by_number(client, entity_number, expected_status): """Test the getting of a light state.""" - entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.get(f"/api/username/lights/{entity_number}") assert result.status == expected_status @@ -967,6 +1095,14 @@ async def perform_get_light_state(client, entity_id, expected_status): return None +async def perform_get_light_state(client, entity_id, expected_status): + """Test the getting of a light state.""" + entity_number = ENTITY_NUMBERS_BY_ID[entity_id] + return await perform_get_light_state_by_number( + client, entity_number, expected_status + ) + + async def perform_put_light_state( hass_hue, client, @@ -976,11 +1112,16 @@ async def perform_put_light_state( content_type="application/json", hue=None, saturation=None, + color_temp=None, + with_state=True, ): """Test the setting of a light state.""" req_headers = {"Content-Type": content_type} - data = {HUE_API_STATE_ON: is_on} + data = {} + + if with_state: + data[HUE_API_STATE_ON] = is_on if brightness is not None: data[HUE_API_STATE_BRI] = brightness @@ -988,6 +1129,8 @@ async def perform_put_light_state( data[HUE_API_STATE_HUE] = hue if saturation is not None: data[HUE_API_STATE_SAT] = saturation + if color_temp is not None: + data[HUE_API_STATE_CT] = color_temp entity_number = ENTITY_NUMBERS_BY_ID[entity_id] result = await client.put( @@ -1042,3 +1185,291 @@ async def test_unauthorized_user_blocked(hue_client): result_json = await result.json() assert result_json[0]["error"]["description"] == "unauthorized user" + + +async def test_put_then_get_cached_properly(hass, hass_hue, hue_client): + """Test the setting of light states and an immediate readback reads the same values.""" + + # Turn the bedroom light on first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_BRIGHTNESS: 153}, + blocking=True, + ) + + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_ON + assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 153 + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + # Check that a Hue brightness level of 254 becomes 255 in HA realm. + assert ( + hass.states.get("light.ceiling_lights").attributes[light.ATTR_BRIGHTNESS] == 255 + ) + + # Make sure that the GET response is the same as the PUT response within 2 seconds if the service call is successful and the state doesn't change. + # We simulate a long latence for the actual setting of the entity by forcibly sitting different values directly. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_BRIGHTNESS: 153}, + blocking=True, + ) + + # go through api to get the state back, the value returned should match those set in the last PUT request. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 254 + + # Make sure that the GET response does not use the cache if PUT response within 2 seconds if the service call is Unsuccessful and the state does not change. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + # go through api to get the state back + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # Now it should be the real value as the state of the entity has changed to OFF. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 0 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 0 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 1 + + # Ensure we read the actual value after exceeding the timeout time. + + # Turn the bedroom light back on first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + { + const.ATTR_ENTITY_ID: "light.ceiling_lights", + light.ATTR_BRIGHTNESS: 127, + light.ATTR_RGB_COLOR: (1, 2, 7), + }, + blocking=True, + ) + + # go through api to get the state back, the value returned should match those set in the last PUT request. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # With no wait, we must be reading what we set via the PUT call. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 4369 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 127 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 254 + + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + + # go through api to get the state back, the value returned should now match the actual values. + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + # Once we're after the cached duration, we should see the real value. + assert ceiling_json["state"][HUE_API_STATE_HUE] == 41869 + assert ceiling_json["state"][HUE_API_STATE_SAT] == 217 + assert ceiling_json["state"][HUE_API_STATE_BRI] == 127 + + +async def test_put_than_get_when_service_call_fails(hass, hass_hue, hue_client): + """Test putting and getting the light state when the service call fails.""" + + # Turn the bedroom light off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + turn_on_calls = [] + + # Now break the turn on service + @callback + def mock_service_call(call): + """Mock service call.""" + turn_on_calls.append(call) + + hass_hue.services.async_register( + light.DOMAIN, SERVICE_TURN_ON, mock_service_call, schema=None + ) + + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_OFF + + with patch.object(hue_api, "STATE_CHANGE_WAIT_TIMEOUT", 0.000001): + # update light state through api + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + hue=4369, + saturation=127, + brightness=254, + ) + + # Ensure we did not actually turn on + assert hass.states.get("light.ceiling_lights").state == STATE_OFF + + # go through api to get the state back, the value returned should NOT match those set in the last PUT request + # as the waiting to check the state change timed out + ceiling_json = await perform_get_light_state( + hue_client, "light.ceiling_lights", HTTP_OK + ) + + assert ceiling_json["state"][HUE_API_STATE_ON] is False + + +async def test_get_invalid_entity(hass, hass_hue, hue_client): + """Test the setting of light states and an immediate readback reads the same values.""" + + # Check that we get an error with an invalid entity number. + await perform_get_light_state_by_number(hue_client, 999, HTTP_NOT_FOUND) + + +async def test_put_light_state_scene(hass, hass_hue, hue_client): + """Test the setting of scene variables.""" + # Turn the kitchen lights off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.kitchen_lights"}, + blocking=True, + ) + + scene_result = await perform_put_light_state( + hass_hue, hue_client, "scene.light_on", True + ) + + scene_result_json = await scene_result.json() + assert scene_result.status == HTTP_OK + assert len(scene_result_json) == 1 + + assert hass_hue.states.get("light.kitchen_lights").state == STATE_ON + + # Set the brightness on the entity; changing a scene brightness via the hue API will do nothing. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.kitchen_lights", light.ATTR_BRIGHTNESS: 127}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "scene.light_on", True, brightness=254 + ) + + assert hass_hue.states.get("light.kitchen_lights").state == STATE_ON + assert ( + hass_hue.states.get("light.kitchen_lights").attributes[light.ATTR_BRIGHTNESS] + == 127 + ) + + await perform_put_light_state(hass_hue, hue_client, "scene.light_off", True) + assert hass_hue.states.get("light.kitchen_lights").state == STATE_OFF + + +async def test_only_change_contrast(hass, hass_hue, hue_client): + """Test when only changing the contrast of a light state.""" + + # Turn the kitchen lights off first + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: "light.ceiling_lights"}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, + hue_client, + "light.ceiling_lights", + True, + brightness=254, + with_state=False, + ) + + # Check that only setting the contrast will also turn on the light. + # TODO: It should be noted that a real Hue hub will not allow to change the brightness if the underlying entity is off. + # giving the error: [{"error":{"type":201,"address":"/lights/20/state/bri","description":"parameter, bri, is not modifiable. Device is set to off."}}] + # emulated_hue however will always turn on the light. + ceiling_lights = hass_hue.states.get("light.ceiling_lights") + assert ceiling_lights.state == STATE_ON + assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 255 + + +async def test_only_change_hue_or_saturation(hass, hass_hue, hue_client): + """Test setting either the hue or the saturation but not both.""" + + # TODO: The handling of this appears wrong, as setting only one will set the other to 0. + # The return values also appear wrong. + + # Turn the ceiling lights on first and set hue and saturation. + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_HS_COLOR: (10, 10)}, + blocking=True, + ) + + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, hue=4369 + ) + + assert hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_HS_COLOR + ] == (24, 0) + + await hass_hue.services.async_call( + light.DOMAIN, + const.SERVICE_TURN_ON, + {const.ATTR_ENTITY_ID: "light.ceiling_lights", light.ATTR_HS_COLOR: (10, 10)}, + blocking=True, + ) + await perform_put_light_state( + hass_hue, hue_client, "light.ceiling_lights", True, saturation=10 + ) + + assert hass_hue.states.get("light.ceiling_lights").attributes[ + light.ATTR_HS_COLOR + ] == (0, 3) From 45e451271eb8fc7c53e3e70e1b0389b872231584 Mon Sep 17 00:00:00 2001 From: Davide Varricchio <45564538+bannhead@users.noreply.github.com> Date: Tue, 4 Aug 2020 12:22:58 +0200 Subject: [PATCH 280/362] Bump pyaehw4a1 to 0.3.9 (#38347) * Bump pyaehw4a1 to 0.3.9 * Add myself to xiaomi miio codeowners (#38350) * add myself to xiaomi miio codeowners * Update CODEOWNERS * Update manifest.json * Upgrade youtube_dl to version 2020.07.28 (#38328) * Temporary lock pip to 20.1.1 to avoid build issue (#38358) * Add wheels job for building core wheels (#38359) * Bump androidtv to 0.0.47 and adb-shell to 0.2.1 (#38344) * Add jobs names to Wheels builds (#38363) * Update aioharmony to 0.2.6 (#38360) * Update run-in-env.sh (#36577) * Bump aioambient to 1.2.0 (#38364) * Bump simplisafe-python to 9.2.2 (#38365) * Ignore remote Plex clients during plex.tv lookup (#38327) * Avoid error with ignored harmony config entries (#38367) * Bump ElkM1 library version. (#38368) To reduce required version of dependent library. No code changed. * Add basic websocket api for OZW (#38265) * Prevent nut config flow error when checking ignored entries (#38372) * Revert "Prevent nut config flow error when checking ignored entries (#38372)" This reverts commit 9e0530df1d3dfe40ca5aff52f5bdd7c5555c8c59. * Revert "Add basic websocket api for OZW (#38265)" This reverts commit 3ca93baa555ece3fa9124cf947ff4ad4bb353ae3. * Revert "Bump ElkM1 library version. (#38368)" This reverts commit 143f55ad1203c81610d6894c97736599d5d00781. * Revert "Avoid error with ignored harmony config entries (#38367)" This reverts commit 90a10baf38749c30cf84c3f4f0704e1ee7091b1b. * Revert "Ignore remote Plex clients during plex.tv lookup (#38327)" This reverts commit 67cdeafe21c73f4275d4c02f6ccaec11186ec9d1. * Revert "Bump simplisafe-python to 9.2.2 (#38365)" This reverts commit 01d68e01c6042e07ce5b9b0ea4adea6c6a682025. * Revert "Bump aioambient to 1.2.0 (#38364)" This reverts commit bec6904eb95e6a3edbca839e22f35a1489e1b072. * Revert "Update run-in-env.sh (#36577)" This reverts commit 53acc1b41e5a8ed3fa1f28dc7463c104aff6ec83. * Revert "Update aioharmony to 0.2.6 (#38360)" This reverts commit a991d6f131c838b366b7f3d8b55d1c4be7602624. * Revert "Add jobs names to Wheels builds (#38363)" This reverts commit 58dcc059c7b6255eb5acff9397b01c5e821c971d. * Revert "Bump androidtv to 0.0.47 and adb-shell to 0.2.1 (#38344)" This reverts commit 14b4722b69e81e8fd485ac487ea8a1548247c4e1. * Revert "Add wheels job for building core wheels (#38359)" This reverts commit cb9e76adb776c647e76ee15b9c28493f25a372a8. * Revert "Temporary lock pip to 20.1.1 to avoid build issue (#38358)" This reverts commit b2207ed7762c23300c74f832b105ae776202f6c9. * Revert "Upgrade youtube_dl to version 2020.07.28 (#38328)" This reverts commit 144e827ce9de344028c26a809795c38597924fe4. * Revert "Add myself to xiaomi miio codeowners (#38350)" This reverts commit 88538254ec51ef4eced194b3d807317b5d07eacc. Co-authored-by: starkillerOG Co-authored-by: Josef Schlehofer Co-authored-by: Franck Nijhof Co-authored-by: Jeff Irion Co-authored-by: ehendrix23 Co-authored-by: Aaron Bach Co-authored-by: jjlawren Co-authored-by: J. Nick Koston Co-authored-by: Glenn Waters Co-authored-by: Charles Garwood --- homeassistant/components/hisense_aehw4a1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hisense_aehw4a1/manifest.json b/homeassistant/components/hisense_aehw4a1/manifest.json index e702e285277..00afa0d1de2 100644 --- a/homeassistant/components/hisense_aehw4a1/manifest.json +++ b/homeassistant/components/hisense_aehw4a1/manifest.json @@ -3,6 +3,6 @@ "name": "Hisense AEH-W4A1", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hisense_aehw4a1", - "requirements": ["pyaehw4a1==0.3.5"], + "requirements": ["pyaehw4a1==0.3.9"], "codeowners": ["@bannhead"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2f47ecaa98c..0a4bd2356c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1205,7 +1205,7 @@ py_nextbusnext==0.1.4 pyads==3.2.1 # homeassistant.components.hisense_aehw4a1 -pyaehw4a1==0.3.5 +pyaehw4a1==0.3.9 # homeassistant.components.aftership pyaftership==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50655192e92..93abbb4d778 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -568,7 +568,7 @@ pyTibber==0.14.0 py_nextbusnext==0.1.4 # homeassistant.components.hisense_aehw4a1 -pyaehw4a1==0.3.5 +pyaehw4a1==0.3.9 # homeassistant.components.airvisual pyairvisual==4.4.0 From 0c030cb8cf8d6f52d5b7a3a9113967be9fa6da99 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Tue, 4 Aug 2020 14:42:25 +0200 Subject: [PATCH 281/362] Update pyhomematic to 0.1.68 (#38530) --- homeassistant/components/homematic/const.py | 3 +++ homeassistant/components/homematic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/const.py b/homeassistant/components/homematic/const.py index 92091930d32..1ce18a8e759 100644 --- a/homeassistant/components/homematic/const.py +++ b/homeassistant/components/homematic/const.py @@ -66,6 +66,7 @@ HM_DEVICE_TYPES = { "ColorEffectLight", "IPKeySwitchLevel", "ColdWarmDimmer", + "IPWDimmer", ], DISCOVER_SENSORS: [ "SwitchPowermeter", @@ -110,6 +111,7 @@ HM_DEVICE_TYPES = { "IPThermostatWall2", "IPRemoteMotionV2", "HBUNISenWEA", + "IPWMotionDection", ], DISCOVER_CLIMATE: [ "Thermostat", @@ -151,6 +153,7 @@ HM_DEVICE_TYPES = { "IPContact", "IPRemoteMotionV2", "IPWInputDevice", + "IPWMotionDection", ], DISCOVER_COVER: [ "Blind", diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index de55b941b91..d8c60c0f976 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,6 +2,6 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.67"], + "requirements": ["pyhomematic==0.1.68"], "codeowners": ["@pvizeli", "@danielperna84"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a4bd2356c9..9acc4e494d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1377,7 +1377,7 @@ pyhik==0.2.7 pyhiveapi==0.2.20.1 # homeassistant.components.homematic -pyhomematic==0.1.67 +pyhomematic==0.1.68 # homeassistant.components.homeworks pyhomeworks==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93abbb4d778..bfe239fd4a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -647,7 +647,7 @@ pyhaversion==3.3.0 pyheos==0.6.0 # homeassistant.components.homematic -pyhomematic==0.1.67 +pyhomematic==0.1.68 # homeassistant.components.icloud pyicloud==0.9.7 From fe07d79744a269674726173fee1b57ad168ab26b Mon Sep 17 00:00:00 2001 From: pbalogh77 Date: Tue, 4 Aug 2020 14:55:03 +0200 Subject: [PATCH 282/362] Fix Fibaro component failure to load with HC3 (#38528) Fixed a rarely occuring problem (maybe a change with Fibaro HC3) where some scenes don't have a "visible" parameter, which was assumed to be mandatory in the past. --- homeassistant/components/fibaro/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 44e55b37faa..4233953ca8c 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -235,7 +235,7 @@ class FibaroController: scenes = self._client.scenes.list() self._scene_map = {} for device in scenes: - if not device.visible: + if "visible" in device and not device.visible: continue device.fibaro_controller = self if device.roomID == 0: @@ -292,7 +292,11 @@ class FibaroController: # otherwise add the first visible device in the group # which is a hack, but solves a problem with FGT having # hidden compatibility devices before the real device - if last_climate_parent != device.parentId and device.visible: + if ( + last_climate_parent != device.parentId + and "visible" in device + and device.visible + ): self.fibaro_devices[dtype].append(device) last_climate_parent = device.parentId _LOGGER.debug( From c2f5831181768b65ca7ad0d45f01a5af002948c4 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 4 Aug 2020 15:34:23 +0200 Subject: [PATCH 283/362] Support dual stack IP support (IPv4 and IPv6) (#38046) Co-authored-by: Paulus Schoutsen --- homeassistant/components/hassio/handler.py | 6 +- homeassistant/components/http/__init__.py | 16 +++-- homeassistant/components/http/web_runner.py | 67 +++++++++++++++++++++ tests/scripts/test_check_config.py | 1 - 4 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/http/web_runner.py diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index cce17695e30..861056a46e4 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -10,7 +10,6 @@ from homeassistant.components.http import ( CONF_SERVER_HOST, CONF_SERVER_PORT, CONF_SSL_CERTIFICATE, - DEFAULT_SERVER_HOST, ) from homeassistant.const import HTTP_BAD_REQUEST, HTTP_OK, SERVER_PORT @@ -142,10 +141,7 @@ class HassIO: "refresh_token": refresh_token.token, } - if ( - http_config.get(CONF_SERVER_HOST, DEFAULT_SERVER_HOST) - != DEFAULT_SERVER_HOST - ): + if http_config.get(CONF_SERVER_HOST) is not None: options["watchdog"] = False _LOGGER.warning( "Found incompatible HTTP option 'server_host'. Watchdog feature disabled" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index b387cea350e..75f13caa6aa 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -30,6 +30,7 @@ from .cors import setup_cors from .real_ip import setup_real_ip from .static import CACHE_HEADERS, CachingStaticResource from .view import HomeAssistantView # noqa: F401 +from .web_runner import HomeAssistantTCPSite # mypy: allow-untyped-defs, no-check-untyped-defs @@ -53,7 +54,6 @@ SSL_INTERMEDIATE = "intermediate" _LOGGER = logging.getLogger(__name__) -DEFAULT_SERVER_HOST = "0.0.0.0" DEFAULT_DEVELOPMENT = "0" # To be able to load custom cards. DEFAULT_CORS = "https://cast.home-assistant.io" @@ -69,7 +69,9 @@ HTTP_SCHEMA = vol.All( cv.deprecated(CONF_BASE_URL), vol.Schema( { - vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, + vol.Optional(CONF_SERVER_HOST): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, @@ -190,7 +192,7 @@ async def async_setup(hass, config): if conf is None: conf = HTTP_SCHEMA({}) - server_host = conf[CONF_SERVER_HOST] + server_host = conf.get(CONF_SERVER_HOST) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) @@ -255,8 +257,9 @@ async def async_setup(hass, config): if host: port = None - elif server_host != DEFAULT_SERVER_HOST: - host = server_host + elif server_host is not None: + # Assume the first server host name provided as API host + host = server_host[0] port = server_port else: host = local_ip @@ -412,7 +415,8 @@ class HomeAssistantHTTP: self.runner = web.AppRunner(self.app) await self.runner.setup() - self.site = web.TCPSite( + + self.site = HomeAssistantTCPSite( self.runner, self.server_host, self.server_port, ssl_context=context ) try: diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py new file mode 100644 index 00000000000..67621d63412 --- /dev/null +++ b/homeassistant/components/http/web_runner.py @@ -0,0 +1,67 @@ +"""HomeAssistant specific aiohttp Site.""" +import asyncio +from ssl import SSLContext +from typing import List, Optional, Union + +from aiohttp import web +from yarl import URL + + +class HomeAssistantTCPSite(web.BaseSite): + """HomeAssistant specific aiohttp Site. + + Vanilla TCPSite accepts only str as host. However, the underlying asyncio's + create_server() implementation does take a list of strings to bind to multiple + host IP's. To support multiple server_host entries (e.g. to enable dual-stack + explicitly), we would like to pass an array of strings. Bring our own + implementation inspired by TCPSite. + + Custom TCPSite can be dropped when https://github.com/aio-libs/aiohttp/pull/4894 + is merged. + """ + + __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port", "_hosturl") + + def __init__( + self, + runner: "web.BaseRunner", + host: Union[None, str, List[str]], + port: int, + *, + shutdown_timeout: float = 60.0, + ssl_context: Optional[SSLContext] = None, + backlog: int = 128, + reuse_address: Optional[bool] = None, + reuse_port: Optional[bool] = None, + ) -> None: # noqa: D107 + super().__init__( + runner, + shutdown_timeout=shutdown_timeout, + ssl_context=ssl_context, + backlog=backlog, + ) + self._host = host + self._port = port + self._reuse_address = reuse_address + self._reuse_port = reuse_port + + @property + def name(self) -> str: # noqa: D102 + scheme = "https" if self._ssl_context else "http" + host = self._host[0] if isinstance(self._host, list) else "0.0.0.0" + return str(URL.build(scheme=scheme, host=host, port=self._port)) + + async def start(self) -> None: # noqa: D102 + await super().start() + loop = asyncio.get_running_loop() + server = self._runner.server + assert server is not None + self._server = await loop.create_server( + server, + self._host, + self._port, + ssl=self._ssl_context, + backlog=self._backlog, + reuse_address=self._reuse_address, + reuse_port=self._reuse_port, + ) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index c4f7d2b08c5..10034cb08af 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -113,7 +113,6 @@ def test_secrets(isfile_patch, loop): "cors_allowed_origins": ["http://google.com"], "ip_ban_enabled": True, "login_attempts_threshold": -1, - "server_host": "0.0.0.0", "server_port": 8123, "ssl_profile": "modern", } From df8e1792077ae498c389a7735df2d6bd41d27490 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 4 Aug 2020 09:39:36 -0500 Subject: [PATCH 284/362] Fix sensor.time intermittent startup exception (#38525) --- homeassistant/components/time_date/sensor.py | 27 ++++++++++++-------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 6081f1dfca6..b9012385296 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -42,15 +42,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Timezone is not set in Home Assistant configuration") return False - devices = [] - for variable in config[CONF_DISPLAY_OPTIONS]: - device = TimeDateSensor(hass, variable) - async_track_point_in_utc_time( - hass, device.point_in_time_listener, device.get_next_interval() - ) - devices.append(device) - - async_add_entities(devices, True) + async_add_entities( + [TimeDateSensor(hass, variable) for variable in config[CONF_DISPLAY_OPTIONS]] + ) class TimeDateSensor(Entity): @@ -62,6 +56,7 @@ class TimeDateSensor(Entity): self.type = option_type self._state = None self.hass = hass + self.unsub = None self._update_internal_state(dt_util.utcnow()) @@ -84,6 +79,18 @@ class TimeDateSensor(Entity): return "mdi:calendar" return "mdi:clock" + async def async_added_to_hass(self) -> None: + """Set up next update.""" + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval() + ) + + async def async_will_remove_from_hass(self) -> None: + """Cancel next update.""" + if self.unsub: + self.unsub() + self.unsub = None + def get_next_interval(self, now=None): """Compute next time an update should occur.""" if now is None: @@ -137,6 +144,6 @@ class TimeDateSensor(Entity): """Get the latest data and update state.""" self._update_internal_state(time_date) self.async_write_ha_state() - async_track_point_in_utc_time( + self.unsub = async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval() ) From 86fc977ff510f9ce64684f98fd375324ab29cb20 Mon Sep 17 00:00:00 2001 From: Daniel Correa Lobato Date: Tue, 4 Aug 2020 11:51:15 -0300 Subject: [PATCH 285/362] Update notify.py (#38526) Clickatell can returns 202 when a message is accepted for delivery. So, success can be indicated by 200 or 202 code (https://archive.clickatell.com/developers/api-docs/http-status-codes-rest/) --- homeassistant/components/clickatell/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index 0c1ce2e9585..966dbdee6e2 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -37,5 +37,5 @@ class ClickatellNotificationService(BaseNotificationService): data = {"apiKey": self.api_key, "to": self.recipient, "content": message} resp = requests.get(BASE_API_URL, params=data, timeout=5) - if (resp.status_code != HTTP_OK) or (resp.status_code != 201): + if (resp.status_code != HTTP_OK) or (resp.status_code != 202): _LOGGER.error("Error %s : %s", resp.status_code, resp.text) From 07806500155552dd989e11a8bc5ef1077b6b4bc6 Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 4 Aug 2020 13:15:21 -0500 Subject: [PATCH 286/362] Make ozw CCT use device attributes instead of hard coded values (#38054) --- homeassistant/components/ozw/__init__.py | 1 - homeassistant/components/ozw/discovery.py | 10 +++++ homeassistant/components/ozw/light.py | 47 +++++++++++++++++------ tests/components/ozw/test_light.py | 19 +++++---- 4 files changed, 56 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index fa0eddfbcd1..bb48c180971 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -114,7 +114,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Filter out CommandClasses we're definitely not interested in. if value.command_class in [ - CommandClass.CONFIGURATION, CommandClass.VERSION, CommandClass.MANUFACTURER_SPECIFIC, ]: diff --git a/homeassistant/components/ozw/discovery.py b/homeassistant/components/ozw/discovery.py index 3eb5d414ac5..84b1d4180e1 100644 --- a/homeassistant/components/ozw/discovery.py +++ b/homeassistant/components/ozw/discovery.py @@ -250,6 +250,16 @@ DISCOVERY_SCHEMAS = ( const.DISC_INDEX: ValueIndex.SWITCH_COLOR_CHANNELS, const.DISC_OPTIONAL: True, }, + "min_kelvin": { + const.DISC_COMMAND_CLASS: (CommandClass.CONFIGURATION,), + const.DISC_INDEX: 81, # PR for upstream to add SWITCH_COLOR_CT_WARM + const.DISC_OPTIONAL: True, + }, + "max_kelvin": { + const.DISC_COMMAND_CLASS: (CommandClass.CONFIGURATION,), + const.DISC_INDEX: 82, # PR for upstream to add SWITCH_COLOR_CT_COLD + const.DISC_OPTIONAL: True, + }, }, }, { # All other text/numeric sensors diff --git a/homeassistant/components/ozw/light.py b/homeassistant/components/ozw/light.py index ae2750618c4..2c7461976c4 100644 --- a/homeassistant/components/ozw/light.py +++ b/homeassistant/components/ozw/light.py @@ -30,9 +30,6 @@ COLOR_CHANNEL_COLD_WHITE = 0x02 COLOR_CHANNEL_RED = 0x04 COLOR_CHANNEL_GREEN = 0x08 COLOR_CHANNEL_BLUE = 0x10 -TEMP_COLOR_MAX = 500 # mired equivalent to 2000K -TEMP_COLOR_MIN = 154 # mired equivalent to 6500K -TEMP_COLOR_DIFF = TEMP_COLOR_MAX - TEMP_COLOR_MIN async def async_setup_entry(hass, config_entry, async_add_entities): @@ -71,6 +68,9 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): self._white = None self._ct = None self._supported_features = SUPPORT_BRIGHTNESS + self._min_mireds = 153 # 6500K as a safe default + self._max_mireds = 370 # 2700K as a safe default + # make sure that supported features is correctly set self.on_value_update() @@ -136,6 +136,16 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): """Return the color temperature.""" return self._ct + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return self._min_mireds + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return self._max_mireds + @callback def async_set_duration(self, **kwargs): """Set the transition time for the brightness value. @@ -209,7 +219,11 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): 0, min( 255, - round((TEMP_COLOR_MAX - round(color_temp)) / TEMP_COLOR_DIFF * 255), + round( + (self._max_mireds - color_temp) + / (self._max_mireds - self._min_mireds) + * 255 + ), ), ) warm = 255 - cold @@ -241,16 +255,26 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): # Color Data String data = self.values.color.data[ATTR_VALUE] - # RGB is always present in the openzwave color data string. + # RGB is always present in the OpenZWave color data string. rgb = [int(data[1:3], 16), int(data[3:5], 16), int(data[5:7], 16)] self._hs = color_util.color_RGB_to_hs(*rgb) - # Parse remaining color channels. Openzwave appends white channels + # Parse remaining color channels. OpenZWave appends white channels # that are present. index = 7 temp_warm = 0 temp_cold = 0 + # Update color temp limits. + if self.values.min_kelvin: + self._max_mireds = color_util.color_temperature_kelvin_to_mired( + self.values.min_kelvin.data[ATTR_VALUE] + ) + if self.values.max_kelvin: + self._min_mireds = color_util.color_temperature_kelvin_to_mired( + self.values.max_kelvin.data[ATTR_VALUE] + ) + # Warm white if self._color_channels & COLOR_CHANNEL_WARM_WHITE: self._white = int(data[index : index + 2], 16) @@ -264,13 +288,12 @@ class ZwaveLight(ZWaveDeviceEntity, LightEntity): temp_cold = self._white # Calculate color temps based on white LED status - if temp_cold > 0: - self._ct = round(TEMP_COLOR_MAX - ((temp_cold / 255) * TEMP_COLOR_DIFF)) - # Only used if CW channel missing - elif temp_warm > 0: - self._ct = round(TEMP_COLOR_MAX - temp_warm) + if temp_cold or temp_warm: + self._ct = round( + self._max_mireds + - ((temp_cold / 255) * (self._max_mireds - self._min_mireds)) + ) - # If no rgb channels supported, report None. if not ( self._color_channels & COLOR_CHANNEL_RED or self._color_channels & COLOR_CHANNEL_GREEN diff --git a/tests/components/ozw/test_light.py b/tests/components/ozw/test_light.py index 0217ca5adf8..8f9892e37cd 100644 --- a/tests/components/ozw/test_light.py +++ b/tests/components/ozw/test_light.py @@ -284,7 +284,7 @@ async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): assert state.attributes["xy_color"] == (0.519, 0.429) # Test setting color temp - new_color = 465 + new_color = 200 await hass.services.async_call( "light", "turn_on", @@ -298,14 +298,14 @@ async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): msg = sent_messages[-2] assert msg["topic"] == "OpenZWave/1/command/setvalue/" - assert msg["payload"] == {"Value": "#000000e51a", "ValueIDKey": 659341335} + assert msg["payload"] == {"Value": "#00000037c8", "ValueIDKey": 659341335} # Feedback on state light_msg.decode() light_msg.payload["Value"] = byte_to_zwave_brightness(255) light_msg.encode() light_rgb_msg.decode() - light_rgb_msg.payload["Value"] = "#000000e51a" + light_rgb_msg.payload["Value"] = "#00000037c8" light_rgb_msg.encode() receive_message(light_msg) receive_message(light_rgb_msg) @@ -314,7 +314,7 @@ async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): state = hass.states.get("light.led_bulb_6_multi_colour_level") assert state is not None assert state.state == "on" - assert state.attributes["color_temp"] == 465 + assert state.attributes["color_temp"] == 200 # Test setting invalid color temp new_color = 120 @@ -347,7 +347,7 @@ async def test_light(hass, light_data, light_msg, light_rgb_msg, sent_messages): state = hass.states.get("light.led_bulb_6_multi_colour_level") assert state is not None assert state.state == "on" - assert state.attributes["color_temp"] == 154 + assert state.attributes["color_temp"] == 153 async def test_no_rgb_light(hass, light_no_rgb_data, light_no_rgb_msg, sent_messages): @@ -486,6 +486,9 @@ async def test_wc_light(hass, light_wc_data, light_msg, light_rgb_msg, sent_mess assert state is not None assert state.state == "off" + assert state.attributes["min_mireds"] == 153 + assert state.attributes["max_mireds"] == 370 + # Turn on the light new_color = 190 await hass.services.async_call( @@ -497,14 +500,14 @@ async def test_wc_light(hass, light_wc_data, light_msg, light_rgb_msg, sent_mess assert len(sent_messages) == 2 msg = sent_messages[-2] assert msg["topic"] == "OpenZWave/1/command/setvalue/" - assert msg["payload"] == {"Value": "#0000001be4", "ValueIDKey": 659341335} + assert msg["payload"] == {"Value": "#0000002bd4", "ValueIDKey": 659341335} # Feedback on state light_msg.decode() light_msg.payload["Value"] = byte_to_zwave_brightness(255) light_msg.encode() light_rgb_msg.decode() - light_rgb_msg.payload["Value"] = "#0000001be4" + light_rgb_msg.payload["Value"] = "#0000002bd4" light_rgb_msg.encode() receive_message(light_msg) receive_message(light_rgb_msg) @@ -513,7 +516,7 @@ async def test_wc_light(hass, light_wc_data, light_msg, light_rgb_msg, sent_mess state = hass.states.get("light.led_bulb_6_multi_colour_level") assert state is not None assert state.state == "on" - assert state.attributes["color_temp"] == 191 + assert state.attributes["color_temp"] == 190 async def test_new_ozw_light(hass, light_new_ozw_data, light_msg, sent_messages): From 31dbdff3c47ed16c98ee60139abdff21a0a5c7bb Mon Sep 17 00:00:00 2001 From: cgtobi Date: Tue, 4 Aug 2020 20:46:46 +0200 Subject: [PATCH 287/362] Add Netatmo data handler (#35571) * Fix webhook registration * Only load camera platform with valid scope * Add initial data handler and netatmo base class * Update camera to use data handler * Update init * Parallelize API calls * Remove cruft * Minor tweaks * Refactor data handler * Update climate to use data handler * Fix pylint error * Fix climate update not getting fresh data * Update climate data * update to pyatmo 4.0.0 * Refactor for pyatmo 4.0.0 * Exclude from coverage until tests are written * Fix typo * Reduce parallel calls * Add heating request attr * Async get_entities * Undo parallel updates * Fix camera issue * Introduce individual scan interval per device class * Some cleanup * Add basic webhook support for climate to improve responsiveness * Replace ClimateDevice by ClimateEntity * Add support for turning camera on/off * Update camera state upon webhook events * Guard data class registration with lock * Capture errors * Add light platform * Add dis-/connect handling * Fix set schedule service * Remove extra calls * Add service to set person(s) home/away * Add service descriptions * Improve service descriptions * Use LightEntity instead of Light * Add guard if no data is retrieved * Make services entity based * Only raise platform not ready if there is a NOC * Register webhook even during runtime * Fix turning off event * Fix linter error * Fix linter error * Exclude light platform from coverage * Change log level * Refactor public weather sensor to use data handler * Prevent too short coordinates * Ignore modules without _id * Code cleanup * Fix test * Exit early if no home data is retrieved * Prevent discovery if already active * Add services to (un-)register webhook * Fix tests * Not actually a coroutine * Move methods to base class * Address pylint comment * Address pylint complaints * Address comments * Address more comments * Add docstring * Use single instance allowed * Extract method * Remove cruft * Write state directly * Fix test * Add file to coverage * Move nested function * Move nested function * Update docstring * Clean up code * Fix webhook bug * Clean up listeners * Use deque * Clean up prints * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/camera.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/camera.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/camera.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/camera.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/camera.py Co-authored-by: J. Nick Koston * Update homeassistant/components/netatmo/camera.py Co-authored-by: J. Nick Koston * Rename data_class variable * Break when match * Extract method * Extract methods * Rename variable * Improve comment * Some refinements * Extra * Extract method * Simplify code * Improve reability * Code simplification * Simplify code * Simplify code * Code cleanup * Fix import * Clean up * Clean up magic strings * Replace data_class_name with CAMERA_DATA_CLASS_NAME * Replace data_class_name with CAMERA_DATA_CLASS_NAME * Replace data_class_name with HOMEDATA_DATA_CLASS_NAME * Replace data_class_name in public weather sensor * Clean up * Remove deprecated config options * Schedule immediate update on camera reconnect * Use UUID to clearly identify public weather areas * Use subscription mode * Move clean up of temporary data classes * Delay data class removal * Fix linter complaints * Adjust test * Only setup lights if webhook are registered * Prevent crash with old config entries * Don't cache home ids * Remove stale code * Fix coordinates if entered mixed up by the user * Move nested function * Add test case for swapped coordinates * Only wait for discovery entries * Only use what I need * Bring stuff closer to where it's used * Auto clean up setup data classes * Code cleanup * Remove unneccessary lock * Update homeassistant/components/netatmo/sensor.py Co-authored-by: J. Nick Koston * Update tests/components/netatmo/test_config_flow.py Co-authored-by: J. Nick Koston * Clean up dead code * Fix formating * Extend coverage * Extend coverage Co-authored-by: J. Nick Koston --- .coveragerc | 4 + homeassistant/components/netatmo/__init__.py | 68 +- homeassistant/components/netatmo/api.py | 2 +- homeassistant/components/netatmo/camera.py | 321 +++++---- homeassistant/components/netatmo/climate.py | 594 ++++++++-------- .../components/netatmo/config_flow.py | 41 +- homeassistant/components/netatmo/const.py | 9 + .../components/netatmo/data_handler.py | 166 +++++ homeassistant/components/netatmo/helper.py | 17 + homeassistant/components/netatmo/light.py | 145 ++++ .../components/netatmo/manifest.json | 2 +- .../components/netatmo/netatmo_entity_base.py | 113 +++ homeassistant/components/netatmo/sensor.py | 641 +++++++++--------- .../components/netatmo/services.yaml | 34 +- homeassistant/components/netatmo/strings.json | 2 +- homeassistant/components/netatmo/webhook.py | 29 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/netatmo/test_config_flow.py | 76 ++- 19 files changed, 1422 insertions(+), 846 deletions(-) create mode 100644 homeassistant/components/netatmo/data_handler.py create mode 100644 homeassistant/components/netatmo/helper.py create mode 100644 homeassistant/components/netatmo/light.py create mode 100644 homeassistant/components/netatmo/netatmo_entity_base.py diff --git a/.coveragerc b/.coveragerc index 81f00ab6968..6978958a3f0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -552,6 +552,10 @@ omit = homeassistant/components/netatmo/camera.py homeassistant/components/netatmo/climate.py homeassistant/components/netatmo/const.py + homeassistant/components/netatmo/data_handler.py + homeassistant/components/netatmo/helper.py + homeassistant/components/netatmo/light.py + homeassistant/components/netatmo/netatmo_entity_base.py homeassistant/components/netatmo/sensor.py homeassistant/components/netatmo/webhook.py homeassistant/components/netdata/sensor.py diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index cb5408c2259..0995511abcc 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -15,13 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_DISCOVERY, - CONF_USERNAME, CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from . import api, config_flow @@ -29,30 +27,25 @@ from .const import ( AUTH, CONF_CLOUDHOOK_URL, DATA_DEVICE_IDS, + DATA_HANDLER, + DATA_HOMES, DATA_PERSONS, + DATA_SCHEDULES, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) +from .data_handler import NetatmoDataHandler from .webhook import handle_webhook _LOGGER = logging.getLogger(__name__) -CONF_SECRET_KEY = "secret_key" -CONF_WEBHOOKS = "webhooks" - -WAIT_FOR_CLOUD = 5 - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, - cv.deprecated(CONF_SECRET_KEY): cv.match_all, - cv.deprecated(CONF_USERNAME): cv.match_all, - cv.deprecated(CONF_WEBHOOKS): cv.match_all, - cv.deprecated(CONF_DISCOVERY): cv.match_all, } ) }, @@ -67,6 +60,8 @@ async def async_setup(hass: HomeAssistant, config: dict): hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_PERSONS] = {} hass.data[DOMAIN][DATA_DEVICE_IDS] = {} + hass.data[DOMAIN][DATA_SCHEDULES] = {} + hass.data[DOMAIN][DATA_HOMES] = {} if DOMAIN not in config: return True @@ -100,27 +95,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): AUTH: api.ConfigEntryNetatmoAuth(hass, entry, implementation) } + data_handler = NetatmoDataHandler(hass, entry) + await data_handler.async_setup() + hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) - async def unregister_webhook(event): + async def unregister_webhook(_): + if CONF_WEBHOOK_ID not in entry.data: + return _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) async def register_webhook(event): - # Wait for the cloud integration to be ready - await asyncio.sleep(WAIT_FOR_CLOUD) - if CONF_WEBHOOK_ID not in entry.data: data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} hass.config_entries.async_update_entry(entry, data=data) if hass.components.cloud.async_active_subscription(): - # Wait for cloud connection to be established - await asyncio.sleep(WAIT_FOR_CLOUD) - if CONF_CLOUDHOOK_URL not in entry.data: webhook_url = await hass.components.cloud.async_create_cloudhook( entry.data[CONF_WEBHOOK_ID] @@ -134,20 +129,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): entry.data[CONF_WEBHOOK_ID] ) - try: - await hass.async_add_executor_job( - hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url + if entry.data["auth_implementation"] == "cloud" and not webhook_url.startswith( + "https://" + ): + _LOGGER.warning( + "Webhook not registered - " + "https and port 443 is required to register the webhook" ) + return + + try: webhook_register( hass, DOMAIN, "Netatmo", entry.data[CONF_WEBHOOK_ID], handle_webhook ) + await hass.async_add_executor_job( + hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url + ) _LOGGER.info("Register Netatmo webhook: %s", webhook_url) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_webhook) + if hass.state == CoreState.running: + await register_webhook(None) + else: + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_webhook) + + hass.services.async_register(DOMAIN, "register_webhook", register_webhook) + hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) + return True @@ -157,6 +171,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): await hass.async_add_executor_job( hass.data[DOMAIN][entry.entry_id][AUTH].dropwebhook ) + _LOGGER.info("Unregister Netatmo webhook.") + + await hass.data[DOMAIN][entry.entry_id][DATA_HANDLER].async_cleanup() unload_ok = all( await asyncio.gather( @@ -175,7 +192,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): """Cleanup when entry is removed.""" - if CONF_WEBHOOK_ID in entry.data: + if ( + CONF_WEBHOOK_ID in entry.data + and hass.components.cloud.async_active_subscription() + ): try: _LOGGER.debug( "Removing Netatmo cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 9a34888fd72..b8b259ed5c1 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -10,7 +10,7 @@ from homeassistant.helpers import config_entry_oauth2_flow _LOGGER = logging.getLogger(__name__) -class ConfigEntryNetatmoAuth(pyatmo.auth.NetatmOAuth2): +class ConfigEntryNetatmoAuth(pyatmo.auth.NetatmoOAuth2): """Provide Netatmo authentication tied to an OAuth2 based config entry.""" def __init__( diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 30f209625f6..8fbff3225dd 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -10,59 +10,113 @@ from homeassistant.components.camera import ( SUPPORT_STREAM, Camera, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( + ATTR_PERSON, + ATTR_PERSONS, ATTR_PSEUDO, - AUTH, + DATA_HANDLER, DATA_PERSONS, DOMAIN, MANUFACTURER, - MIN_TIME_BETWEEN_EVENT_UPDATES, - MIN_TIME_BETWEEN_UPDATES, MODELS, + SERVICE_SETPERSONAWAY, + SERVICE_SETPERSONSHOME, + SIGNAL_NAME, ) +from .data_handler import CAMERA_DATA_CLASS_NAME +from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -CONF_HOME = "home" -CONF_CAMERAS = "cameras" -CONF_QUALITY = "quality" - DEFAULT_QUALITY = "high" -VALID_QUALITIES = ["high", "medium", "low", "poor"] +SCHEMA_SERVICE_SETPERSONSHOME = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(CAMERA_DOMAIN), + vol.Required(ATTR_PERSONS): vol.All(cv.ensure_list, [cv.string]), + } +) -_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} - -SCHEMA_SERVICE_SETLIGHTAUTO = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_domain(CAMERA_DOMAIN)} +SCHEMA_SERVICE_SETPERSONAWAY = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(CAMERA_DOMAIN), + vol.Optional(ATTR_PERSON): cv.string, + } ) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Netatmo camera platform.""" + if "access_camera" not in entry.data["token"]["scope"]: + _LOGGER.info( + "Cameras are currently not supported with this authentication method" + ) + return - def get_entities(): + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + + async def get_entities(): """Retrieve Netatmo entities.""" + await data_handler.register_data_class( + CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None + ) + + data = data_handler.data + + if not data.get(CAMERA_DATA_CLASS_NAME): + return [] + + data_class = data_handler.data[CAMERA_DATA_CLASS_NAME] + entities = [] try: - camera_data = CameraData(hass, hass.data[DOMAIN][entry.entry_id][AUTH]) - for camera in camera_data.get_all_cameras(): - _LOGGER.debug("Setting up camera %s %s", camera["id"], camera["name"]) + all_cameras = [] + for home in data_class.cameras.values(): + for camera in home.values(): + all_cameras.append(camera) + + for camera in all_cameras: + _LOGGER.debug("Adding camera %s %s", camera["id"], camera["name"]) entities.append( NetatmoCamera( - camera_data, camera["id"], camera["type"], True, DEFAULT_QUALITY + data_handler, + camera["id"], + camera["type"], + camera["home_id"], + DEFAULT_QUALITY, ) ) - camera_data.update_persons() + + for person_id, person_data in data_handler.data[ + CAMERA_DATA_CLASS_NAME + ].persons.items(): + hass.data[DOMAIN][DATA_PERSONS][person_id] = person_data.get( + ATTR_PSEUDO + ) except pyatmo.NoDevice: _LOGGER.debug("No cameras found") + return entities - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(await get_entities(), True) + + platform = entity_platform.current_platform.get() + + if data_handler.data[CAMERA_DATA_CLASS_NAME] is not None: + platform.async_register_entity_service( + SERVICE_SETPERSONSHOME, + SCHEMA_SERVICE_SETPERSONSHOME, + "_service_setpersonshome", + ) + platform.async_register_entity_service( + SERVICE_SETPERSONAWAY, + SCHEMA_SERVICE_SETPERSONAWAY, + "_service_setpersonaway", + ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -70,19 +124,26 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return -class NetatmoCamera(Camera): +class NetatmoCamera(NetatmoBase, Camera): """Representation of a Netatmo camera.""" - def __init__(self, data, camera_id, camera_type, verify_ssl, quality): + def __init__( + self, data_handler, camera_id, camera_type, home_id, quality, + ): """Set up for access to the Netatmo camera images.""" - super().__init__() - self._data = data - self._camera_id = camera_id - self._camera_name = self._data.camera_data.get_camera(cid=camera_id).get("name") - self._name = f"{MANUFACTURER} {self._camera_name}" - self._camera_type = camera_type - self._unique_id = f"{self._camera_id}-{self._camera_type}" - self._verify_ssl = verify_ssl + Camera.__init__(self) + super().__init__(data_handler) + + self._data_classes.append( + {"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} + ) + + self._id = camera_id + self._home_id = home_id + self._device_name = self._data.get_camera(camera_id=camera_id).get("name") + self._name = f"{MANUFACTURER} {self._device_name}" + self._model = camera_type + self._unique_id = f"{self._id}-{self._model}" self._quality = quality self._vpnurl = None self._localurl = None @@ -91,6 +152,35 @@ class NetatmoCamera(Camera): self._alim_status = None self._is_local = None + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + self._listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def handle_event(self, event): + """Handle webhook events.""" + data = event.data["data"] + + if not data.get("event_type"): + return + + if not data.get("camera_id"): + return + + if data["home_id"] == self._home_id and data["camera_id"] == self._id: + if data["push_type"] in ["NACamera-off", "NACamera-disconnection"]: + self.is_streaming = False + self._status = "off" + elif data["push_type"] in ["NACamera-on", "NACamera-connection"]: + self.is_streaming = True + self._status = "on" + + self.async_write_ha_state() + return + def camera_image(self): """Return a still image response from the camera.""" try: @@ -100,77 +190,46 @@ class NetatmoCamera(Camera): ) elif self._vpnurl: response = requests.get( - f"{self._vpnurl}/live/snapshot_720.jpg", - timeout=10, - verify=self._verify_ssl, + f"{self._vpnurl}/live/snapshot_720.jpg", timeout=10, verify=True, ) else: _LOGGER.error("Welcome/Presence VPN URL is None") - self._data.update() - (self._vpnurl, self._localurl) = self._data.camera_data.camera_urls( - cid=self._camera_id + (self._vpnurl, self._localurl) = self._data.camera_urls( + camera_id=self._id ) return None + except requests.exceptions.RequestException as error: _LOGGER.info("Welcome/Presence URL changed: %s", error) - self._data.update() - (self._vpnurl, self._localurl) = self._data.camera_data.camera_urls( - cid=self._camera_id - ) + self._data.update_camera_urls(camera_id=self._id) + (self._vpnurl, self._localurl) = self._data.camera_urls(camera_id=self._id) return None + return response.content - @property - def should_poll(self) -> bool: - """Return True if entity has to be polled for state. - - False if entity pushes its state to HA. - """ - return True - - @property - def name(self): - """Return the name of this Netatmo camera device.""" - return self._name - - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._camera_id)}, - "name": self._camera_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._camera_type], - } - @property def device_state_attributes(self): """Return the Netatmo-specific camera state attributes.""" - attr = {} - attr["id"] = self._camera_id - attr["status"] = self._status - attr["sd_status"] = self._sd_status - attr["alim_status"] = self._alim_status - attr["is_local"] = self._is_local - attr["vpn_url"] = self._vpnurl - - return attr + return { + "id": self._id, + "status": self._status, + "sd_status": self._sd_status, + "alim_status": self._alim_status, + "is_local": self._is_local, + "vpn_url": self._vpnurl, + "local_url": self._localurl, + } @property def available(self): """Return True if entity is available.""" - return bool(self._alim_status == "on") + return bool(self._alim_status == "on" or self._status == "disconnected") @property def supported_features(self): """Return supported features.""" return SUPPORT_STREAM - @property - def is_recording(self): - """Return true if the device is recording.""" - return bool(self._status == "on") - @property def brand(self): """Return the camera brand.""" @@ -186,6 +245,16 @@ class NetatmoCamera(Camera): """Return true if on.""" return self.is_streaming + def turn_off(self): + """Turn off camera.""" + self._data.set_state( + home_id=self._home_id, camera_id=self._id, monitoring="off" + ) + + def turn_on(self): + """Turn on camera.""" + self._data.set_state(home_id=self._home_id, camera_id=self._id, monitoring="on") + async def stream_source(self): """Return the stream source.""" url = "{0}/live/files/{1}/index.m3u8" @@ -196,72 +265,48 @@ class NetatmoCamera(Camera): @property def model(self): """Return the camera model.""" - if self._camera_type == "NOC": - return "Presence" - if self._camera_type == "NACamera": - return "Welcome" - return None + return MODELS[self._model] - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - - def update(self): - """Update entity status.""" - self._data.update() - - camera = self._data.camera_data.get_camera(cid=self._camera_id) - - self._vpnurl, self._localurl = self._data.camera_data.camera_urls( - cid=self._camera_id - ) + @callback + def async_update_callback(self): + """Update the entity's state.""" + camera = self._data.get_camera(self._id) + self._vpnurl, self._localurl = self._data.camera_urls(self._id) self._status = camera.get("status") self._sd_status = camera.get("sd_status") self._alim_status = camera.get("alim_status") self._is_local = camera.get("is_local") - self.is_streaming = self._alim_status == "on" + self.is_streaming = bool(self._status == "on") + def _service_setpersonshome(self, **kwargs): + """Service to change current home schedule.""" + persons = kwargs.get(ATTR_PERSONS) + person_ids = [] + for person in persons: + for pid, data in self._data.persons.items(): + if data.get("pseudo") == person: + person_ids.append(pid) -class CameraData: - """Get the latest data from Netatmo.""" + self._data.set_persons_home(person_ids=person_ids, home_id=self._home_id) + _LOGGER.info("Set %s as at home", persons) - def __init__(self, hass, auth): - """Initialize the data object.""" - self._hass = hass - self.auth = auth - self.camera_data = None + def _service_setpersonaway(self, **kwargs): + """Service to mark a person as away or set the home as empty.""" + person = kwargs.get(ATTR_PERSON) + person_id = None + if person: + for pid, data in self._data.persons.items(): + if data.get("pseudo") == person: + person_id = pid - def get_all_cameras(self): - """Return all camera available on the API as a list.""" - self.update() - cameras = [] - for camera in self.camera_data.cameras.values(): - cameras.extend(camera.values()) - return cameras - - def get_modules(self, camera_id): - """Return all modules for a given camera.""" - return self.camera_data.get_camera(camera_id).get("modules", []) - - def get_camera_type(self, camera_id): - """Return camera type for a camera, cid has preference over camera.""" - return self.camera_data.cameraType(cid=camera_id) - - def update_persons(self): - """Gather person data for webhooks.""" - for person_id, person_data in self.camera_data.persons.items(): - self._hass.data[DOMAIN][DATA_PERSONS][person_id] = person_data.get( - ATTR_PSEUDO + if person_id is not None: + self._data.set_persons_away( + person_id=person_id, home_id=self._home_id, ) + _LOGGER.info("Set %s as away", person) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the Netatmo API to update the data.""" - self.camera_data = pyatmo.CameraData(self.auth, size=100) - self.update_persons() - - @Throttle(MIN_TIME_BETWEEN_EVENT_UPDATES) - def update_event(self, camera_type): - """Call the Netatmo API to update the events.""" - self.camera_data.updateEvent(devicetype=camera_type) + else: + self._data.set_persons_away( + person_id=person_id, home_id=self._home_id, + ) + _LOGGER.info("Set home as empty") diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 8de2694095e..459f005695b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,13 +1,10 @@ """Support for Netatmo Smart thermostats.""" -from datetime import timedelta import logging from typing import List, Optional -import pyatmo -import requests import voluptuous as vol -from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateEntity from homeassistant.components.climate.const import ( CURRENT_HVAC_HEAT, CURRENT_HVAC_IDLE, @@ -22,23 +19,28 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ( ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, ATTR_TEMPERATURE, PRECISION_HALVES, STATE_OFF, TEMP_CELSIUS, ) -from homeassistant.helpers import config_validation as cv -from homeassistant.util import Throttle +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, entity_platform from .const import ( - ATTR_HOME_NAME, + ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, - AUTH, + DATA_HANDLER, + DATA_HOMES, + DATA_SCHEDULES, DOMAIN, MANUFACTURER, - MODELS, SERVICE_SETSCHEDULE, + SIGNAL_NAME, ) +from .data_handler import HOMEDATA_DATA_CLASS_NAME, HOMESTATUS_DATA_CLASS_NAME +from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -88,11 +90,6 @@ HVAC_MAP_NETATMO = { CURRENT_HVAC_MAP_NETATMO = {True: CURRENT_HVAC_HEAT, False: CURRENT_HVAC_IDLE} -CONF_HOMES = "homes" -CONF_ROOMS = "rooms" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) - DEFAULT_MAX_TEMP = 30 NA_THERM = "NATherm1" @@ -100,54 +97,66 @@ NA_VALVE = "NRV" SCHEMA_SERVICE_SETSCHEDULE = vol.Schema( { + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(CLIMATE_DOMAIN), vol.Required(ATTR_SCHEDULE_NAME): cv.string, - vol.Required(ATTR_HOME_NAME): cv.string, } ) async def async_setup_entry(hass, entry, async_add_entities): """Set up the Netatmo energy platform.""" - auth = hass.data[DOMAIN][entry.entry_id][AUTH] + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - home_data = HomeData(auth) + await data_handler.register_data_class( + HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None + ) + home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME) - def get_entities(): + if not home_data: + return + + async def get_entities(): """Retrieve Netatmo entities.""" entities = [] - try: - home_data.setup() - except pyatmo.NoDevice: - return - home_ids = home_data.get_all_home_ids() - for home_id in home_ids: + for home_id in get_all_home_ids(home_data): _LOGGER.debug("Setting up home %s ...", home_id) - try: - room_data = ThermostatData(auth, home_id) - except pyatmo.NoDevice: - continue - for room_id in room_data.get_room_ids(): - room_name = room_data.homedata.rooms[home_id][room_id]["name"] + for room_id in home_data.rooms[home_id].keys(): + room_name = home_data.rooms[home_id][room_id]["name"] _LOGGER.debug("Setting up room %s (%s) ...", room_name, room_id) - entities.append(NetatmoThermostat(room_data, room_id)) + signal_name = f"{HOMESTATUS_DATA_CLASS_NAME}-{home_id}" + await data_handler.register_data_class( + HOMESTATUS_DATA_CLASS_NAME, signal_name, None, home_id=home_id + ) + home_status = data_handler.data.get(signal_name) + if home_status and room_id in home_status.rooms: + entities.append(NetatmoThermostat(data_handler, home_id, room_id)) + + hass.data[DOMAIN][DATA_SCHEDULES][home_id] = { + schedule_id: schedule_data.get("name") + for schedule_id, schedule_data in ( + data_handler.data[HOMEDATA_DATA_CLASS_NAME] + .schedules[home_id] + .items() + ) + } + + hass.data[DOMAIN][DATA_HOMES] = { + home_id: home_data.get("name") + for home_id, home_data in ( + data_handler.data[HOMEDATA_DATA_CLASS_NAME].homes.items() + ) + } + return entities - async_add_entities(await hass.async_add_executor_job(get_entities), True) + async_add_entities(await get_entities(), True) - def _service_setschedule(service): - """Service to change current home schedule.""" - home_name = service.data.get(ATTR_HOME_NAME) - schedule_name = service.data.get(ATTR_SCHEDULE_NAME) - home_data.homedata.switchHomeSchedule(schedule=schedule_name, home=home_name) - _LOGGER.info("Set home (%s) schedule to %s", home_name, schedule_name) + platform = entity_platform.current_platform.get() - if home_data.homedata is not None: - hass.services.async_register( - DOMAIN, - SERVICE_SETSCHEDULE, - _service_setschedule, - schema=SCHEMA_SERVICE_SETSCHEDULE, + if home_data is not None: + platform.async_register_entity_service( + SERVICE_SETSCHEDULE, SCHEMA_SERVICE_SETSCHEDULE, "_service_setschedule", ) @@ -156,16 +165,46 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return -class NetatmoThermostat(ClimateEntity): +class NetatmoThermostat(NetatmoBase, ClimateEntity): """Representation a Netatmo thermostat.""" - def __init__(self, data, room_id): + def __init__(self, data_handler, home_id, room_id): """Initialize the sensor.""" - self._data = data + ClimateEntity.__init__(self) + super().__init__(data_handler) + + self._id = room_id + self._home_id = home_id + + self._home_status_class = f"{HOMESTATUS_DATA_CLASS_NAME}-{self._home_id}" + + self._data_classes.extend( + [ + { + "name": HOMEDATA_DATA_CLASS_NAME, + SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME, + }, + { + "name": HOMESTATUS_DATA_CLASS_NAME, + "home_id": self._home_id, + SIGNAL_NAME: self._home_status_class, + }, + ] + ) + + self._home_status = self.data_handler.data[self._home_status_class] + self._room_status = self._home_status.rooms[room_id] + self._room_data = self._data.rooms[home_id][room_id] + + self._model = NA_VALVE + for module in self._room_data.get("module_ids"): + if self._home_status.thermostats.get(module): + self._model = NA_THERM + break + self._state = None - self._room_id = room_id - self._room_name = self._data.homedata.rooms[self._data.home_id][room_id]["name"] - self._name = f"{MANUFACTURER} {self._room_name}" + self._device_name = self._data.rooms[home_id][room_id]["name"] + self._name = f"{MANUFACTURER} {self._device_name}" self._current_temperature = None self._target_temperature = None self._preset = None @@ -175,41 +214,72 @@ class NetatmoThermostat(ClimateEntity): self._hvac_mode = None self._battery_level = None self._connected = None - self.update_without_throttle = False - self._module_type = self._data.room_status.get(room_id, {}).get( - "module_type", NA_VALVE - ) - if self._module_type == NA_THERM: + self._away_temperature = None + self._hg_temperature = None + self._boilerstatus = None + self._setpoint_duration = None + + if self._model == NA_THERM: self._operation_list.append(HVAC_MODE_OFF) - self._unique_id = f"{self._room_id}-{self._module_type}" + self._unique_id = f"{self._id}-{self._model}" - @property - def device_info(self): - """Return the device info for the thermostat/valve.""" - return { - "identifiers": {(DOMAIN, self._room_id)}, - "name": self._room_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._module_type], - } + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id + self._listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def handle_event(self, event): + """Handle webhook events.""" + data = event.data["data"] + + if not data.get("event_type"): + return + + if not data.get("home"): + return + + home = data["home"] + if self._home_id == home["id"] and data["event_type"] == "therm_mode": + self._preset = NETATMO_MAP_PRESET[home["therm_mode"]] + self._hvac_mode = HVAC_MAP_NETATMO[self._preset] + if self._preset == PRESET_FROST_GUARD: + self._target_temperature = self._hg_temperature + elif self._preset == PRESET_AWAY: + self._target_temperature = self._away_temperature + elif self._preset == PRESET_SCHEDULE: + self.async_update_callback() + self.async_write_ha_state() + return + + if not home.get("rooms"): + return + + for room in home["rooms"]: + if data["event_type"] == "set_point": + if self._id == room["id"]: + if room["therm_setpoint_mode"] == "off": + self._hvac_mode = HVAC_MODE_OFF + else: + self._target_temperature = room["therm_setpoint_temperature"] + self.async_write_ha_state() + break + + elif data["event_type"] == "cancel_set_point": + if self._id == room["id"]: + self.async_update_callback() + self.async_write_ha_state() + break @property def supported_features(self): """Return the list of supported features.""" return self._support_flags - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - @property def temperature_unit(self): """Return the unit of measurement.""" @@ -243,15 +313,11 @@ class NetatmoThermostat(ClimateEntity): @property def hvac_action(self) -> Optional[str]: """Return the current running hvac operation if supported.""" - if self._module_type == NA_THERM: - return CURRENT_HVAC_MAP_NETATMO[self._data.boilerstatus] + if self._model == NA_THERM: + return CURRENT_HVAC_MAP_NETATMO[self._boilerstatus] # Maybe it is a valve - if self._room_id in self._data.room_status: - if ( - self._data.room_status[self._room_id].get("heating_power_request", 0) - > 0 - ): - return CURRENT_HVAC_HEAT + if self._room_status and self._room_status.get("heating_power_request", 0) > 0: + return CURRENT_HVAC_HEAT return CURRENT_HVAC_IDLE def set_hvac_mode(self, hvac_mode: str) -> None: @@ -268,33 +334,24 @@ class NetatmoThermostat(ClimateEntity): def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if self.target_temperature == 0: - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_HOME, + self._home_status.set_room_thermpoint( + self._id, STATE_NETATMO_HOME, ) - if ( - preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] - and self._module_type == NA_VALVE - ): - self._data.homestatus.setroomThermpoint( - self._data.home_id, - self._room_id, - STATE_NETATMO_MANUAL, - DEFAULT_MAX_TEMP, + if preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX] and self._model == NA_VALVE: + self._home_status.set_room_thermpoint( + self._id, STATE_NETATMO_MANUAL, DEFAULT_MAX_TEMP, ) elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX]: - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, PRESET_MAP_NETATMO[preset_mode] + self._home_status.set_room_thermpoint( + self._id, PRESET_MAP_NETATMO[preset_mode] ) elif preset_mode in [PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY]: - self._data.homestatus.setThermmode( - self._data.home_id, PRESET_MAP_NETATMO[preset_mode] - ) + self._home_status.set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) - self.update_without_throttle = True - self.schedule_update_ha_state() + self.async_write_ha_state() @property def preset_mode(self) -> Optional[str]: @@ -311,12 +368,9 @@ class NetatmoThermostat(ClimateEntity): temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: return - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_MANUAL, temp - ) + self._home_status.set_room_thermpoint(self._id, STATE_NETATMO_MANUAL, temp) - self.update_without_throttle = True - self.schedule_update_ha_state() + self.async_write_ha_state() @property def device_state_attributes(self): @@ -326,241 +380,147 @@ class NetatmoThermostat(ClimateEntity): if self._battery_level is not None: attr[ATTR_BATTERY_LEVEL] = self._battery_level + if self._model == NA_VALVE: + attr[ATTR_HEATING_POWER_REQUEST] = self._room_status.get( + "heating_power_request", 0 + ) + return attr def turn_off(self): """Turn the entity off.""" - if self._module_type == NA_VALVE: - self._data.homestatus.setroomThermpoint( - self._data.home_id, - self._room_id, - STATE_NETATMO_MANUAL, - DEFAULT_MIN_TEMP, + if self._model == NA_VALVE: + self._home_status.set_room_thermpoint( + self._id, STATE_NETATMO_MANUAL, DEFAULT_MIN_TEMP, ) elif self.hvac_mode != HVAC_MODE_OFF: - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_OFF - ) - self.update_without_throttle = True - self.schedule_update_ha_state() + self._home_status.set_room_thermpoint(self._id, STATE_NETATMO_OFF) + self.async_write_ha_state() def turn_on(self): """Turn the entity on.""" - self._data.homestatus.setroomThermpoint( - self._data.home_id, self._room_id, STATE_NETATMO_HOME - ) - self.update_without_throttle = True - self.schedule_update_ha_state() + self._home_status.set_room_thermpoint(self._id, STATE_NETATMO_HOME) + self.async_write_ha_state() @property def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" return bool(self._connected) - def update(self): - """Get the latest data from NetAtmo API and updates the states.""" + @callback + def async_update_callback(self): + """Update the entity's state.""" + self._home_status = self.data_handler.data[self._home_status_class] + self._room_status = self._home_status.rooms[self._id] + self._room_data = self._data.rooms[self._home_id][self._id] + + roomstatus = {"roomID": self._room_status["id"]} + if self._room_status.get("reachable"): + roomstatus.update(self._build_room_status()) + + self._away_temperature = self._data.get_away_temp(self._home_id) + self._hg_temperature = self._data.get_hg_temp(self._home_id) + self._setpoint_duration = self._data.setpoint_duration[self._home_id] + try: - if self.update_without_throttle: - self._data.update(no_throttle=True) - self.update_without_throttle = False - else: - self._data.update() - except AttributeError: - _LOGGER.error("NetatmoThermostat::update() got exception") - return - try: - if self._module_type is None: - self._module_type = self._data.room_status[self._room_id]["module_type"] - self._current_temperature = self._data.room_status[self._room_id][ - "current_temperature" - ] - self._target_temperature = self._data.room_status[self._room_id][ - "target_temperature" - ] - self._preset = NETATMO_MAP_PRESET[ - self._data.room_status[self._room_id]["setpoint_mode"] - ] + if self._model is None: + self._model = roomstatus["module_type"] + self._current_temperature = roomstatus["current_temperature"] + self._target_temperature = roomstatus["target_temperature"] + self._preset = NETATMO_MAP_PRESET[roomstatus["setpoint_mode"]] self._hvac_mode = HVAC_MAP_NETATMO[self._preset] - self._battery_level = self._data.room_status[self._room_id].get( - "battery_level" - ) + self._battery_level = roomstatus.get("battery_level") self._connected = True + except KeyError as err: - if self._connected is not False: + if self._connected: _LOGGER.debug( "The thermostat in room %s seems to be out of reach. (%s)", - self._room_name, + self._device_name, err, ) + self._connected = False + self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - -class HomeData: - """Representation Netatmo homes.""" - - def __init__(self, auth, home=None): - """Initialize the HomeData object.""" - self.auth = auth - self.homedata = None - self.home_ids = [] - self.home_names = [] - self.room_names = [] - self.schedules = [] - self.home = home - self.home_id = None - - def get_all_home_ids(self): - """Get all the home ids returned by NetAtmo API.""" - if self.homedata is None: - return [] - for home_id in self.homedata.homes: - if ( - "therm_schedules" in self.homedata.homes[home_id] - and "modules" in self.homedata.homes[home_id] - ): - self.home_ids.append(self.homedata.homes[home_id]["id"]) - return self.home_ids - - def setup(self): - """Retrieve HomeData by NetAtmo API.""" + def _build_room_status(self): + """Construct room status.""" try: - self.homedata = pyatmo.HomeData(self.auth) - self.home_id = self.homedata.gethomeId(self.home) - except TypeError: - _LOGGER.error("Error when getting home data") - except AttributeError: - _LOGGER.error("No default_home in HomeData") - except pyatmo.NoDevice: - _LOGGER.debug("No thermostat devices available") - except pyatmo.InvalidHome: - _LOGGER.debug("Invalid home %s", self.home) + roomstatus = { + "roomname": self._room_data["name"], + "target_temperature": self._room_status["therm_setpoint_temperature"], + "setpoint_mode": self._room_status["therm_setpoint_mode"], + "current_temperature": self._room_status["therm_measured_temperature"], + "module_type": self._data.get_thermostat_type( + home_id=self._home_id, room_id=self._id + ), + "module_id": None, + "heating_status": None, + "heating_power_request": None, + } - -class ThermostatData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, home_id=None): - """Initialize the data object.""" - self.auth = auth - self.homedata = None - self.homestatus = None - self.room_ids = [] - self.room_status = {} - self.schedules = [] - self.home_id = home_id - self.home_name = None - self.away_temperature = None - self.hg_temperature = None - self.boilerstatus = None - self.setpoint_duration = None - - def get_room_ids(self): - """Return all module available on the API as a list.""" - if not self.setup(): - return [] - for room in self.homestatus.rooms: - self.room_ids.append(room) - return self.room_ids - - def setup(self): - """Retrieve HomeData and HomeStatus by NetAtmo API.""" - try: - self.homedata = pyatmo.HomeData(self.auth) - self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) - self.home_name = self.homedata.getHomeName(self.home_id) - self.update() - except TypeError: - _LOGGER.error("ThermostatData::setup() got error") - return False - except pyatmo.exceptions.NoDevice: - _LOGGER.debug( - "No climate devices for %s (%s)", self.home_name, self.home_id - ) - return False - return True - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the NetAtmo API to update the data.""" - try: - self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) - except pyatmo.exceptions.NoDevice: - _LOGGER.error("No device found") - return - except TypeError: - _LOGGER.error("Error when getting homestatus") - return - except requests.exceptions.Timeout: - _LOGGER.warning("Timed out when connecting to Netatmo server") - return - for room in self.homestatus.rooms: - try: - roomstatus = {} - homestatus_room = self.homestatus.rooms[room] - homedata_room = self.homedata.rooms[self.home_id][room] - - roomstatus["roomID"] = homestatus_room["id"] - if homestatus_room["reachable"]: - roomstatus["roomname"] = homedata_room["name"] - roomstatus["target_temperature"] = homestatus_room[ - "therm_setpoint_temperature" - ] - roomstatus["setpoint_mode"] = homestatus_room["therm_setpoint_mode"] - roomstatus["current_temperature"] = homestatus_room[ - "therm_measured_temperature" - ] - roomstatus["module_type"] = self.homestatus.thermostatType( - home_id=self.home_id, rid=room, home=self.home_name + batterylevel = None + for module_id in self._room_data["module_ids"]: + if ( + self._data.modules[self._home_id][module_id]["type"] == NA_THERM + or roomstatus["module_id"] is None + ): + roomstatus["module_id"] = module_id + if roomstatus["module_type"] == NA_THERM: + self._boilerstatus = self._home_status.boiler_status( + roomstatus["module_id"] + ) + roomstatus["heating_status"] = self._boilerstatus + batterylevel = self._home_status.thermostats[ + roomstatus["module_id"] + ].get("battery_level") + elif roomstatus["module_type"] == NA_VALVE: + roomstatus["heating_power_request"] = self._room_status[ + "heating_power_request" + ] + roomstatus["heating_status"] = roomstatus["heating_power_request"] > 0 + if self._boilerstatus is not None: + roomstatus["heating_status"] = ( + self._boilerstatus and roomstatus["heating_status"] ) - roomstatus["module_id"] = None - roomstatus["heating_status"] = None - roomstatus["heating_power_request"] = None - batterylevel = None - for module_id in homedata_room["module_ids"]: - if ( - self.homedata.modules[self.home_id][module_id]["type"] - == NA_THERM - or roomstatus["module_id"] is None - ): - roomstatus["module_id"] = module_id - if roomstatus["module_type"] == NA_THERM: - self.boilerstatus = self.homestatus.boilerStatus( - rid=roomstatus["module_id"] - ) - roomstatus["heating_status"] = self.boilerstatus - batterylevel = self.homestatus.thermostats[ - roomstatus["module_id"] - ].get("battery_level") - elif roomstatus["module_type"] == NA_VALVE: - roomstatus["heating_power_request"] = homestatus_room[ - "heating_power_request" - ] - roomstatus["heating_status"] = ( - roomstatus["heating_power_request"] > 0 - ) - if self.boilerstatus is not None: - roomstatus["heating_status"] = ( - self.boilerstatus and roomstatus["heating_status"] - ) - batterylevel = self.homestatus.valves[ - roomstatus["module_id"] - ].get("battery_level") + batterylevel = self._home_status.valves[roomstatus["module_id"]].get( + "battery_level" + ) - if batterylevel: - batterypct = interpolate( - batterylevel, roomstatus["module_type"] - ) - if roomstatus.get("battery_level") is None: - roomstatus["battery_level"] = batterypct - elif batterypct < roomstatus["battery_level"]: - roomstatus["battery_level"] = batterypct - self.room_status[room] = roomstatus - except KeyError as err: - _LOGGER.error("Update of room %s failed. Error: %s", room, err) - self.away_temperature = self.homestatus.getAwaytemp(home_id=self.home_id) - self.hg_temperature = self.homestatus.getHgtemp(home_id=self.home_id) - self.setpoint_duration = self.homedata.setpoint_duration[self.home_id] + if batterylevel: + batterypct = interpolate(batterylevel, roomstatus["module_type"]) + if ( + not roomstatus.get("battery_level") + or batterypct < roomstatus["battery_level"] + ): + roomstatus["battery_level"] = batterypct + + return roomstatus + + except KeyError as err: + _LOGGER.error("Update of room %s failed. Error: %s", self._id, err) + + return {} + + def _service_setschedule(self, **kwargs): + schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) + schedule_id = None + for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): + if name == schedule_name: + schedule_id = sid + + if not schedule_id: + _LOGGER.error("You passed an invalid schedule") + return + + self._data.switch_home_schedule(home_id=self._home_id, schedule_id=schedule_id) + _LOGGER.info( + "Setting %s schedule to %s (%s)", + self._home_id, + kwargs.get(ATTR_SCHEDULE_NAME), + schedule_id, + ) def interpolate(batterylevel, module_type): @@ -603,3 +563,17 @@ def interpolate(batterylevel, module_type): / (levels[i + 1] - levels[i]) ) return int(pct) + + +def get_all_home_ids(home_data): + """Get all the home ids returned by NetAtmo API.""" + if home_data is None: + return [] + return [ + home_data.homes[home_id]["id"] + for home_id in home_data.homes + if ( + "therm_schedules" in home_data.homes[home_id] + and "modules" in home_data.homes[home_id] + ) + ] diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 380878c6e73..eedac3229c0 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Netatmo.""" import logging +import uuid import voluptuous as vol @@ -16,6 +17,7 @@ from .const import ( CONF_LON_SW, CONF_NEW_AREA, CONF_PUBLIC_MODE, + CONF_UUID, CONF_WEATHER_AREAS, DOMAIN, ) @@ -66,6 +68,10 @@ class NetatmoFlowHandler( async def async_step_user(self, user_input=None): """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) + + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + return await super().async_step_user(user_input) async def async_step_homekit(self, homekit_info): @@ -102,7 +108,7 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): user_input={CONF_NEW_AREA: new_client} ) - return await self._update_options() + return self._update_options() weather_areas = list(self.options[CONF_WEATHER_AREAS]) @@ -121,7 +127,14 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_public_weather(self, user_input=None): """Manage configuration of Netatmo public weather sensors.""" if user_input is not None and CONF_NEW_AREA not in user_input: - self.options[CONF_WEATHER_AREAS][user_input[CONF_AREA_NAME]] = user_input + self.options[CONF_WEATHER_AREAS][ + user_input[CONF_AREA_NAME] + ] = fix_coordinates(user_input) + + self.options[CONF_WEATHER_AREAS][user_input[CONF_AREA_NAME]][ + CONF_UUID + ] = str(uuid.uuid4()) + return await self.async_step_public_weather_areas() orig_options = self.config_entry.options.get(CONF_WEATHER_AREAS, {}).get( @@ -170,8 +183,30 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="public_weather", data_schema=data_schema) - async def _update_options(self): + def _update_options(self): """Update config entry options.""" return self.async_create_entry( title="Netatmo Public Weather", data=self.options ) + + +def fix_coordinates(user_input): + """Fix coordinates if they don't comply with the Netatmo API.""" + # Ensure coordinates have acceptable length for the Netatmo API + for coordinate in [CONF_LAT_NE, CONF_LAT_SW, CONF_LON_NE, CONF_LON_SW]: + if len(str(user_input[coordinate]).split(".")[1]) < 7: + user_input[coordinate] = user_input[coordinate] + 0.0000001 + + # Swap coordinates if entered in wrong order + if user_input[CONF_LAT_NE] < user_input[CONF_LAT_SW]: + user_input[CONF_LAT_NE], user_input[CONF_LAT_SW] = ( + user_input[CONF_LAT_SW], + user_input[CONF_LAT_NE], + ) + if user_input[CONF_LON_NE] < user_input[CONF_LON_SW]: + user_input[CONF_LON_NE], user_input[CONF_LON_SW] = ( + user_input[CONF_LON_SW], + user_input[CONF_LON_NE], + ) + + return user_input diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 835d42a32ba..c23b934c541 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -27,6 +27,8 @@ AUTH = "netatmo_auth" CONF_PUBLIC = "public_sensor_config" CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" +DATA_HANDLER = "netatmo_data_handler" +SIGNAL_NAME = "signal_name" CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_WEATHER_AREAS = "weather_areas" @@ -37,12 +39,15 @@ CONF_LON_NE = "lon_ne" CONF_LAT_SW = "lat_sw" CONF_LON_SW = "lon_sw" CONF_PUBLIC_MODE = "mode" +CONF_UUID = "uuid" OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token" DATA_DEVICE_IDS = "netatmo_device_ids" +DATA_HOMES = "netatmo_homes" DATA_PERSONS = "netatmo_persons" +DATA_SCHEDULES = "netatmo_schedules" NETATMO_WEBHOOK_URL = None NETATMO_EVENT = "netatmo_event" @@ -55,8 +60,10 @@ ATTR_ID = "id" ATTR_PSEUDO = "pseudo" ATTR_NAME = "name" ATTR_EVENT_TYPE = "event_type" +ATTR_HEATING_POWER_REQUEST = "heating_power_request" ATTR_HOME_ID = "home_id" ATTR_HOME_NAME = "home_name" +ATTR_PERSON = "person" ATTR_PERSONS = "persons" ATTR_IS_KNOWN = "is_known" ATTR_FACE_URL = "face_url" @@ -67,3 +74,5 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) MIN_TIME_BETWEEN_EVENT_UPDATES = timedelta(seconds=5) SERVICE_SETSCHEDULE = "set_schedule" +SERVICE_SETPERSONSHOME = "set_persons_home" +SERVICE_SETPERSONAWAY = "set_person_away" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py new file mode 100644 index 00000000000..414c89e13ec --- /dev/null +++ b/homeassistant/components/netatmo/data_handler.py @@ -0,0 +1,166 @@ +"""The Netatmo data handler.""" +from collections import deque +from datetime import timedelta +from functools import partial +from itertools import islice +import logging +from time import time +from typing import Deque, Dict, List + +import pyatmo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .const import AUTH, DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +CAMERA_DATA_CLASS_NAME = "CameraData" +WEATHERSTATION_DATA_CLASS_NAME = "WeatherStationData" +HOMECOACH_DATA_CLASS_NAME = "HomeCoachData" +HOMEDATA_DATA_CLASS_NAME = "HomeData" +HOMESTATUS_DATA_CLASS_NAME = "HomeStatus" +PUBLICDATA_DATA_CLASS_NAME = "PublicData" + +NEXT_SCAN = "next_scan" + +DATA_CLASSES = { + WEATHERSTATION_DATA_CLASS_NAME: pyatmo.WeatherStationData, + HOMECOACH_DATA_CLASS_NAME: pyatmo.HomeCoachData, + CAMERA_DATA_CLASS_NAME: pyatmo.CameraData, + HOMEDATA_DATA_CLASS_NAME: pyatmo.HomeData, + HOMESTATUS_DATA_CLASS_NAME: pyatmo.HomeStatus, + PUBLICDATA_DATA_CLASS_NAME: pyatmo.PublicData, +} + +MAX_CALLS_1H = 20 +BATCH_SIZE = 3 +DEFAULT_INTERVALS = { + HOMEDATA_DATA_CLASS_NAME: 900, + HOMESTATUS_DATA_CLASS_NAME: 300, + CAMERA_DATA_CLASS_NAME: 900, + WEATHERSTATION_DATA_CLASS_NAME: 300, + HOMECOACH_DATA_CLASS_NAME: 300, + PUBLICDATA_DATA_CLASS_NAME: 600, +} +SCAN_INTERVAL = 60 + + +class NetatmoDataHandler: + """Manages the Netatmo data handling.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + """Initialize self.""" + self.hass = hass + self._auth = hass.data[DOMAIN][entry.entry_id][AUTH] + self.listeners: List[CALLBACK_TYPE] = [] + self._data_classes: Dict = {} + self.data = {} + self._queue: Deque = deque() + self._webhook: bool = False + + async def async_setup(self): + """Set up the Netatmo data handler.""" + + async_track_time_interval( + self.hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) + ) + + self.listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def async_update(self, event_time): + """ + Update device. + + We do up to BATCH_SIZE calls in one update in order + to minimize the calls on the api service. + """ + for data_class in islice(self._queue, 0, BATCH_SIZE): + if data_class[NEXT_SCAN] > time(): + continue + self._data_classes[data_class["name"]][NEXT_SCAN] = ( + time() + data_class["interval"] + ) + + await self.async_fetch_data( + data_class["class"], data_class["name"], **data_class["kwargs"] + ) + + self._queue.rotate(BATCH_SIZE) + + async def async_cleanup(self): + """Clean up the Netatmo data handler.""" + for listener in self.listeners: + listener() + + async def handle_event(self, event): + """Handle webhook events.""" + if event.data["data"]["push_type"] == "webhook_activation": + _LOGGER.info("%s webhook successfully registered", MANUFACTURER) + self._webhook = True + + elif event.data["data"]["push_type"] == "NACamera-connection": + _LOGGER.debug("%s camera reconnected", MANUFACTURER) + self._data_classes[CAMERA_DATA_CLASS_NAME][NEXT_SCAN] = time() + + async def async_fetch_data(self, data_class, data_class_entry, **kwargs): + """Fetch data and notify.""" + try: + self.data[data_class_entry] = await self.hass.async_add_executor_job( + partial(data_class, **kwargs), self._auth, + ) + for update_callback in self._data_classes[data_class_entry][ + "subscriptions" + ]: + if update_callback: + update_callback() + + except (pyatmo.NoDevice, pyatmo.ApiError) as err: + _LOGGER.debug(err) + + async def register_data_class( + self, data_class_name, data_class_entry, update_callback, **kwargs + ): + """Register data class.""" + if data_class_entry not in self._data_classes: + self._data_classes[data_class_entry] = { + "class": DATA_CLASSES[data_class_name], + "name": data_class_entry, + "interval": DEFAULT_INTERVALS[data_class_name], + NEXT_SCAN: time() + DEFAULT_INTERVALS[data_class_name], + "kwargs": kwargs, + "subscriptions": [update_callback], + } + + await self.async_fetch_data( + DATA_CLASSES[data_class_name], data_class_entry, **kwargs + ) + + self._queue.append(self._data_classes[data_class_entry]) + _LOGGER.debug("Data class %s added", data_class_entry) + + else: + self._data_classes[data_class_entry]["subscriptions"].append( + update_callback + ) + + async def unregister_data_class(self, data_class_entry, update_callback): + """Unregister data class.""" + if update_callback not in self._data_classes[data_class_entry]["subscriptions"]: + return + + self._data_classes[data_class_entry]["subscriptions"].remove(update_callback) + + if not self._data_classes[data_class_entry].get("subscriptions"): + self._queue.remove(self._data_classes[data_class_entry]) + self._data_classes.pop(data_class_entry) + _LOGGER.debug("Data class %s removed", data_class_entry) + + @property + def webhook(self) -> bool: + """Return the webhook state.""" + return self._webhook diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py new file mode 100644 index 00000000000..d9ef4d1e455 --- /dev/null +++ b/homeassistant/components/netatmo/helper.py @@ -0,0 +1,17 @@ +"""Helper for Netatmo integration.""" +from dataclasses import dataclass +from uuid import uuid4 + + +@dataclass +class NetatmoArea: + """Class for keeping track of an area.""" + + area_name: str + lat_ne: float + lon_ne: float + lat_sw: float + lon_sw: float + mode: str + show_on_map: bool + uuid: str = uuid4() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py new file mode 100644 index 00000000000..56cf7945402 --- /dev/null +++ b/homeassistant/components/netatmo/light.py @@ -0,0 +1,145 @@ +"""Support for the Netatmo camera lights.""" +import logging + +import pyatmo + +from homeassistant.components.light import LightEntity +from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady + +from .const import DATA_HANDLER, DOMAIN, MANUFACTURER, SIGNAL_NAME +from .data_handler import CAMERA_DATA_CLASS_NAME, NetatmoDataHandler +from .netatmo_entity_base import NetatmoBase + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Netatmo camera light platform.""" + if "access_camera" not in entry.data["token"]["scope"]: + _LOGGER.info( + "Cameras are currently not supported with this authentication method" + ) + return + + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + + async def get_entities(): + """Retrieve Netatmo entities.""" + await data_handler.register_data_class( + CAMERA_DATA_CLASS_NAME, CAMERA_DATA_CLASS_NAME, None + ) + + entities = [] + try: + all_cameras = [] + for home in data_handler.data[CAMERA_DATA_CLASS_NAME].cameras.values(): + for camera in home.values(): + all_cameras.append(camera) + + for camera in all_cameras: + if camera["type"] == "NOC": + if not data_handler.webhook: + raise PlatformNotReady + + _LOGGER.debug( + "Adding camera light %s %s", camera["id"], camera["name"] + ) + entities.append( + NetatmoLight( + data_handler, + camera["id"], + camera["type"], + camera["home_id"], + ) + ) + + except pyatmo.NoDevice: + _LOGGER.debug("No cameras found") + + return entities + + async_add_entities(await get_entities(), True) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Netatmo camera platform.""" + return + + +class NetatmoLight(NetatmoBase, LightEntity): + """Representation of a Netatmo Presence camera light.""" + + def __init__( + self, + data_handler: NetatmoDataHandler, + camera_id: str, + camera_type: str, + home_id: str, + ): + """Initialize a Netatmo Presence camera light.""" + LightEntity.__init__(self) + super().__init__(data_handler) + + self._data_classes.append( + {"name": CAMERA_DATA_CLASS_NAME, SIGNAL_NAME: CAMERA_DATA_CLASS_NAME} + ) + self._id = camera_id + self._home_id = home_id + self._model = camera_type + self._device_name = self._data.get_camera(camera_id).get("name") + self._name = f"{MANUFACTURER} {self._device_name}" + self._is_on = False + self._unique_id = f"{self._id}-light" + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + self._listeners.append( + self.hass.bus.async_listen("netatmo_event", self.handle_event) + ) + + async def handle_event(self, event): + """Handle webhook events.""" + data = event.data["data"] + + if not data.get("event_type"): + return + + if not data.get("camera_id"): + return + + if ( + data["home_id"] == self._home_id + and data["camera_id"] == self._id + and data["push_type"] == "NOC-light_mode" + ): + self._is_on = bool(data["sub_type"] == "on") + + self.async_write_ha_state() + return + + @property + def is_on(self): + """Return true if light is on.""" + return self._is_on + + def turn_on(self, **kwargs): + """Turn camera floodlight on.""" + _LOGGER.debug("Turn camera '%s' on", self._name) + self._data.set_state( + home_id=self._home_id, camera_id=self._id, floodlight="on", + ) + + def turn_off(self, **kwargs): + """Turn camera floodlight into auto mode.""" + _LOGGER.debug("Turn camera '%s' off", self._name) + self._data.set_state( + home_id=self._home_id, camera_id=self._id, floodlight="auto", + ) + + @callback + def async_update_callback(self): + """Update the entity's state.""" + self._is_on = bool(self._data.get_light_state(self._id) == "on") diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index ece1b33c608..fe8c5367093 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==3.3.1" + "pyatmo==4.0.0" ], "after_dependencies": [ "cloud" diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py new file mode 100644 index 00000000000..6bae7d54168 --- /dev/null +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -0,0 +1,113 @@ +"""Base class for Netatmo entities.""" +import logging +from typing import Dict, List + +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER, MODELS, SIGNAL_NAME +from .data_handler import NetatmoDataHandler + +_LOGGER = logging.getLogger(__name__) + + +class NetatmoBase(Entity): + """Netatmo entity base class.""" + + def __init__(self, data_handler: NetatmoDataHandler) -> None: + """Set up Netatmo entity base.""" + self.data_handler = data_handler + self._data_classes: List[Dict] = [] + self._listeners: List[CALLBACK_TYPE] = [] + + self._device_name = None + self._id = None + self._model = None + self._name = None + self._unique_id = None + + async def async_added_to_hass(self) -> None: + """Entity created.""" + _LOGGER.debug("New client %s", self.entity_id) + for data_class in self._data_classes: + signal_name = data_class[SIGNAL_NAME] + + if "home_id" in data_class: + await self.data_handler.register_data_class( + data_class["name"], + signal_name, + self.async_update_callback, + home_id=data_class["home_id"], + ) + + elif data_class["name"] == "PublicData": + await self.data_handler.register_data_class( + data_class["name"], + signal_name, + self.async_update_callback, + LAT_NE=data_class["LAT_NE"], + LON_NE=data_class["LON_NE"], + LAT_SW=data_class["LAT_SW"], + LON_SW=data_class["LON_SW"], + ) + + else: + await self.data_handler.register_data_class( + data_class["name"], signal_name, self.async_update_callback + ) + + await self.data_handler.unregister_data_class(signal_name, None) + + self.async_update_callback() + + async def async_will_remove_from_hass(self): + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + + for listener in self._listeners: + listener() + + for data_class in self._data_classes: + await self.data_handler.unregister_data_class( + data_class[SIGNAL_NAME], self.async_update_callback + ) + + async def async_remove(self): + """Clean up when removing entity.""" + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + entity_entry = entity_registry.async_get(self.entity_id) + if not entity_entry: + await super().async_remove() + return + + entity_registry.async_remove(self.entity_id) + + @callback + def async_update_callback(self): + """Update the entity's state.""" + raise NotImplementedError + + @property + def _data(self): + """Return data for this entity.""" + return self.data_handler.data[self._data_classes[0]["name"]] + + @property + def unique_id(self): + """Return the unique ID of this entity.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity.""" + return self._name + + @property + def device_info(self): + """Return the device info for the sensor.""" + return { + "identifiers": {(DOMAIN, self._id)}, + "name": self._device_name, + "manufacturer": MANUFACTURER, + "model": MODELS[self._model], + } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 6aaa7d08975..2352b4abee8 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,15 +1,11 @@ """Support for the Netatmo Weather Service.""" -from datetime import timedelta import logging -import pyatmo - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONCENTRATION_PARTS_PER_MILLION, - CONF_SHOW_ON_MAP, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -23,31 +19,18 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from .const import ( - AUTH, - CONF_AREA_NAME, - CONF_LAT_NE, - CONF_LAT_SW, - CONF_LON_NE, - CONF_LON_SW, - CONF_PUBLIC_MODE, - CONF_WEATHER_AREAS, - DOMAIN, - MANUFACTURER, - MODELS, +from .const import CONF_WEATHER_AREAS, DATA_HANDLER, DOMAIN, MANUFACTURER, SIGNAL_NAME +from .data_handler import ( + HOMECOACH_DATA_CLASS_NAME, + PUBLICDATA_DATA_CLASS_NAME, + WEATHERSTATION_DATA_CLASS_NAME, ) +from .helper import NetatmoArea +from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -# This is the Netatmo data upload interval in seconds -NETATMO_UPDATE_INTERVAL = 600 - -# NetAtmo Public Data is uploaded to server every 10 minutes -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=NETATMO_UPDATE_INTERVAL) - SUPPORTED_PUBLIC_SENSOR_TYPES = [ "temperature", "pressure", @@ -76,11 +59,11 @@ SENSOR_TYPES = { DEVICE_CLASS_HUMIDITY, ], "rain": ["Rain", "mm", "mdi:weather-rainy", None], - "sum_rain_1": ["sum_rain_1", "mm", "mdi:weather-rainy", None], - "sum_rain_24": ["sum_rain_24", "mm", "mdi:weather-rainy", None], + "sum_rain_1": ["Rain last hour", "mm", "mdi:weather-rainy", None], + "sum_rain_24": ["Rain last 24h", "mm", "mdi:weather-rainy", None], "battery_vp": ["Battery", "", "mdi:battery", None], - "battery_lvl": ["Battery_lvl", "", "mdi:battery", None], - "battery_percent": ["battery_percent", UNIT_PERCENTAGE, None, DEVICE_CLASS_BATTERY], + "battery_lvl": ["Battery Level", "", "mdi:battery", None], + "battery_percent": ["Battery Percent", UNIT_PERCENTAGE, None, DEVICE_CLASS_BATTERY], "min_temp": ["Min Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "windangle": ["Angle", "", "mdi:compass", None], @@ -101,9 +84,9 @@ SENSOR_TYPES = { ], "reachable": ["Reachability", "", "mdi:signal", None], "rf_status": ["Radio", "", "mdi:signal", None], - "rf_status_lvl": ["Radio_lvl", "", "mdi:signal", None], + "rf_status_lvl": ["Radio Level", "", "mdi:signal", None], "wifi_status": ["Wifi", "", "mdi:wifi", None], - "wifi_status_lvl": ["Wifi_lvl", "dBm", "mdi:wifi", None], + "wifi_status_lvl": ["Wifi Level", "dBm", "mdi:wifi", None], "health_idx": ["Health", "", "mdi:cloud", None], } @@ -112,76 +95,110 @@ MODULE_TYPE_WIND = "NAModule2" MODULE_TYPE_RAIN = "NAModule3" MODULE_TYPE_INDOOR = "NAModule4" - -NETATMO_DEVICE_TYPES = { - "WeatherStationData": "weather station", - "HomeCoachData": "home coach", +BATTERY_VALUES = { + MODULE_TYPE_WIND: {"Full": 5590, "High": 5180, "Medium": 4770, "Low": 4360}, + MODULE_TYPE_RAIN: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, + MODULE_TYPE_INDOOR: {"Full": 5500, "High": 5280, "Medium": 4920, "Low": 4560}, + MODULE_TYPE_OUTDOOR: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, } PUBLIC = "public" -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): +async def async_setup_entry(hass, entry, async_add_entities): """Set up the Netatmo weather and homecoach platform.""" - auth = hass.data[DOMAIN][entry.entry_id][AUTH] device_registry = await hass.helpers.device_registry.async_get_registry() + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] - def find_entities(data): + async def find_entities(data_class_name): """Find all entities.""" - all_module_infos = data.get_module_infos() + await data_handler.register_data_class(data_class_name, data_class_name, None) + + all_module_infos = {} + data = data_handler.data + + if not data.get(data_class_name): + return [] + + data_class = data[data_class_name] + + for station_id in data_class.stations: + for module_id in data_class.get_modules(station_id): + all_module_infos[module_id] = data_class.get_module(module_id) + + all_module_infos[station_id] = data_class.get_station(station_id) + entities = [] for module in all_module_infos.values(): - _LOGGER.debug("Adding module %s %s", module["module_name"], module["id"]) - for condition in data.station_data.monitoredConditions( - moduleId=module["id"] - ): - entities.append(NetatmoSensor(data, module, condition.lower())) - return entities - - def get_entities(): - """Retrieve Netatmo entities.""" - entities = [] - - for data_class in [pyatmo.WeatherStationData, pyatmo.HomeCoachData]: - try: - dc_data = data_class(auth) - _LOGGER.debug("%s detected!", NETATMO_DEVICE_TYPES[data_class.__name__]) - data = NetatmoData(auth, dc_data) - except pyatmo.NoDevice: - _LOGGER.debug( - "No %s entities found", NETATMO_DEVICE_TYPES[data_class.__name__] - ) + if "_id" not in module: + _LOGGER.debug("Skipping module %s", module.get("module_name")) continue - entities.extend(find_entities(data)) + _LOGGER.debug( + "Adding module %s %s", module.get("module_name"), module.get("_id"), + ) + for condition in data_class.get_monitored_conditions( + module_id=module["_id"] + ): + entities.append( + NetatmoSensor( + data_handler, data_class_name, module, condition.lower() + ) + ) return entities - async_add_entities(await hass.async_add_executor_job(get_entities), True) + for data_class_name in [ + WEATHERSTATION_DATA_CLASS_NAME, + HOMECOACH_DATA_CLASS_NAME, + ]: + async_add_entities(await find_entities(data_class_name), True) @callback - def add_public_entities(): + async def add_public_entities(update=True): """Retrieve Netatmo public weather entities.""" - entities = [] - for area in entry.options.get(CONF_WEATHER_AREAS, {}).values(): - data = NetatmoPublicData( - auth, - lat_ne=area[CONF_LAT_NE], - lon_ne=area[CONF_LON_NE], - lat_sw=area[CONF_LAT_SW], - lon_sw=area[CONF_LON_SW], + entities = { + device.name: device.id + for device in async_entries_for_config_entry( + device_registry, entry.entry_id + ) + if device.model == "Public Weather stations" + } + + new_entities = [] + for area in [ + NetatmoArea(**i) for i in entry.options.get(CONF_WEATHER_AREAS, {}).values() + ]: + signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + + if area.area_name in entities: + entities.pop(area.area_name) + + if update: + async_dispatcher_send( + hass, f"netatmo-config-{area.area_name}", area, + ) + continue + + await data_handler.register_data_class( + PUBLICDATA_DATA_CLASS_NAME, + signal_name, + None, + LAT_NE=area.lat_ne, + LON_NE=area.lon_ne, + LAT_SW=area.lat_sw, + LON_SW=area.lon_sw, ) for sensor_type in SUPPORTED_PUBLIC_SENSOR_TYPES: - entities.append(NetatmoPublicSensor(area, data, sensor_type,)) + new_entities.append( + NetatmoPublicSensor(data_handler, area, sensor_type) + ) - for device in async_entries_for_config_entry(device_registry, entry.entry_id): - if device.model == "Public Weather stations": - device_registry.async_remove_device(device.id) + for device_id in entities.values(): + device_registry.async_remove_device(device_id) - if entities: - async_add_entities(entities) + if new_entities: + async_add_entities(new_entities) async_dispatcher_connect( hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}", add_public_entities @@ -189,7 +206,7 @@ async def async_setup_entry( entry.add_update_listener(async_config_entry_updated) - add_public_entities() + await add_public_entities(False) async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: @@ -202,39 +219,42 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= return -class NetatmoSensor(Entity): +class NetatmoSensor(NetatmoBase): """Implementation of a Netatmo sensor.""" - def __init__(self, netatmo_data, module_info, sensor_type): + def __init__(self, data_handler, data_class_name, module_info, sensor_type): """Initialize the sensor.""" - self.netatmo_data = netatmo_data + super().__init__(data_handler) + + self._data_classes.append( + {"name": data_class_name, SIGNAL_NAME: data_class_name} + ) + + self._id = module_info["_id"] + self._station_id = module_info.get("main_device", self._id) + + station = self._data.get_station(self._station_id) + device = self._data.get_module(self._id) - device = self.netatmo_data.station_data.moduleById(mid=module_info["id"]) if not device: # Assume it's a station if module can't be found - device = self.netatmo_data.station_data.stationById(sid=module_info["id"]) + device = station - if device["type"] == "NHC": - self.module_name = module_info["station_name"] + if device["type"] in ("NHC", "NAMain"): + self._device_name = module_info["station_name"] else: - self.module_name = ( - f"{module_info['station_name']} {module_info['module_name']}" - ) + self._device_name = f"{station['station_name']} {module_info.get('module_name', device['type'])}" - self._name = f"{MANUFACTURER} {self.module_name} {SENSOR_TYPES[sensor_type][0]}" + self._name = ( + f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[sensor_type][0]}" + ) self.type = sensor_type self._state = None self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self._module_type = device["type"] - self._module_id = module_info["id"] - self._unique_id = f"{self._module_id}-{self.type}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._model = device["type"] + self._unique_id = f"{self._id}-{self.type}" @property def icon(self): @@ -246,16 +266,6 @@ class NetatmoSensor(Entity): """Return the device class of the sensor.""" return self._device_class - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._module_id)}, - "name": self.module_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._module_type], - } - @property def state(self): """Return the state of the device.""" @@ -266,34 +276,33 @@ class NetatmoSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def available(self): - """Return True if entity is available.""" + """Return entity availability.""" return self._state is not None - def update(self): - """Get the latest data from Netatmo API and updates the states.""" - self.netatmo_data.update() - if self.netatmo_data.data is None: + @callback + def async_update_callback(self): + """Update the entity's state.""" + if self._data is None: if self._state is None: return _LOGGER.warning("No data from update") self._state = None return - data = self.netatmo_data.data.get(self._module_id) + data = self._data.get_last_data(station_id=self._station_id, exclude=3600).get( + self._id + ) if data is None: if self._state: _LOGGER.debug( - "No data found for %s (%s)", self.module_name, self._module_id + "No data (%s) found for %s (%s)", + self._data, + self._device_name, + self._id, ) - _LOGGER.debug("data: %s", self.netatmo_data.data) self._state = None return @@ -318,50 +327,8 @@ class NetatmoSensor(Entity): self._state = data["battery_percent"] elif self.type == "battery_lvl": self._state = data["battery_vp"] - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_WIND: - if data["battery_vp"] >= 5590: - self._state = "Full" - elif data["battery_vp"] >= 5180: - self._state = "High" - elif data["battery_vp"] >= 4770: - self._state = "Medium" - elif data["battery_vp"] >= 4360: - self._state = "Low" - elif data["battery_vp"] < 4360: - self._state = "Very Low" - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_RAIN: - if data["battery_vp"] >= 5500: - self._state = "Full" - elif data["battery_vp"] >= 5000: - self._state = "High" - elif data["battery_vp"] >= 4500: - self._state = "Medium" - elif data["battery_vp"] >= 4000: - self._state = "Low" - elif data["battery_vp"] < 4000: - self._state = "Very Low" - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_INDOOR: - if data["battery_vp"] >= 5640: - self._state = "Full" - elif data["battery_vp"] >= 5280: - self._state = "High" - elif data["battery_vp"] >= 4920: - self._state = "Medium" - elif data["battery_vp"] >= 4560: - self._state = "Low" - elif data["battery_vp"] < 4560: - self._state = "Very Low" - elif self.type == "battery_vp" and self._module_type == MODULE_TYPE_OUTDOOR: - if data["battery_vp"] >= 5500: - self._state = "Full" - elif data["battery_vp"] >= 5000: - self._state = "High" - elif data["battery_vp"] >= 4500: - self._state = "Medium" - elif data["battery_vp"] >= 4000: - self._state = "Low" - elif data["battery_vp"] < 4000: - self._state = "Very Low" + elif self.type == "battery_vp": + self._state = process_battery(data["battery_vp"], self._model) elif self.type == "min_temp": self._state = data["min_temp"] elif self.type == "max_temp": @@ -369,47 +336,13 @@ class NetatmoSensor(Entity): elif self.type == "windangle_value": self._state = data["WindAngle"] elif self.type == "windangle": - if data["WindAngle"] >= 330: - self._state = "N (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 300: - self._state = "NW (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 240: - self._state = "W (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 210: - self._state = "SW (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 150: - self._state = "S (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 120: - self._state = "SE (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 60: - self._state = "E (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 30: - self._state = "NE (%d\xb0)" % data["WindAngle"] - elif data["WindAngle"] >= 0: - self._state = "N (%d\xb0)" % data["WindAngle"] + self._state = process_angle(data["WindAngle"]) elif self.type == "windstrength": self._state = data["WindStrength"] elif self.type == "gustangle_value": self._state = data["GustAngle"] elif self.type == "gustangle": - if data["GustAngle"] >= 330: - self._state = "N (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 300: - self._state = "NW (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 240: - self._state = "W (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 210: - self._state = "SW (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 150: - self._state = "S (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 120: - self._state = "SE (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 60: - self._state = "E (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 30: - self._state = "NE (%d\xb0)" % data["GustAngle"] - elif data["GustAngle"] >= 0: - self._state = "N (%d\xb0)" % data["GustAngle"] + self._state = process_angle(data["GustAngle"]) elif self.type == "guststrength": self._state = data["GustStrength"] elif self.type == "reachable": @@ -417,90 +350,127 @@ class NetatmoSensor(Entity): elif self.type == "rf_status_lvl": self._state = data["rf_status"] elif self.type == "rf_status": - if data["rf_status"] >= 90: - self._state = "Low" - elif data["rf_status"] >= 76: - self._state = "Medium" - elif data["rf_status"] >= 60: - self._state = "High" - elif data["rf_status"] <= 59: - self._state = "Full" + self._state = process_rf(data["rf_status"]) elif self.type == "wifi_status_lvl": self._state = data["wifi_status"] elif self.type == "wifi_status": - if data["wifi_status"] >= 86: - self._state = "Low" - elif data["wifi_status"] >= 71: - self._state = "Medium" - elif data["wifi_status"] >= 56: - self._state = "High" - elif data["wifi_status"] <= 55: - self._state = "Full" + self._state = process_wifi(data["wifi_status"]) elif self.type == "health_idx": - if data["health_idx"] == 0: - self._state = "Healthy" - elif data["health_idx"] == 1: - self._state = "Fine" - elif data["health_idx"] == 2: - self._state = "Fair" - elif data["health_idx"] == 3: - self._state = "Poor" - elif data["health_idx"] == 4: - self._state = "Unhealthy" + self._state = process_health(data["health_idx"]) except KeyError: if self._state: - _LOGGER.info("No %s data found for %s", self.type, self.module_name) + _LOGGER.debug("No %s data found for %s", self.type, self._device_name) self._state = None return -class NetatmoData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, station_data): - """Initialize the data object.""" - self.data = {} - self.station_data = station_data - self.auth = auth - - def get_module_infos(self): - """Return all modules available on the API as a dict.""" - return self.station_data.getModules() - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Call the Netatmo API to update the data.""" - self.station_data = self.station_data.__class__(self.auth) - - data = self.station_data.lastData(exclude=3600, byId=True) - if not data: - _LOGGER.debug("No data received when updating station data") - return - self.data = data +def process_angle(angle: int) -> str: + """Process angle and return string for display.""" + if angle >= 330: + return f"N ({angle}\xb0)" + if angle >= 300: + return f"NW ({angle}\xb0)" + if angle >= 240: + return f"W ({angle}\xb0)" + if angle >= 210: + return f"SW ({angle}\xb0)" + if angle >= 150: + return f"S ({angle}\xb0)" + if angle >= 120: + return f"SE ({angle}\xb0)" + if angle >= 60: + return f"E ({angle}\xb0)" + if angle >= 30: + return f"NE ({angle}\xb0)" + return f"N ({angle}\xb0)" -class NetatmoPublicSensor(Entity): +def process_battery(data: int, model: str) -> str: + """Process battery data and return string for display.""" + values = BATTERY_VALUES[model] + + if data >= values["Full"]: + return "Full" + if data >= values["High"]: + return "High" + if data >= values["Medium"]: + return "Medium" + if data >= values["Low"]: + return "Low" + return "Very Low" + + +def process_health(health): + """Process health index and return string for display.""" + if health == 0: + return "Healthy" + if health == 1: + return "Fine" + if health == 2: + return "Fair" + if health == 3: + return "Poor" + if health == 4: + return "Unhealthy" + + +def process_rf(strength): + """Process wifi signal strength and return string for display.""" + if strength >= 90: + return "Low" + if strength >= 76: + return "Medium" + if strength >= 60: + return "High" + return "Full" + + +def process_wifi(strength): + """Process wifi signal strength and return string for display.""" + if strength >= 86: + return "Low" + if strength >= 71: + return "Medium" + if strength >= 56: + return "High" + return "Full" + + +class NetatmoPublicSensor(NetatmoBase): """Represent a single sensor in a Netatmo.""" - def __init__(self, area, data, sensor_type): + def __init__(self, data_handler, area, sensor_type): """Initialize the sensor.""" - self.netatmo_data = data + super().__init__(data_handler) + + self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + + self._data_classes.append( + { + "name": PUBLICDATA_DATA_CLASS_NAME, + "LAT_NE": area.lat_ne, + "LON_NE": area.lon_ne, + "LAT_SW": area.lat_sw, + "LON_SW": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ) + self.type = sensor_type - self._mode = area[CONF_PUBLIC_MODE] - self._area_name = area[CONF_AREA_NAME] - self._name = f"{MANUFACTURER} {self._area_name} {SENSOR_TYPES[self.type][0]}" + self.area = area + self._mode = area.mode + self._area_name = area.area_name + self._id = self._area_name + self._device_name = f"{self._area_name}" + self._name = f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[self.type][0]}" self._state = None self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self._show_on_map = area[CONF_SHOW_ON_MAP] - self._unique_id = f"{self._name.replace(' ', '-')}" - self._module_type = PUBLIC - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self._show_on_map = area.show_on_map + self._unique_id = f"{self._device_name.replace(' ', '-')}-{self.type}" + self._model = PUBLIC @property def icon(self): @@ -512,28 +482,14 @@ class NetatmoPublicSensor(Entity): """Return the device class of the sensor.""" return self._device_class - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._area_name)}, - "name": self._area_name, - "manufacturer": MANUFACTURER, - "model": MODELS[self._module_type], - } - @property def device_state_attributes(self): """Return the attributes of the device.""" attrs = {} if self._show_on_map: - attrs[ATTR_LATITUDE] = ( - self.netatmo_data.lat_ne + self.netatmo_data.lat_sw - ) / 2 - attrs[ATTR_LONGITUDE] = ( - self.netatmo_data.lon_ne + self.netatmo_data.lon_sw - ) / 2 + attrs[ATTR_LATITUDE] = (self.area.lat_ne + self.area.lat_sw) / 2 + attrs[ATTR_LONGITUDE] = (self.area.lon_ne + self.area.lon_sw) / 2 return attrs @@ -547,46 +503,95 @@ class NetatmoPublicSensor(Entity): """Return the unit of measurement of this entity.""" return self._unit_of_measurement - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - @property def available(self): """Return True if entity is available.""" return self._state is not None - def update(self): - """Get the latest data from Netatmo API and updates the states.""" - self.netatmo_data.update() + @property + def _data(self): + return self.data_handler.data[self._signal_name] - if self.netatmo_data.data is None: - _LOGGER.info("No data found for %s", self._name) + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + self.data_handler.listeners.append( + async_dispatcher_connect( + self.hass, + f"netatmo-config-{self.device_info['name']}", + self.async_config_update_callback, + ) + ) + + @callback + async def async_config_update_callback(self, area): + """Update the entity's config.""" + if self.area == area: + return + + await self.data_handler.unregister_data_class( + self._signal_name, self.async_update_callback + ) + + self.area = area + self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" + self._data_classes = [ + { + "name": PUBLICDATA_DATA_CLASS_NAME, + "LAT_NE": area.lat_ne, + "LON_NE": area.lon_ne, + "LAT_SW": area.lat_sw, + "LON_SW": area.lon_sw, + "area_name": area.area_name, + SIGNAL_NAME: self._signal_name, + } + ] + self._mode = area.mode + self._show_on_map = area.show_on_map + await self.data_handler.register_data_class( + PUBLICDATA_DATA_CLASS_NAME, + self._signal_name, + self.async_update_callback, + LAT_NE=area.lat_ne, + LON_NE=area.lon_ne, + LAT_SW=area.lat_sw, + LON_SW=area.lon_sw, + ) + + @callback + def async_update_callback(self): + """Update the entity's state.""" + if self._data is None: + if self._state is None: + return + _LOGGER.warning("No data from update") self._state = None return data = None if self.type == "temperature": - data = self.netatmo_data.data.getLatestTemperatures() + data = self._data.get_latest_temperatures() elif self.type == "pressure": - data = self.netatmo_data.data.getLatestPressures() + data = self._data.get_latest_pressures() elif self.type == "humidity": - data = self.netatmo_data.data.getLatestHumidities() + data = self._data.get_latest_humidities() elif self.type == "rain": - data = self.netatmo_data.data.getLatestRain() + data = self._data.get_latest_rain() elif self.type == "sum_rain_1": - data = self.netatmo_data.data.get60minRain() + data = self._data.get_60_min_rain() elif self.type == "sum_rain_24": - data = self.netatmo_data.data.get24hRain() + data = self._data.get_24_h_rain() elif self.type == "windstrength": - data = self.netatmo_data.data.getLatestWindStrengths() + data = self._data.get_latest_wind_strengths() elif self.type == "guststrength": - data = self.netatmo_data.data.getLatestGustStrengths() + data = self._data.get_latest_gust_strengths() if not data: - _LOGGER.warning( + if self._state is None: + return + _LOGGER.debug( "No station provides %s data in the area %s", self.type, self._area_name ) self._state = None @@ -597,41 +602,3 @@ class NetatmoPublicSensor(Entity): self._state = round(sum(values) / len(values), 1) elif self._mode == "max": self._state = max(values) - - -class NetatmoPublicData: - """Get the latest data from Netatmo.""" - - def __init__(self, auth, lat_ne, lon_ne, lat_sw, lon_sw): - """Initialize the data object.""" - self.auth = auth - self.data = None - self.lat_ne = lat_ne - self.lon_ne = lon_ne - self.lat_sw = lat_sw - self.lon_sw = lon_sw - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Request an update from the Netatmo API.""" - try: - data = pyatmo.PublicData( - self.auth, - LAT_NE=self.lat_ne, - LON_NE=self.lon_ne, - LAT_SW=self.lat_sw, - LON_SW=self.lon_sw, - filtering=True, - ) - except pyatmo.NoDevice: - data = None - - if not data: - _LOGGER.debug("No data received when updating public station data") - return - - if data.CountStationInArea() == 0: - _LOGGER.warning("No Stations available in this area") - return - - self.data = data diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 46de69b5cb3..bd8a0cc8f20 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -3,8 +3,34 @@ set_schedule: description: Set the heating schedule. fields: schedule_name: - description: Schedule name. + description: Schedule name example: Standard - home_name: - description: Home name. - example: MyHome + entity_id: + description: Entity id of the climate device. + example: climate.netatmo_livingroom + +set_persons_home: + description: Set a list of persons as at home. Person's name must match a name known by the Welcome Camera. + fields: + persons: + description: List of names + example: Bob + entity_id: + description: Entity id of the camera. + example: camera.netatmo_entrance + +set_person_away: + description: Set a person away. If no person is set the home will be marked as empty. Person's name must match a name known by the Welcome Camera. + fields: + person: + description: Person's name (optional) + example: Bob + entity_id: + description: Entity id of the camera. + example: camera.netatmo_entrance + +register_webhook: + description: Register webhook + +unregister_webhook: + description: Unregister webhook diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 116a37adb55..f1b761dd187 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -6,7 +6,7 @@ } }, "abort": { - "already_setup": "[%key:common::config_flow::abort::single_instance_allowed%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]" }, diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 9e5d33f5dbb..7126551883a 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -18,6 +18,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +EVENT_TYPE_MAP = { + "outdoor": "", + "therm_mode": "", +} + async def handle_webhook(hass, webhook_id, request): """Handle webhook callback.""" @@ -31,18 +36,13 @@ async def handle_webhook(hass, webhook_id, request): event_type = data.get(ATTR_EVENT_TYPE) - if event_type == "outdoor": + if event_type in ["outdoor", "therm_mode"]: hass.bus.async_fire( event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data} ) - for event_data in data.get("event_list"): - async_evaluate_event(hass, event_data) - elif event_type == "therm_mode": - hass.bus.async_fire( - event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data} - ) - for event_data in data.get("data"): + for event_data in data.get(EVENT_TYPE_MAP[event_type], []): async_evaluate_event(hass, event_data) + else: async_evaluate_event(hass, data) @@ -65,19 +65,8 @@ def async_evaluate_event(hass, event_data): event_type=NETATMO_EVENT, event_data={"type": event_type, "data": person_event_data}, ) - elif event_type == "therm_mode": - _LOGGER.debug("therm_mode: %s", event_data) - hass.bus.async_fire( - event_type=NETATMO_EVENT, - event_data={"type": event_type, "data": event_data}, - ) - elif event_type == "set_point": - _LOGGER.debug("set_point: %s", event_data) - hass.bus.async_fire( - event_type=NETATMO_EVENT, - event_data={"type": event_type, "data": event_data}, - ) else: + _LOGGER.debug("%s: %s", event_type, event_data) hass.bus.async_fire( event_type=NETATMO_EVENT, event_data={"type": event_type, "data": event_data}, diff --git a/requirements_all.txt b/requirements_all.txt index 9acc4e494d1..543c81615a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1223,7 +1223,7 @@ pyarlo==0.2.3 pyatag==0.3.3.4 # homeassistant.components.netatmo -pyatmo==3.3.1 +pyatmo==4.0.0 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfe239fd4a6..1fac73fb770 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -583,7 +583,7 @@ pyarlo==0.2.3 pyatag==0.3.3.4 # homeassistant.components.netatmo -pyatmo==3.3.1 +pyatmo==4.0.0 # homeassistant.components.blackbird pyblackbird==0.5 diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index 24668ea47e6..c6091e4d5e1 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -31,7 +31,7 @@ async def test_abort_if_existing_entry(hass): "netatmo", context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" + assert result["reason"] == "single_instance_allowed" result = await hass.config_entries.flow.async_init( "netatmo", @@ -39,7 +39,7 @@ async def test_abort_if_existing_entry(hass): data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" + assert result["reason"] == "single_instance_allowed" async def test_full_flow(hass, aiohttp_client, aioclient_mock): @@ -108,11 +108,21 @@ async def test_option_flow(hass): """Test config flow options.""" valid_option = { "lat_ne": 32.91336, + "lon_ne": -117.187429, + "lat_sw": 32.83336, "lon_sw": -117.26743, "show_on_map": False, "area_name": "Home", - "lon_ne": -117.187429, - "lat_sw": 32.83336, + "mode": "avg", + } + + expected_result = { + "lat_ne": 32.9133601, + "lon_ne": -117.1874289, + "lat_sw": 32.8333601, + "lon_sw": -117.26742990000001, + "show_on_map": False, + "area_name": "Home", "mode": "avg", } @@ -145,4 +155,60 @@ async def test_option_flow(hass): ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == {CONF_WEATHER_AREAS: {"Home": valid_option}} + for k, v in expected_result.items(): + assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v + + +async def test_option_flow_wrong_coordinates(hass): + """Test config flow options with mixed up coordinates.""" + valid_option = { + "lat_ne": 32.1234567, + "lon_ne": -117.2345678, + "lat_sw": 32.2345678, + "lon_sw": -117.1234567, + "show_on_map": False, + "area_name": "Home", + "mode": "avg", + } + + expected_result = { + "lat_ne": 32.2345678, + "lon_ne": -117.1234567, + "lat_sw": 32.1234567, + "lon_sw": -117.2345678, + "show_on_map": False, + "area_name": "Home", + "mode": "avg", + } + + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id=DOMAIN, data=VALID_CONFIG, options={}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "public_weather_areas" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_NEW_AREA: "Home"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "public_weather" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input=valid_option + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "public_weather_areas" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + for k, v in expected_result.items(): + assert config_entry.options[CONF_WEATHER_AREAS]["Home"][k] == v From e208d8b93edd3fc29d99121b6286b6324cf7de84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2020 09:21:45 -1000 Subject: [PATCH 288/362] Move system log processing out of the event loop (#38445) --- .../components/system_log/__init__.py | 55 +++++++++--- tests/components/system_log/test_init.py | 88 +++++++++++++++---- 2 files changed, 112 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index bf49de5a731..6f658962fe0 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -1,6 +1,8 @@ """Support for system log.""" +import asyncio from collections import OrderedDict, deque import logging +import queue import re import traceback @@ -8,7 +10,8 @@ import voluptuous as vol from homeassistant import __path__ as HOMEASSISTANT_PATH from homeassistant.components.http import HomeAssistantView -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_MAX_ENTRIES = "max_entries" @@ -155,6 +158,19 @@ class DedupStore(OrderedDict): return [value.to_dict() for value in reversed(self.values())] +class LogErrorQueueHandler(logging.handlers.QueueHandler): + """Process the log in another thread.""" + + def emit(self, record): + """Emit a log record.""" + try: + self.enqueue(record) + except asyncio.CancelledError: # pylint: disable=try-except-raise + raise + except Exception: # pylint: disable=broad-except + self.handleError(record) + + class LogErrorHandler(logging.Handler): """Log handler for error messages.""" @@ -172,17 +188,14 @@ class LogErrorHandler(logging.Handler): default upper limit is set to 50 (older entries are discarded) but can be changed if needed. """ - if record.levelno >= logging.WARN: - stack = [] - if not record.exc_info: - stack = [(f[0], f[1]) for f in traceback.extract_stack()] + stack = [] + if not record.exc_info: + stack = [(f[0], f[1]) for f in traceback.extract_stack()] - entry = LogEntry( - record, stack, _figure_out_source(record, stack, self.hass) - ) - self.records.add_entry(entry) - if self.fire_event: - self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) + entry = LogEntry(record, stack, _figure_out_source(record, stack, self.hass)) + self.records.add_entry(entry) + if self.fire_event: + self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) async def async_setup(hass, config): @@ -191,8 +204,26 @@ async def async_setup(hass, config): if conf is None: conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + simple_queue = queue.SimpleQueue() + queue_handler = LogErrorQueueHandler(simple_queue) + queue_handler.setLevel(logging.WARN) + logging.root.addHandler(queue_handler) + handler = LogErrorHandler(hass, conf[CONF_MAX_ENTRIES], conf[CONF_FIRE_EVENT]) - logging.getLogger().addHandler(handler) + + listener = logging.handlers.QueueListener( + simple_queue, handler, respect_handler_level=True + ) + + listener.start() + + @callback + def _async_stop_queue_handler(_) -> None: + """Cleanup handler.""" + logging.root.removeHandler(queue_handler) + listener.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_stop_queue_handler) hass.http.register_view(AllErrorsView(handler)) diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 009701ca886..d19ca2261bb 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -1,5 +1,9 @@ """Test system log component.""" +import asyncio import logging +import queue + +import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components import system_log @@ -11,13 +15,34 @@ _LOGGER = logging.getLogger("test_logger") BASIC_CONFIG = {"system_log": {"max_entries": 2}} +@pytest.fixture +def simple_queue(): + """Fixture that get the queue.""" + simple_queue_fixed = queue.SimpleQueue() + with patch( + "homeassistant.components.system_log.queue.SimpleQueue", + return_value=simple_queue_fixed, + ): + yield simple_queue_fixed + + +async def _async_block_until_queue_empty(hass, sq): + # Unfortunately we are stuck with polling + await hass.async_block_till_done() + while not sq.empty(): + await asyncio.sleep(0.01) + await hass.async_block_till_done() + + async def get_error_log(hass, hass_client, expected_count): """Fetch all entries from system_log via the API.""" + client = await hass_client() resp = await client.get("/api/error/all") assert resp.status == 200 data = await resp.json() + assert len(data) == expected_count return data @@ -46,41 +71,49 @@ def get_frame(name): return (name, 5, None, None) -async def test_normal_logs(hass, hass_client): +async def test_normal_logs(hass, simple_queue, hass_client): """Test that debug and info are not logged.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + _LOGGER.debug("debug") _LOGGER.info("info") + await _async_block_until_queue_empty(hass, simple_queue) # Assert done by get_error_log await get_error_log(hass, hass_client, 0) -async def test_exception(hass, hass_client): +async def test_exception(hass, simple_queue, hass_client): """Test that exceptions are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _generate_and_log_exception("exception message", "log message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "exception message", "log message", "ERROR") -async def test_warning(hass, hass_client): +async def test_warning(hass, simple_queue, hass_client): """Test that warning are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.warning("warning message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "", "warning message", "WARNING") -async def test_error(hass, hass_client): +async def test_error(hass, simple_queue, hass_client): """Test that errors are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "", "error message", "ERROR") -async def test_config_not_fire_event(hass): +async def test_config_not_fire_event(hass, simple_queue): """Test that errors are not posted as events with default config.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) events = [] @@ -93,12 +126,12 @@ async def test_config_not_fire_event(hass): hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) _LOGGER.error("error message") - await hass.async_block_till_done() + await _async_block_until_queue_empty(hass, simple_queue) assert len(events) == 0 -async def test_error_posted_as_event(hass): +async def test_error_posted_as_event(hass, simple_queue): """Test that error are posted as events.""" await async_setup_component( hass, system_log.DOMAIN, {"system_log": {"max_entries": 2, "fire_event": True}} @@ -113,26 +146,30 @@ async def test_error_posted_as_event(hass): hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener) _LOGGER.error("error message") - await hass.async_block_till_done() + await _async_block_until_queue_empty(hass, simple_queue) assert len(events) == 1 assert_log(events[0].data, "", "error message", "ERROR") -async def test_critical(hass, hass_client): +async def test_critical(hass, simple_queue, hass_client): """Test that critical are logged and retrieved correctly.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.critical("critical message") + await _async_block_until_queue_empty(hass, simple_queue) + log = (await get_error_log(hass, hass_client, 1))[0] assert_log(log, "", "critical message", "CRITICAL") -async def test_remove_older_logs(hass, hass_client): +async def test_remove_older_logs(hass, simple_queue, hass_client): """Test that older logs are rotated out.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message 1") _LOGGER.error("error message 2") _LOGGER.error("error message 3") + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 2) assert_log(log[0], "", "error message 3", "ERROR") assert_log(log[1], "", "error message 2", "ERROR") @@ -143,19 +180,23 @@ def log_msg(nr=2): _LOGGER.error("error message %s", nr) -async def test_dedup_logs(hass, hass_client): +async def test_dedup_logs(hass, simple_queue, hass_client): """Test that duplicate log entries are dedup.""" await async_setup_component(hass, system_log.DOMAIN, {}) _LOGGER.error("error message 1") log_msg() log_msg("2-2") _LOGGER.error("error message 3") + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 3) assert_log(log[0], "", "error message 3", "ERROR") assert log[1]["count"] == 2 assert_log(log[1], "", ["error message 2", "error message 2-2"], "ERROR") log_msg() + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 3) assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR") assert log[0]["timestamp"] > log[0]["first_occurred"] @@ -164,6 +205,8 @@ async def test_dedup_logs(hass, hass_client): log_msg("2-4") log_msg("2-5") log_msg("2-6") + await _async_block_until_queue_empty(hass, simple_queue) + log = await get_error_log(hass, hass_client, 3) assert_log( log[0], @@ -179,15 +222,16 @@ async def test_dedup_logs(hass, hass_client): ) -async def test_clear_logs(hass, hass_client): +async def test_clear_logs(hass, simple_queue, hass_client): """Test that the log can be cleared via a service call.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, simple_queue) hass.async_add_job( hass.services.async_call(system_log.DOMAIN, system_log.SERVICE_CLEAR, {}) ) - await hass.async_block_till_done() + await _async_block_until_queue_empty(hass, simple_queue) # Assert done by get_error_log await get_error_log(hass, hass_client, 0) @@ -239,16 +283,17 @@ async def test_write_choose_level(hass): assert logger.method_calls[0] == ("debug", ("test_message",)) -async def test_unknown_path(hass, hass_client): +async def test_unknown_path(hass, simple_queue, hass_client): """Test error logged from unknown path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) _LOGGER.findCaller = MagicMock(return_value=("unknown_path", 0, None, None)) _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, simple_queue) log = (await get_error_log(hass, hass_client, 1))[0] assert log["source"] == ["unknown_path", 0] -def log_error_from_test_path(path): +async def async_log_error_from_test_path(hass, path, sq): """Log error while mocking the path.""" call_path = "internal_path.py" with patch.object( @@ -266,24 +311,29 @@ def log_error_from_test_path(path): ), ): _LOGGER.error("error message") + await _async_block_until_queue_empty(hass, sq) -async def test_homeassistant_path(hass, hass_client): +async def test_homeassistant_path(hass, simple_queue, hass_client): """Test error logged from Home Assistant path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch( "homeassistant.components.system_log.HOMEASSISTANT_PATH", new=["venv_path/homeassistant"], ): - log_error_from_test_path("venv_path/homeassistant/component/component.py") + await async_log_error_from_test_path( + hass, "venv_path/homeassistant/component/component.py", simple_queue + ) log = (await get_error_log(hass, hass_client, 1))[0] assert log["source"] == ["component/component.py", 5] -async def test_config_path(hass, hass_client): +async def test_config_path(hass, simple_queue, hass_client): """Test error logged from config path.""" await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) with patch.object(hass.config, "config_dir", new="config"): - log_error_from_test_path("config/custom_component/test.py") + await async_log_error_from_test_path( + hass, "config/custom_component/test.py", simple_queue + ) log = (await get_error_log(hass, hass_client, 1))[0] assert log["source"] == ["custom_component/test.py", 5] From 96c6e4c2f434f371d90f39e326ae9b7a8b54131d Mon Sep 17 00:00:00 2001 From: lawtancool <26829131+lawtancool@users.noreply.github.com> Date: Tue, 4 Aug 2020 12:35:28 -0700 Subject: [PATCH 289/362] Fix Control4 token refresh (#38302) --- homeassistant/components/control4/__init__.py | 4 +--- homeassistant/components/control4/light.py | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 43af82678f4..0f27c678e59 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -171,9 +171,7 @@ class Control4Entity(entity.Entity): ): """Initialize a Control4 entity.""" self.entry = entry - self.account = entry_data[CONF_ACCOUNT] - self.director = entry_data[CONF_DIRECTOR] - self.director_token_expiry = entry_data[CONF_DIRECTOR_TOKEN_EXPIRATION] + self.entry_data = entry_data self._name = name self._idx = idx self._coordinator = coordinator diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index f121219fd36..d5a681eac09 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import Control4Entity, get_items_of_category -from .const import CONTROL4_ENTITY_TYPE, DOMAIN +from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN from .director_utils import director_update_data _LOGGER = logging.getLogger(__name__) @@ -138,12 +138,13 @@ class Control4Light(Control4Entity, LightEntity): device_id, ) self._is_dimmer = is_dimmer - self._c4_light = None - async def async_added_to_hass(self): - """When entity is added to hass.""" - await super().async_added_to_hass() - self._c4_light = C4Light(self.director, self._idx) + def create_api_object(self): + """Create a pyControl4 device object. + + This exists so the director token used is always the latest one, without needing to re-init the entire entity. + """ + return C4Light(self.entry_data[CONF_DIRECTOR], self._idx) @property def is_on(self): @@ -167,6 +168,7 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_on(self, **kwargs) -> None: """Turn the entity on.""" + c4_light = self.create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 @@ -176,10 +178,10 @@ class Control4Light(Control4Entity, LightEntity): brightness = (kwargs[ATTR_BRIGHTNESS] / 255) * 100 else: brightness = 100 - await self._c4_light.rampToLevel(brightness, transition_length) + await c4_light.rampToLevel(brightness, transition_length) else: transition_length = 0 - await self._c4_light.setLevel(100) + await c4_light.setLevel(100) if transition_length == 0: transition_length = 1000 delay_time = (transition_length / 1000) + 0.7 @@ -189,15 +191,16 @@ class Control4Light(Control4Entity, LightEntity): async def async_turn_off(self, **kwargs) -> None: """Turn the entity off.""" + c4_light = self.create_api_object() if self._is_dimmer: if ATTR_TRANSITION in kwargs: transition_length = kwargs[ATTR_TRANSITION] * 1000 else: transition_length = 0 - await self._c4_light.rampToLevel(0, transition_length) + await c4_light.rampToLevel(0, transition_length) else: transition_length = 0 - await self._c4_light.setLevel(0) + await c4_light.setLevel(0) if transition_length == 0: transition_length = 1500 delay_time = (transition_length / 1000) + 0.7 From ab512a12731a1ccdf1480f3bfd9a0cf82ccb30cd Mon Sep 17 00:00:00 2001 From: Peter Nijssen Date: Tue, 4 Aug 2020 22:37:20 +0200 Subject: [PATCH 290/362] Add spider config flow (#36001) --- homeassistant/components/spider/__init__.py | 91 +++++++++++----- homeassistant/components/spider/climate.py | 17 ++- .../components/spider/config_flow.py | 79 ++++++++++++++ homeassistant/components/spider/const.py | 6 ++ homeassistant/components/spider/manifest.json | 9 +- homeassistant/components/spider/strings.json | 20 ++++ homeassistant/components/spider/switch.py | 16 ++- .../components/spider/translations/en.json | 20 ++++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/spider/__init__.py | 1 + tests/components/spider/test_config_flow.py | 100 ++++++++++++++++++ 12 files changed, 314 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/spider/config_flow.py create mode 100644 homeassistant/components/spider/const.py create mode 100644 homeassistant/components/spider/strings.json create mode 100644 homeassistant/components/spider/translations/en.json create mode 100644 tests/components/spider/__init__.py create mode 100644 tests/components/spider/test_config_flow.py diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py index 125799b394a..f2e9a06fb94 100644 --- a/homeassistant/components/spider/__init__.py +++ b/homeassistant/components/spider/__init__.py @@ -1,29 +1,27 @@ """Support for Spider Smart devices.""" -from datetime import timedelta +import asyncio import logging from spiderpy.spiderapi import SpiderApi, UnauthorizedException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) -DOMAIN = "spider" - -SPIDER_COMPONENTS = ["climate", "switch"] - -SCAN_INTERVAL = timedelta(seconds=120) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, } ) }, @@ -31,27 +29,66 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass, config): - """Set up Spider Component.""" +def _spider_startup_wrapper(entry): + """Startup wrapper for spider.""" + api = SpiderApi( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_SCAN_INTERVAL], + ) + return api - username = config[DOMAIN][CONF_USERNAME] - password = config[DOMAIN][CONF_PASSWORD] - refresh_rate = config[DOMAIN][CONF_SCAN_INTERVAL] - try: - api = SpiderApi(username, password, refresh_rate.total_seconds()) - - hass.data[DOMAIN] = { - "controller": api, - "thermostats": api.get_thermostats(), - "power_plugs": api.get_power_plugs(), - } - - for component in SPIDER_COMPONENTS: - load_platform(hass, component, DOMAIN, {}, config) - - _LOGGER.debug("Connection with Spider API succeeded") +async def async_setup(hass, config): + """Set up a config entry.""" + hass.data[DOMAIN] = {} + if DOMAIN not in config: return True + + conf = config[DOMAIN] + + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Spider via config entry.""" + try: + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + _spider_startup_wrapper, entry + ) except UnauthorizedException: _LOGGER.error("Can't connect to the Spider API") return False + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload Spider entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if not unload_ok: + return False + + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 78c77f3679a..015606286e2 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -12,7 +12,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import DOMAIN as SPIDER_DOMAIN +from .const import DOMAIN SUPPORT_FAN = ["Auto", "Low", "Medium", "High", "Boost 10", "Boost 20", "Boost 30"] @@ -29,16 +29,13 @@ SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()} _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider thermostat.""" + api = hass.data[DOMAIN][config.entry_id] - devices = [ - SpiderThermostat(hass.data[SPIDER_DOMAIN]["controller"], device) - for device in hass.data[SPIDER_DOMAIN]["thermostats"] - ] - add_entities(devices, True) + entities = [SpiderThermostat(api, entity) for entity in api.get_thermostats()] + + async_add_entities(entities) class SpiderThermostat(ClimateEntity): diff --git a/homeassistant/components/spider/config_flow.py b/homeassistant/components/spider/config_flow.py new file mode 100644 index 00000000000..e1026f344b0 --- /dev/null +++ b/homeassistant/components/spider/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for Spider.""" +import logging + +from spiderpy.spiderapi import SpiderApi, SpiderApiException, UnauthorizedException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA_USER = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + +RESULT_AUTH_FAILED = "auth_failed" +RESULT_CONN_ERROR = "conn_error" +RESULT_SUCCESS = "success" + + +class SpiderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Spider config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the Spider flow.""" + self.data = { + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + } + + def _try_connect(self): + """Try to connect and check auth.""" + try: + SpiderApi( + self.data[CONF_USERNAME], + self.data[CONF_PASSWORD], + self.data[CONF_SCAN_INTERVAL], + ) + except SpiderApiException: + return RESULT_CONN_ERROR + except UnauthorizedException: + return RESULT_AUTH_FAILED + + return RESULT_SUCCESS + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors = {} + if user_input is not None: + self.data[CONF_USERNAME] = user_input["username"] + self.data[CONF_PASSWORD] = user_input["password"] + + result = await self.hass.async_add_executor_job(self._try_connect) + + if result == RESULT_SUCCESS: + return self.async_create_entry(title=DOMAIN, data=self.data,) + if result != RESULT_AUTH_FAILED: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return self.async_abort(reason=result) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors, + ) + + async def async_step_import(self, import_data): + """Import spider config from configuration.yaml.""" + return await self.async_step_user(import_data) diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py new file mode 100644 index 00000000000..420767fd221 --- /dev/null +++ b/homeassistant/components/spider/const.py @@ -0,0 +1,6 @@ +"""Constants for the Spider integration.""" + +DOMAIN = "spider" +DEFAULT_SCAN_INTERVAL = 300 + +PLATFORMS = ["climate", "switch"] diff --git a/homeassistant/components/spider/manifest.json b/homeassistant/components/spider/manifest.json index 8fa108f24f7..b285cafcfa9 100644 --- a/homeassistant/components/spider/manifest.json +++ b/homeassistant/components/spider/manifest.json @@ -2,6 +2,11 @@ "domain": "spider", "name": "Itho Daalderop Spider", "documentation": "https://www.home-assistant.io/integrations/spider", - "requirements": ["spiderpy==1.3.1"], - "codeowners": ["@peternijssen"] + "requirements": [ + "spiderpy==1.3.1" + ], + "codeowners": [ + "@peternijssen" + ], + "config_flow": true } diff --git a/homeassistant/components/spider/strings.json b/homeassistant/components/spider/strings.json new file mode 100644 index 00000000000..2e86f47dd2d --- /dev/null +++ b/homeassistant/components/spider/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "title": "Sign-in with mijn.ithodaalderop.nl account", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index 58a45cf7b4d..cea20d8c6be 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -3,22 +3,18 @@ import logging from homeassistant.components.switch import SwitchEntity -from . import DOMAIN as SPIDER_DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Spider thermostat.""" - if discovery_info is None: - return +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider thermostat.""" + api = hass.data[DOMAIN][config.entry_id] - devices = [ - SpiderPowerPlug(hass.data[SPIDER_DOMAIN]["controller"], device) - for device in hass.data[SPIDER_DOMAIN]["power_plugs"] - ] + entities = [SpiderPowerPlug(api, entity) for entity in api.get_power_plugs()] - add_entities(devices, True) + async_add_entities(entities) class SpiderPowerPlug(SwitchEntity): diff --git a/homeassistant/components/spider/translations/en.json b/homeassistant/components/spider/translations/en.json new file mode 100644 index 00000000000..0eca909fd09 --- /dev/null +++ b/homeassistant/components/spider/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Sign-in with your mijn.ithodaalderop.nl account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9fab383d718..d1f31841a30 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -158,6 +158,7 @@ FLOWS = [ "songpal", "sonos", "speedtestdotnet", + "spider", "spotify", "squeezebox", "starline", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fac73fb770..182ae7b0506 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -904,6 +904,9 @@ speak2mary==1.4.0 # homeassistant.components.speedtestdotnet speedtest-cli==2.1.2 +# homeassistant.components.spider +spiderpy==1.3.1 + # homeassistant.components.spotify spotipy==2.12.0 diff --git a/tests/components/spider/__init__.py b/tests/components/spider/__init__.py new file mode 100644 index 00000000000..d145f4efc09 --- /dev/null +++ b/tests/components/spider/__init__.py @@ -0,0 +1 @@ +"""Tests for the Spider component.""" diff --git a/tests/components/spider/test_config_flow.py b/tests/components/spider/test_config_flow.py new file mode 100644 index 00000000000..5c2c074027f --- /dev/null +++ b/tests/components/spider/test_config_flow.py @@ -0,0 +1,100 @@ +"""Tests for the Spider config flow.""" +import pytest + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.spider.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry + +USERNAME = "spider-username" +PASSWORD = "spider-password" + +SPIDER_USER_DATA = { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, +} + + +@pytest.fixture(name="spider") +def spider_fixture() -> Mock: + """Patch libraries.""" + with patch("homeassistant.components.spider.config_flow.SpiderApi") as spider: + yield spider + + +async def test_user(hass, spider): + """Test user config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.spider.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.spider.async_setup_entry", return_value=True + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass, spider): + """Test import step.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.spider.async_setup", return_value=True, + ) as mock_setup, patch( + "homeassistant.components.spider.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=SPIDER_USER_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + assert not result["result"].unique_id + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_abort_if_already_setup(hass, spider): + """Test we abort if Spider is already setup.""" + MockConfigEntry(domain=DOMAIN, data=SPIDER_USER_DATA).add_to_hass(hass) + + # Should fail, config exist (import) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + # Should fail, config exist (flow) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=SPIDER_USER_DATA + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" From 1e32d0e2b962431c98b85bbd90d0709e5c7d927f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Aug 2020 23:42:53 +0200 Subject: [PATCH 291/362] Upgrade toonapi to v0.2.0 (#38543) --- homeassistant/components/toon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 2ced62ffc6c..87398fab302 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -3,7 +3,7 @@ "name": "Toon", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/toon", - "requirements": ["toonapi==0.1.0"], + "requirements": ["toonapi==0.2.0"], "dependencies": ["http"], "after_dependencies": ["cloud"], "codeowners": ["@frenck"] diff --git a/requirements_all.txt b/requirements_all.txt index 543c81615a5..aecac46f944 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2122,7 +2122,7 @@ tmb==0.0.4 todoist-python==8.0.0 # homeassistant.components.toon -toonapi==0.1.0 +toonapi==0.2.0 # homeassistant.components.totalconnect total_connect_client==0.55 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 182ae7b0506..ac5d3ceefdf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -939,7 +939,7 @@ tesla-powerwall==0.2.12 teslajsonpy==0.10.1 # homeassistant.components.toon -toonapi==0.1.0 +toonapi==0.2.0 # homeassistant.components.totalconnect total_connect_client==0.55 From e53d770b3d6e20e87602f2d5fdcdd74179550884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 5 Aug 2020 00:31:12 +0200 Subject: [PATCH 292/362] Update pymetno lib, and start using met api v2 (#38547) --- homeassistant/components/met/manifest.json | 2 +- homeassistant/components/met/weather.py | 12 +++++------- homeassistant/components/norway_air/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index f4f32a5097f..a68a8223ba5 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -3,6 +3,6 @@ "name": "Meteorologisk institutt (Met.no)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met", - "requirements": ["pyMetno==0.5.1"], + "requirements": ["pyMetno==0.7.0"], "codeowners": ["@danielhiversen"] } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 7f71fbe07eb..a1bcc360623 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -36,7 +36,7 @@ ATTRIBUTION = ( ) DEFAULT_NAME = "Met.no" -URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/" +URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/classic" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -105,13 +105,11 @@ class MetWeather(WeatherEntity): elevation = conf[CONF_ELEVATION] if not self._is_metric: - elevation = int( - round(convert_distance(elevation, LENGTH_FEET, LENGTH_METERS)) - ) + elevation = convert_distance(elevation, LENGTH_FEET, LENGTH_METERS) coordinates = { - "lat": str(latitude), - "lon": str(longitude), - "msl": str(elevation), + "lat": latitude, + "lon": longitude, + "msl": elevation, } if coordinates == self._coordinates: return diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index d815482c3f0..2898ee6ff64 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -2,6 +2,6 @@ "domain": "norway_air", "name": "Om Luftkvalitet i Norge (Norway Air)", "documentation": "https://www.home-assistant.io/integrations/norway_air", - "requirements": ["pyMetno==0.5.1"], + "requirements": ["pyMetno==0.7.0"], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index aecac46f944..ce3b5751074 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1178,7 +1178,7 @@ pyHS100==0.3.5.1 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.5.1 +pyMetno==0.7.0 # homeassistant.components.rfxtrx pyRFXtrx==0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac5d3ceefdf..feac0608b5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -556,7 +556,7 @@ pyHS100==0.3.5.1 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.5.1 +pyMetno==0.7.0 # homeassistant.components.rfxtrx pyRFXtrx==0.25 From 2d7b9326ee34b9992b5c4ffc77dbdbdcc8f2e089 Mon Sep 17 00:00:00 2001 From: tizzen33 <53906250+tizzen33@users.noreply.github.com> Date: Wed, 5 Aug 2020 01:02:19 +0200 Subject: [PATCH 293/362] Add new Water Meter Sensor for Toon (#37879) Co-authored-by: Franck Nijhof --- homeassistant/components/toon/const.py | 56 +++++++++++++++++++++++++ homeassistant/components/toon/models.py | 14 +++++++ homeassistant/components/toon/sensor.py | 19 +++++++++ 3 files changed, 89 insertions(+) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 5015d50fa63..c814134f767 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -31,6 +31,8 @@ DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = "EUR" VOLUME_CM3 = "CM3" VOLUME_M3 = "M3" +VOLUME_LHOUR = "L/H" +VOLUME_LMIN = "L/MIN" ATTR_DEFAULT_ENABLED = "default_enabled" ATTR_INVERTED = "inverted" @@ -338,6 +340,60 @@ SENSOR_ENTITIES = { ATTR_ICON: "mdi:solar-power", ATTR_DEFAULT_ENABLED: True, }, + "water_average": { + ATTR_NAME: "Average Water Usage", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "average", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_average_daily": { + ATTR_NAME: "Average Daily Water Usage", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "day_average", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_daily_usage": { + ATTR_NAME: "Water Usage Today", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "day_usage", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_meter_reading": { + ATTR_NAME: "Water Meter", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "meter", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_M3, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water", + ATTR_DEFAULT_ENABLED: False, + }, + "water_value": { + ATTR_NAME: "Current Water Usage", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "current", + ATTR_UNIT_OF_MEASUREMENT: VOLUME_LMIN, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water-pump", + ATTR_DEFAULT_ENABLED: False, + }, + "water_daily_cost": { + ATTR_NAME: "Water Cost Today", + ATTR_SECTION: "water_usage", + ATTR_MEASUREMENT: "day_cost", + ATTR_UNIT_OF_MEASUREMENT: CURRENCY_EUR, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:water-pump", + ATTR_DEFAULT_ENABLED: False, + }, } SWITCH_ENTITIES = { diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 7634246d1c9..441b718c40a 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -110,6 +110,20 @@ class ToonGasMeterDeviceEntity(ToonEntity): } +class ToonWaterMeterDeviceEntity(ToonEntity): + """Defines a Water Meter device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this entity.""" + agreement_id = self.coordinator.data.agreement.agreement_id + return { + "name": "Water Meter", + "identifiers": {(DOMAIN, agreement_id, "water")}, + "via_device": (DOMAIN, agreement_id, "electricity"), + } + + class ToonSolarDeviceEntity(ToonEntity): """Defines a Solar Device device entity.""" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 2a0604c6c74..e686ff28211 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -24,6 +24,7 @@ from .models import ( ToonEntity, ToonGasMeterDeviceEntity, ToonSolarDeviceEntity, + ToonWaterMeterDeviceEntity, ) _LOGGER = logging.getLogger(__name__) @@ -68,6 +69,20 @@ async def async_setup_entry( ] ) + sensors.extend( + [ + ToonWaterMeterDeviceSensor(coordinator, key=key) + for key in ( + "water_average_daily", + "water_average", + "water_daily_cost", + "water_daily_usage", + "water_meter_reading", + "water_value", + ) + ] + ) + if coordinator.data.agreement.is_toon_solar: sensors.extend( [ @@ -146,6 +161,10 @@ class ToonGasMeterDeviceSensor(ToonSensor, ToonGasMeterDeviceEntity): """Defines a Gas Meter sensor.""" +class ToonWaterMeterDeviceSensor(ToonSensor, ToonWaterMeterDeviceEntity): + """Defines a Water Meter sensor.""" + + class ToonSolarDeviceSensor(ToonSensor, ToonSolarDeviceEntity): """Defines a Solar sensor.""" From 17c9e31e2c7739a1e0957499dd837e1148f15242 Mon Sep 17 00:00:00 2001 From: Janis Jansons Date: Wed, 5 Aug 2020 02:39:55 +0300 Subject: [PATCH 294/362] Fix Mikrotik encoding by setting utf8 (#38091) --- homeassistant/components/mikrotik/hub.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 90150f448e8..0b4ea0c5ea3 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -390,7 +390,7 @@ def get_api(hass, entry): _LOGGER.debug("Connecting to Mikrotik hub [%s]", entry[CONF_HOST]) _login_method = (login_plain, login_token) - kwargs = {"login_methods": _login_method, "port": entry["port"]} + kwargs = {"login_methods": _login_method, "port": entry["port"], "encoding": "utf8"} if entry[CONF_VERIFY_SSL]: ssl_context = ssl.create_default_context() From d89bfe79f9f081ff8eb55e5b8d90aa2b0a025edd Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Tue, 4 Aug 2020 19:00:05 -0500 Subject: [PATCH 295/362] Allow device class to control icons for tesla (#37526) --- homeassistant/components/tesla/__init__.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 82c98518f48..67ebe90669d 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -15,12 +15,10 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME, - DEVICE_CLASS_BATTERY, ) from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify from .config_flow import ( @@ -223,14 +221,8 @@ class TeslaDevice(Entity): @property def icon(self): """Return the icon of the sensor.""" - if ( - self.device_class == DEVICE_CLASS_BATTERY - and self.tesla_device.has_battery() - ): - return icon_for_battery_level( - battery_level=self.tesla_device.battery_level(), - charging=self.tesla_device.battery_charging(), - ) + if self.device_class: + return None return self._icon From e76db603672524aefa94e2e3b6aea88e50b1f0e3 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Wed, 5 Aug 2020 00:02:19 +0000 Subject: [PATCH 296/362] [ci skip] Translation update --- .../accuweather/translations/no.json | 8 ++++- .../accuweather/translations/pl.json | 2 +- .../accuweather/translations/sensor.cs.json | 9 ++++++ .../accuweather/translations/sensor.es.json | 9 ++++++ .../accuweather/translations/sensor.no.json | 9 ++++++ .../accuweather/translations/sensor.pl.json | 9 ++++++ .../components/arcam_fmj/translations/no.json | 1 + .../components/awair/translations/no.json | 5 +++ .../azure_devops/translations/no.json | 1 + .../components/bond/translations/cs.json | 16 ++++++++++ .../components/bond/translations/es.json | 10 ++++++ .../components/bond/translations/no.json | 27 ++++++++++++++++ .../components/control4/translations/no.json | 13 ++++++++ .../components/cover/translations/cs.json | 3 ++ .../components/cover/translations/es.json | 3 +- .../components/cover/translations/no.json | 3 +- .../components/daikin/translations/no.json | 11 +++++-- .../components/denonavr/translations/no.json | 1 + .../components/dexcom/translations/no.json | 12 ++++++- .../components/directv/translations/no.json | 2 +- .../components/doorbird/translations/no.json | 1 + .../components/dunehd/translations/no.json | 8 +++++ .../components/enocean/translations/no.json | 3 +- .../components/firmata/translations/no.json | 7 ++++ .../components/gogogate2/translations/no.json | 7 ++++ .../components/hlk_sw16/translations/cs.json | 18 +++++++++++ .../components/hlk_sw16/translations/en.json | 17 +++++----- .../components/hlk_sw16/translations/es.json | 21 ++++++++++++ .../components/hlk_sw16/translations/no.json | 21 ++++++++++++ .../components/hlk_sw16/translations/ru.json | 21 ++++++++++++ .../components/homekit/translations/no.json | 1 + .../components/hue/translations/no.json | 3 ++ .../humidifier/translations/no.json | 6 ++++ .../components/icloud/translations/no.json | 1 + .../components/iqvia/translations/cs.json | 7 ++++ .../components/iqvia/translations/en.json | 1 + .../components/iqvia/translations/es.json | 3 ++ .../components/iqvia/translations/no.json | 3 ++ .../components/iqvia/translations/ru.json | 3 ++ .../components/isy994/translations/no.json | 8 +++-- .../components/life360/translations/no.json | 3 +- .../components/melcloud/translations/no.json | 4 +-- .../meteo_france/translations/cs.json | 24 ++++++++++++++ .../meteo_france/translations/es.json | 19 +++++++++++ .../meteo_france/translations/no.json | 19 +++++++++++ .../components/metoffice/translations/no.json | 7 ++++ .../components/mill/translations/no.json | 18 +++++++++++ .../components/mqtt/translations/cs.json | 1 + .../components/mqtt/translations/en.json | 2 ++ .../components/mqtt/translations/es.json | 2 ++ .../components/mqtt/translations/no.json | 3 ++ .../components/mqtt/translations/ru.json | 2 ++ .../components/netatmo/translations/en.json | 3 +- .../components/netatmo/translations/no.json | 1 + .../components/plex/translations/no.json | 1 + .../plum_lightpad/translations/no.json | 18 +++++++++++ .../components/point/translations/no.json | 2 +- .../components/poolsense/translations/no.json | 13 ++++++++ .../components/sense/translations/no.json | 2 +- .../simplisafe/translations/no.json | 6 +++- .../components/smappee/translations/no.json | 3 +- .../components/smarthab/translations/no.json | 8 ++++- .../components/sms/translations/no.json | 8 +++++ .../components/sonarr/translations/no.json | 8 +++++ .../components/songpal/translations/no.json | 4 +++ .../components/spider/translations/en.json | 32 +++++++++---------- .../squeezebox/translations/no.json | 14 ++++++-- .../components/syncthru/translations/no.json | 9 ++++++ .../components/tile/translations/no.json | 4 +++ .../components/toon/translations/no.json | 2 ++ .../components/tuya/translations/no.json | 12 ++++++- .../components/unifi/translations/no.json | 1 + .../components/vizio/translations/no.json | 6 ++-- .../components/volumio/translations/cs.json | 15 +++++++++ .../components/volumio/translations/no.json | 11 +++++++ .../components/withings/translations/no.json | 2 +- .../components/wolflink/translations/no.json | 12 +++++++ .../xiaomi_aqara/translations/no.json | 1 + .../xiaomi_miio/translations/no.json | 1 + .../components/zerproc/translations/no.json | 11 +++++++ 80 files changed, 577 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/sensor.cs.json create mode 100644 homeassistant/components/accuweather/translations/sensor.es.json create mode 100644 homeassistant/components/accuweather/translations/sensor.no.json create mode 100644 homeassistant/components/accuweather/translations/sensor.pl.json create mode 100644 homeassistant/components/bond/translations/cs.json create mode 100644 homeassistant/components/bond/translations/no.json create mode 100644 homeassistant/components/firmata/translations/no.json create mode 100644 homeassistant/components/hlk_sw16/translations/cs.json create mode 100644 homeassistant/components/hlk_sw16/translations/es.json create mode 100644 homeassistant/components/hlk_sw16/translations/no.json create mode 100644 homeassistant/components/hlk_sw16/translations/ru.json create mode 100644 homeassistant/components/iqvia/translations/cs.json create mode 100644 homeassistant/components/meteo_france/translations/cs.json create mode 100644 homeassistant/components/mill/translations/no.json create mode 100644 homeassistant/components/plum_lightpad/translations/no.json create mode 100644 homeassistant/components/volumio/translations/cs.json diff --git a/homeassistant/components/accuweather/translations/no.json b/homeassistant/components/accuweather/translations/no.json index e0197581db6..c6cbc82bc2c 100644 --- a/homeassistant/components/accuweather/translations/no.json +++ b/homeassistant/components/accuweather/translations/no.json @@ -1,16 +1,22 @@ { "config": { + "abort": { + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_api_key": "Ugyldig API-n\u00f8kkel", "requests_exceeded": "Det tillatte antallet foresp\u00f8rsler til Accuweather API er overskredet. Du m\u00e5 vente eller endre API-n\u00f8kkel." }, "step": { "user": { "data": { + "api_key": "API-n\u00f8kkel", "latitude": "Breddegrad", "longitude": "Lengdegrad", "name": "Navn p\u00e5 integrasjon" }, - "description": "Hvis du trenger hjelp med konfigurasjonen, kan du se her: https://www.home-assistant.io/integrations/accuweather/ \n\n V\u00e6rmelding er ikke aktivert som standard. Du kan aktivere det i integrasjonsalternativene.", + "description": "Hvis du trenger hjelp med konfigurasjonen, kan du se her: https://www.home-assistant.io/integrations/accuweather/ \n\n Noen sensorer er ikke aktivert som standard. Du kan aktivere dem i enhetsregisteret etter integrasjonskonfigurasjonen. \n V\u00e6rmelding er ikke aktivert som standard. Du kan aktivere det i integrasjonsalternativene.", "title": "" } } diff --git a/homeassistant/components/accuweather/translations/pl.json b/homeassistant/components/accuweather/translations/pl.json index eb1ea8e5b04..273458db00c 100644 --- a/homeassistant/components/accuweather/translations/pl.json +++ b/homeassistant/components/accuweather/translations/pl.json @@ -16,7 +16,7 @@ "longitude": "D\u0142ugo\u015b\u0107 geograficzna", "name": "Nazwa integracji" }, - "description": "Je\u015bli potrzebujesz pomocy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/accuweather/ \n\n Prognoza pogody nie jest domy\u015blnie w\u0142\u0105czona. Mo\u017cesz j\u0105 w\u0142\u0105czy\u0107 w opcjach integracji.", + "description": "Je\u015bli potrzebujesz pomocy z konfiguracj\u0105, przejd\u017a na stron\u0119: https://www.home-assistant.io/integrations/accuweather/ \n\nCz\u0119\u015b\u0107 sensor\u00f3w nie jest w\u0142\u0105czona domy\u015blnie. Mo\u017cesz je w\u0142\u0105czy\u0107 w rejestrze encji po konfiguracji integracji.\nPrognoza pogody nie jest domy\u015blnie w\u0142\u0105czona. Mo\u017cesz j\u0105 w\u0142\u0105czy\u0107 w opcjach integracji.", "title": "AccuWeather" } } diff --git a/homeassistant/components/accuweather/translations/sensor.cs.json b/homeassistant/components/accuweather/translations/sensor.cs.json new file mode 100644 index 00000000000..e49b09927d5 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.cs.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Klesaj\u00edc\u00ed", + "rising": "Roustouc\u00ed", + "steady": "St\u00e1l\u00fd" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.es.json b/homeassistant/components/accuweather/translations/sensor.es.json new file mode 100644 index 00000000000..72d666b1ba3 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.es.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Cayendo", + "rising": "Subiendo", + "steady": "Estable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.no.json b/homeassistant/components/accuweather/translations/sensor.no.json new file mode 100644 index 00000000000..abe8a935115 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.no.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "Fallende", + "rising": "Stiger", + "steady": "Jevn" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.pl.json b/homeassistant/components/accuweather/translations/sensor.pl.json new file mode 100644 index 00000000000..cc7ba9b873c --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.pl.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "spada", + "rising": "ro\u015bnie", + "steady": "bez zmian" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/translations/no.json b/homeassistant/components/arcam_fmj/translations/no.json index 554097b95a5..14d55224119 100644 --- a/homeassistant/components/arcam_fmj/translations/no.json +++ b/homeassistant/components/arcam_fmj/translations/no.json @@ -12,6 +12,7 @@ }, "user": { "data": { + "host": "Vert", "port": "" }, "description": "Vennligst skriv inn vertsnavnet eller IP-adressen til enheten." diff --git a/homeassistant/components/awair/translations/no.json b/homeassistant/components/awair/translations/no.json index dd69f2b255f..46c3d5c3711 100644 --- a/homeassistant/components/awair/translations/no.json +++ b/homeassistant/components/awair/translations/no.json @@ -1,20 +1,25 @@ { "config": { "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "no_devices": "Ingen enheter funnet p\u00e5 nettverket", "reauth_successful": "Tilgangstoken oppdatert" }, "error": { + "auth": "Ugyldig tilgangstoken", "unknown": "Ukjent Awair API-feil." }, "step": { "reauth": { "data": { + "access_token": "Tilgangstoken", "email": "Epost" }, "description": "Skriv inn tilgangstokenet for Awair-utviklere p\u00e5 nytt." }, "user": { "data": { + "access_token": "Tilgangstoken", "email": "Epost " }, "description": "Du m\u00e5 registrere deg for et Awair-utviklertilgangstoken p\u00e5: https://developer.getawair.com/onboard/login" diff --git a/homeassistant/components/azure_devops/translations/no.json b/homeassistant/components/azure_devops/translations/no.json index 2723f8e08c7..00d9ae4d925 100644 --- a/homeassistant/components/azure_devops/translations/no.json +++ b/homeassistant/components/azure_devops/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Kontoen er allerede konfigurert", "reauth_successful": "Tilgangstoken oppdatert" }, "error": { diff --git a/homeassistant/components/bond/translations/cs.json b/homeassistant/components/bond/translations/cs.json new file mode 100644 index 00000000000..bf42fe8d5fc --- /dev/null +++ b/homeassistant/components/bond/translations/cs.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nakofigurovan\u00e9" + }, + "flow_title": "Bond: {bond_id} ({host})", + "step": { + "confirm": { + "data": { + "access_token": "P\u0159\u00edstupov\u00fd token" + }, + "description": "Chcete nastavit {bond_id} ?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bond/translations/es.json b/homeassistant/components/bond/translations/es.json index 9620672ccf0..063915421e3 100644 --- a/homeassistant/components/bond/translations/es.json +++ b/homeassistant/components/bond/translations/es.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { "cannot_connect": "No se pudo conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, + "flow_title": "Bond: {bond_id} ({host})", "step": { + "confirm": { + "data": { + "access_token": "Token de acceso" + }, + "description": "\u00bfQuieres configurar {bond_id}?" + }, "user": { "data": { "access_token": "Token de acceso", diff --git a/homeassistant/components/bond/translations/no.json b/homeassistant/components/bond/translations/no.json new file mode 100644 index 00000000000..1a1c8792dc0 --- /dev/null +++ b/homeassistant/components/bond/translations/no.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "flow_title": "Obligasjon: {bond_id} ( {host} )", + "step": { + "confirm": { + "data": { + "access_token": "Tilgangstoken" + }, + "description": "Vil du konfigurere {bond_id}?" + }, + "user": { + "data": { + "access_token": "Tilgangstoken", + "host": "Vert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/no.json b/homeassistant/components/control4/translations/no.json index 3ea1bb403bd..bc6bcc64462 100644 --- a/homeassistant/components/control4/translations/no.json +++ b/homeassistant/components/control4/translations/no.json @@ -1,7 +1,20 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, "step": { "user": { + "data": { + "host": "IP adresse", + "password": "Passord", + "username": "Brukernavn" + }, "description": "Vennligst skriv inn Control4-kontodetaljene og IP-adressen til din lokale kontroller." } } diff --git a/homeassistant/components/cover/translations/cs.json b/homeassistant/components/cover/translations/cs.json index c32db1e8b97..b093fb9f214 100644 --- a/homeassistant/components/cover/translations/cs.json +++ b/homeassistant/components/cover/translations/cs.json @@ -1,5 +1,8 @@ { "device_automation": { + "action_type": { + "stop": "Zastavit {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} je zav\u0159eno", "is_closing": "{entity_name} se zav\u00edr\u00e1", diff --git a/homeassistant/components/cover/translations/es.json b/homeassistant/components/cover/translations/es.json index 857813eefb5..181cda45fb5 100644 --- a/homeassistant/components/cover/translations/es.json +++ b/homeassistant/components/cover/translations/es.json @@ -6,7 +6,8 @@ "open": "Abrir {entity_name}", "open_tilt": "Abrir inclinaci\u00f3n de {entity_name}", "set_position": "Ajustar la posici\u00f3n de {entity_name}", - "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}" + "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}", + "stop": "Detener {entity_name}" }, "condition_type": { "is_closed": "{entity_name} est\u00e1 cerrado", diff --git a/homeassistant/components/cover/translations/no.json b/homeassistant/components/cover/translations/no.json index eaa0f2d1678..a9f1cbafb18 100644 --- a/homeassistant/components/cover/translations/no.json +++ b/homeassistant/components/cover/translations/no.json @@ -6,7 +6,8 @@ "open": "\u00c5pne {entity_name}", "open_tilt": "\u00c5pne {entity_name} tilt", "set_position": "Angi {entity_name} posisjon", - "set_tilt_position": "Angi {entity_name} tilt posisjon" + "set_tilt_position": "Angi {entity_name} tilt posisjon", + "stop": "Stopp {entity_name}" }, "condition_type": { "is_closed": "{entity_name} er lukket", diff --git a/homeassistant/components/daikin/translations/no.json b/homeassistant/components/daikin/translations/no.json index 98d93a29952..cb2f8cde40b 100644 --- a/homeassistant/components/daikin/translations/no.json +++ b/homeassistant/components/daikin/translations/no.json @@ -3,12 +3,19 @@ "abort": { "already_configured": "Enheten er allerede konfigurert" }, + "error": { + "device_fail": "Uventet feil", + "device_timeout": "Tilkobling mislyktes.", + "forbidden": "Ugyldig godkjenning" + }, "step": { "user": { "data": { - "host": "Vert" + "host": "Vert", + "key": "API-n\u00f8kkel", + "password": "Passord" }, - "description": "Fyll inn IP-adressen til din Daikin AC.", + "description": "Angi IP-adressen til Daikin AC. \n\n Merk at API-n\u00f8kkel og Passord brukes av henholdsvis BRP072Cxx og SKYFi-enheter.", "title": "Konfigurer Daikin AC" } } diff --git a/homeassistant/components/denonavr/translations/no.json b/homeassistant/components/denonavr/translations/no.json index 93f58eedf6a..e156101c378 100644 --- a/homeassistant/components/denonavr/translations/no.json +++ b/homeassistant/components/denonavr/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyt for denne Denon AVR p\u00e5g\u00e5r allerede", "connection_error": "Kunne ikke koble til, vennligst pr\u00f8v igjen. Koble fra str\u00f8m- og nettverkskablene og koble dem til igjen kan hjelpe", "not_denonavr_manufacturer": "Ikke en Denon AVR Network Receiver, oppdaget manafucturer stemte ikke overens", diff --git a/homeassistant/components/dexcom/translations/no.json b/homeassistant/components/dexcom/translations/no.json index 09ec0002470..61ad015b5a4 100644 --- a/homeassistant/components/dexcom/translations/no.json +++ b/homeassistant/components/dexcom/translations/no.json @@ -1,9 +1,19 @@ { "config": { + "abort": { + "already_configured_account": "Kontoen er allerede konfigurert" + }, + "error": { + "account_error": "Ugyldig godkjenning", + "session_error": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "user": { "data": { - "server": "" + "password": "Passord", + "server": "", + "username": "Brukernavn" }, "description": "Angi Dexcom Share-legitimasjon", "title": "Setup Dexcom integrasjon" diff --git a/homeassistant/components/directv/translations/no.json b/homeassistant/components/directv/translations/no.json index d4f9e1f72de..c6db33d32d0 100644 --- a/homeassistant/components/directv/translations/no.json +++ b/homeassistant/components/directv/translations/no.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "DirecTV-mottaker er allerede konfigurert", + "already_configured": "Enheten er allerede konfigurert", "unknown": "Uventet feil" }, "error": { diff --git a/homeassistant/components/doorbird/translations/no.json b/homeassistant/components/doorbird/translations/no.json index 8f3a580e43e..4929e58c61f 100644 --- a/homeassistant/components/doorbird/translations/no.json +++ b/homeassistant/components/doorbird/translations/no.json @@ -6,6 +6,7 @@ "not_doorbird_device": "Denne enheten er ikke en DoorBird" }, "error": { + "cannot_connect": "Tilkobling mislyktes.", "invalid_auth": "Ugyldig godkjenning", "unknown": "Uventet feil" }, diff --git a/homeassistant/components/dunehd/translations/no.json b/homeassistant/components/dunehd/translations/no.json index 061809a1c30..e395c28b7a9 100644 --- a/homeassistant/components/dunehd/translations/no.json +++ b/homeassistant/components/dunehd/translations/no.json @@ -1,10 +1,18 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, "error": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Tilkobling mislyktes.", "invalid_host": "Ugyldig vertsnavn eller IP-adresse." }, "step": { "user": { + "data": { + "host": "Vert" + }, "description": "Konfigurer Dune HD-integrering. Hvis du har problemer med konfigurasjonen, kan du g\u00e5 til: https://www.home-assistant.io/integrations/dunehd \n\nKontroller at spilleren er sl\u00e5tt p\u00e5.", "title": "" } diff --git a/homeassistant/components/enocean/translations/no.json b/homeassistant/components/enocean/translations/no.json index b51e1a75ba7..a3fc35edcc8 100644 --- a/homeassistant/components/enocean/translations/no.json +++ b/homeassistant/components/enocean/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "invalid_dongle_path": "Ugyldig donglesti" + "invalid_dongle_path": "Ugyldig donglesti", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "error": { "invalid_dongle_path": "Ingen gyldig dongle funnet for denne banen" diff --git a/homeassistant/components/firmata/translations/no.json b/homeassistant/components/firmata/translations/no.json new file mode 100644 index 00000000000..e1e5c8f1ea4 --- /dev/null +++ b/homeassistant/components/firmata/translations/no.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "Kan ikke koble til Firmata Board under installasjonen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/no.json b/homeassistant/components/gogogate2/translations/no.json index 436ca38bf7b..8adc85e0c26 100644 --- a/homeassistant/components/gogogate2/translations/no.json +++ b/homeassistant/components/gogogate2/translations/no.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "cannot_connect": "Tilkobling mislyktes." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/hlk_sw16/translations/cs.json b/homeassistant/components/hlk_sw16/translations/cs.json new file mode 100644 index 00000000000..480a7187a7a --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed ji\u017e je nakonfigurov\u00e1no" + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/en.json b/homeassistant/components/hlk_sw16/translations/en.json index 75ec99a5512..f15fe84c3ed 100644 --- a/homeassistant/components/hlk_sw16/translations/en.json +++ b/homeassistant/components/hlk_sw16/translations/en.json @@ -1,22 +1,21 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Device is already configured" }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" }, "step": { "user": { "data": { - "host": "[%key:common::config_flow::data::host%]", - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" + "host": "Host", + "password": "Password", + "username": "Username" } } } - }, - "title": "Hi-Link HLK-SW16" + } } \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/es.json b/homeassistant/components/hlk_sw16/translations/es.json new file mode 100644 index 00000000000..2609ee07eaf --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "username": "Usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/no.json b/homeassistant/components/hlk_sw16/translations/no.json new file mode 100644 index 00000000000..1cb08943e34 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/ru.json b/homeassistant/components/hlk_sw16/translations/ru.json new file mode 100644 index 00000000000..ee2788cea56 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.", + "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/no.json b/homeassistant/components/homekit/translations/no.json index 836defe3b35..38599b5476f 100644 --- a/homeassistant/components/homekit/translations/no.json +++ b/homeassistant/components/homekit/translations/no.json @@ -22,6 +22,7 @@ "step": { "advanced": { "data": { + "auto_start": "Autostart (deaktiver hvis du bruker Z-Wave eller annet forsinket startsystem)", "safe_mode": "Sikker modus (aktiver bare hvis sammenkoblingen mislykkes)", "zeroconf_default_interface": "Bruk standard zeroconf-grensesnitt (aktiver hvis broen ikke kan finnes i Hjem-appen)" }, diff --git a/homeassistant/components/hue/translations/no.json b/homeassistant/components/hue/translations/no.json index a632a0584da..c5e9cedd708 100644 --- a/homeassistant/components/hue/translations/no.json +++ b/homeassistant/components/hue/translations/no.json @@ -26,6 +26,9 @@ "title": "" }, "manual": { + "data": { + "host": "Vert" + }, "title": "Manuell konfigurere en Hue-bro" } } diff --git a/homeassistant/components/humidifier/translations/no.json b/homeassistant/components/humidifier/translations/no.json index 9d6e4b2ae61..620a38c6cbe 100644 --- a/homeassistant/components/humidifier/translations/no.json +++ b/homeassistant/components/humidifier/translations/no.json @@ -18,5 +18,11 @@ "turned_on": "{entity_name} sl\u00e5tt p\u00e5" } }, + "state": { + "_": { + "off": "Av", + "on": "P\u00e5" + } + }, "title": "Luftfukter" } \ No newline at end of file diff --git a/homeassistant/components/icloud/translations/no.json b/homeassistant/components/icloud/translations/no.json index 021ebd1d71b..9805c769a67 100644 --- a/homeassistant/components/icloud/translations/no.json +++ b/homeassistant/components/icloud/translations/no.json @@ -20,6 +20,7 @@ "user": { "data": { "password": "Passord", + "username": "E-post", "with_family": "Med familie" }, "description": "Fyll inn legitimasjonen din", diff --git a/homeassistant/components/iqvia/translations/cs.json b/homeassistant/components/iqvia/translations/cs.json new file mode 100644 index 00000000000..eb0fdc62f01 --- /dev/null +++ b/homeassistant/components/iqvia/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Toto PS\u010c ji\u017e bylo nakonfigurov\u00e1no." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iqvia/translations/en.json b/homeassistant/components/iqvia/translations/en.json index c9b3526102b..6c96b78c854 100644 --- a/homeassistant/components/iqvia/translations/en.json +++ b/homeassistant/components/iqvia/translations/en.json @@ -4,6 +4,7 @@ "already_configured": "This ZIP code has already been configured." }, "error": { + "identifier_exists": "ZIP code already registered", "invalid_zip_code": "ZIP code is invalid" }, "step": { diff --git a/homeassistant/components/iqvia/translations/es.json b/homeassistant/components/iqvia/translations/es.json index 0fbc0ecceb0..9288503ed60 100644 --- a/homeassistant/components/iqvia/translations/es.json +++ b/homeassistant/components/iqvia/translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Este c\u00f3digo postal ya ha sido configurado." + }, "error": { "identifier_exists": "C\u00f3digo postal ya registrado", "invalid_zip_code": "El c\u00f3digo postal no es v\u00e1lido" diff --git a/homeassistant/components/iqvia/translations/no.json b/homeassistant/components/iqvia/translations/no.json index 37fb766ee36..9f130f52403 100644 --- a/homeassistant/components/iqvia/translations/no.json +++ b/homeassistant/components/iqvia/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne postnummeren er allerede konfigurert." + }, "error": { "identifier_exists": "Postnummer er allerede registrert", "invalid_zip_code": "Postnummeret er ugyldig" diff --git a/homeassistant/components/iqvia/translations/ru.json b/homeassistant/components/iqvia/translations/ru.json index 69b1bd3745e..d7b868acad3 100644 --- a/homeassistant/components/iqvia/translations/ru.json +++ b/homeassistant/components/iqvia/translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u044d\u0442\u0438\u043c \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u043c \u0438\u043d\u0434\u0435\u043a\u0441\u043e\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, "error": { "identifier_exists": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d.", "invalid_zip_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441." diff --git a/homeassistant/components/isy994/translations/no.json b/homeassistant/components/isy994/translations/no.json index 7864a6916cd..da5b2fe3711 100644 --- a/homeassistant/components/isy994/translations/no.json +++ b/homeassistant/components/isy994/translations/no.json @@ -4,15 +4,19 @@ "already_configured": "Enheten er allerede konfigurert" }, "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", "invalid_host": "Vertsoppf\u00f8ringen var ikke i fullstendig URL-format, for eksempel http://192.168.10.100:80", - "unknown": "[%key:common::config_flow::error::unknown%" + "unknown": "Uventet feil" }, "flow_title": "Universelle enheter ISY994 {name} ({host})", "step": { "user": { "data": { "host": "URL", - "tls": "TLS-versjonen av ISY-kontrolleren." + "password": "Passord", + "tls": "TLS-versjonen av ISY-kontrolleren.", + "username": "Brukernavn" }, "description": "Vertsoppf\u00f8ringen m\u00e5 v\u00e6re i fullstendig URL-format, for eksempel http://192.168.10.100:80", "title": "Koble til ISY994" diff --git a/homeassistant/components/life360/translations/no.json b/homeassistant/components/life360/translations/no.json index 1abca20c9dc..dc1db8a9206 100644 --- a/homeassistant/components/life360/translations/no.json +++ b/homeassistant/components/life360/translations/no.json @@ -10,7 +10,8 @@ "error": { "invalid_credentials": "Ugyldig legitimasjon", "invalid_username": "Ugyldig brukernavn", - "unexpected": "Uventet feil under kommunikasjon med Life360-servern" + "unexpected": "Uventet feil under kommunikasjon med Life360-servern", + "user_already_configured": "Kontoen er allerede konfigurert" }, "step": { "user": { diff --git a/homeassistant/components/melcloud/translations/no.json b/homeassistant/components/melcloud/translations/no.json index fcdc00168eb..e96fdb171e7 100644 --- a/homeassistant/components/melcloud/translations/no.json +++ b/homeassistant/components/melcloud/translations/no.json @@ -11,8 +11,8 @@ "step": { "user": { "data": { - "password": "MELCloud passord.", - "username": "E-post som blir brukt til \u00e5 logge inn p\u00e5 MELCloud." + "password": "Passord", + "username": "E-post" }, "description": "Koble til ved hjelp av MELCloud-kontoen din.", "title": "Koble til MELCloud" diff --git a/homeassistant/components/meteo_france/translations/cs.json b/homeassistant/components/meteo_france/translations/cs.json new file mode 100644 index 00000000000..da3388bd558 --- /dev/null +++ b/homeassistant/components/meteo_france/translations/cs.json @@ -0,0 +1,24 @@ +{ + "config": { + "error": { + "empty": "\u017d\u00e1dn\u00fd v\u00fdsledek p\u0159i hled\u00e1n\u00ed m\u011bsta: zkontrolujte pros\u00edm pole m\u011bsta" + }, + "step": { + "cities": { + "data": { + "city": "M\u011bsto" + }, + "description": "Vyberte m\u011bsto ze seznamu" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Re\u017eim p\u0159edpov\u011bdi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/es.json b/homeassistant/components/meteo_france/translations/es.json index 4c04a5d32c3..31d221eba09 100644 --- a/homeassistant/components/meteo_france/translations/es.json +++ b/homeassistant/components/meteo_france/translations/es.json @@ -4,7 +4,17 @@ "already_configured": "La ciudad ya est\u00e1 configurada", "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde" }, + "error": { + "empty": "No hay resultado en la b\u00fasqueda de la ciudad: por favor, comprueba el campo de la ciudad" + }, "step": { + "cities": { + "data": { + "city": "Ciudad" + }, + "description": "Elige tu ciudad de la lista", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "Ciudad" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Modo de pron\u00f3stico" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/no.json b/homeassistant/components/meteo_france/translations/no.json index d4921d7e4e5..91eea1fcec7 100644 --- a/homeassistant/components/meteo_france/translations/no.json +++ b/homeassistant/components/meteo_france/translations/no.json @@ -4,7 +4,17 @@ "already_configured": "Byen er allerede konfigurert", "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere" }, + "error": { + "empty": "Ingen resultater i bys\u00f8k: vennligst sjekk byfeltet" + }, "step": { + "cities": { + "data": { + "city": "By" + }, + "description": "Velg din by fra listen", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "By" @@ -13,5 +23,14 @@ "title": "" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Prognosemodus" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/metoffice/translations/no.json b/homeassistant/components/metoffice/translations/no.json index 0711e25b73f..0dc9d305d97 100644 --- a/homeassistant/components/metoffice/translations/no.json +++ b/homeassistant/components/metoffice/translations/no.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/mill/translations/no.json b/homeassistant/components/mill/translations/no.json new file mode 100644 index 00000000000..0f698cbbddb --- /dev/null +++ b/homeassistant/components/mill/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "connection_error": "Tilkobling mislyktes." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/cs.json b/homeassistant/components/mqtt/translations/cs.json index 95bd6d68c8f..87ab2de60d3 100644 --- a/homeassistant/components/mqtt/translations/cs.json +++ b/homeassistant/components/mqtt/translations/cs.json @@ -41,6 +41,7 @@ }, "options": { "data": { + "birth_enable": "Povolit zpr\u00e1vu p\u0159i p\u0159ipojen\u00ed", "discovery": "Povolit zji\u0161\u0165ov\u00e1n\u00ed" }, "description": "Zvolte mo\u017enosti MQTT." diff --git a/homeassistant/components/mqtt/translations/en.json b/homeassistant/components/mqtt/translations/en.json index 99cd59be13b..8ece91cb85d 100644 --- a/homeassistant/components/mqtt/translations/en.json +++ b/homeassistant/components/mqtt/translations/en.json @@ -66,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "Enable birth message", "birth_payload": "Birth message payload", "birth_qos": "Birth message QoS", "birth_retain": "Birth message retain", "birth_topic": "Birth message topic", "discovery": "Enable discovery", + "will_enable": "Enable birth message", "will_payload": "Will message payload", "will_qos": "Will message QoS", "will_retain": "Will message retain", diff --git a/homeassistant/components/mqtt/translations/es.json b/homeassistant/components/mqtt/translations/es.json index 52dda70695a..4ebf1e11c77 100644 --- a/homeassistant/components/mqtt/translations/es.json +++ b/homeassistant/components/mqtt/translations/es.json @@ -66,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "Habilitar mensaje de nacimiento", "birth_payload": "Carga del mensaje de nacimiento", "birth_qos": "QoS del mensaje de nacimiento", "birth_retain": "Retenci\u00f3n del mensaje de nacimiento", "birth_topic": "Tema del mensaje de nacimiento", "discovery": "Habilitar descubrimiento", + "will_enable": "Habilitar mensaje de nacimiento", "will_payload": "Enviar\u00e1 la carga", "will_qos": "El mensaje usar\u00e1 el QoS", "will_retain": "Retendr\u00e1 el mensaje", diff --git a/homeassistant/components/mqtt/translations/no.json b/homeassistant/components/mqtt/translations/no.json index 84e4f23a7d0..b1863b90d1c 100644 --- a/homeassistant/components/mqtt/translations/no.json +++ b/homeassistant/components/mqtt/translations/no.json @@ -58,6 +58,7 @@ "broker": { "data": { "broker": "Megler", + "password": "Passord", "port": "", "username": "Brukernavn" }, @@ -65,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "Aktiver f\u00f8dselsmelding", "birth_payload": "F\u00f8dselsmelding nyttelast", "birth_qos": "F\u00f8dselsmelding QoS", "birth_retain": "F\u00f8dselsmelding behold", "birth_topic": "F\u00f8dselsmelding emne", "discovery": "Aktiver oppdagelse", + "will_enable": "Aktiver f\u00f8dselsmelding", "will_payload": "Testament melding nyttelast", "will_qos": "Testament melding QoS", "will_retain": "Testament melding behold", diff --git a/homeassistant/components/mqtt/translations/ru.json b/homeassistant/components/mqtt/translations/ru.json index 8139781f51e..4ff21126cfa 100644 --- a/homeassistant/components/mqtt/translations/ru.json +++ b/homeassistant/components/mqtt/translations/ru.json @@ -66,11 +66,13 @@ }, "options": { "data": { + "birth_enable": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_payload": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u043e\u043f\u0438\u043a\u0430 \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_qos": "QoS \u0442\u043e\u043f\u0438\u043a\u0430 \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_retain": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "birth_topic": "\u0422\u043e\u043f\u0438\u043a \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 (LWT)", "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435", + "will_enable": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "will_payload": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0442\u043e\u043f\u0438\u043a\u0430 \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "will_qos": "QoS \u0442\u043e\u043f\u0438\u043a\u0430 \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", "will_retain": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0442\u043e\u043f\u0438\u043a \u043e\u0431 \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438", diff --git a/homeassistant/components/netatmo/translations/en.json b/homeassistant/components/netatmo/translations/en.json index 8176f4f057e..a96dfcbec6c 100644 --- a/homeassistant/components/netatmo/translations/en.json +++ b/homeassistant/components/netatmo/translations/en.json @@ -3,7 +3,8 @@ "abort": { "already_setup": "Already configured. Only a single configuration possible.", "authorize_url_timeout": "Timeout generating authorize URL.", - "missing_configuration": "The component is not configured. Please follow the documentation." + "missing_configuration": "The component is not configured. Please follow the documentation.", + "single_instance_allowed": "Already configured. Only a single configuration possible." }, "create_entry": { "default": "Successfully authenticated" diff --git a/homeassistant/components/netatmo/translations/no.json b/homeassistant/components/netatmo/translations/no.json index d3a4e111a95..a61c6576209 100644 --- a/homeassistant/components/netatmo/translations/no.json +++ b/homeassistant/components/netatmo/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_setup": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig.", "authorize_url_timeout": "Tidsavbrutt ved oppretting av godkjennings url.", "missing_configuration": "Komponeneten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen." }, diff --git a/homeassistant/components/plex/translations/no.json b/homeassistant/components/plex/translations/no.json index 45b2d451f2f..ab6c8232985 100644 --- a/homeassistant/components/plex/translations/no.json +++ b/homeassistant/components/plex/translations/no.json @@ -20,6 +20,7 @@ "step": { "manual_setup": { "data": { + "host": "Vert", "port": "", "ssl": "Bruk SSL", "token": "Token (valgfritt)", diff --git a/homeassistant/components/plum_lightpad/translations/no.json b/homeassistant/components/plum_lightpad/translations/no.json new file mode 100644 index 00000000000..f4f16565659 --- /dev/null +++ b/homeassistant/components/plum_lightpad/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-post" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/translations/no.json b/homeassistant/components/point/translations/no.json index 2b907e42c3a..f9fb69d7f67 100644 --- a/homeassistant/components/point/translations/no.json +++ b/homeassistant/components/point/translations/no.json @@ -12,7 +12,7 @@ }, "error": { "follow_link": "Vennligst f\u00f8lg lenken og godkjenn f\u00f8r du trykker p\u00e5 Send", - "no_token": "Ikke godkjent med Minut" + "no_token": "Ugyldig tilgangstoken" }, "step": { "auth": { diff --git a/homeassistant/components/poolsense/translations/no.json b/homeassistant/components/poolsense/translations/no.json index e99a1d62746..edf3a8a9463 100644 --- a/homeassistant/components/poolsense/translations/no.json +++ b/homeassistant/components/poolsense/translations/no.json @@ -1,7 +1,20 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, "step": { "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, + "description": "[%key:common::config_flow::description%]", "title": "" } } diff --git a/homeassistant/components/sense/translations/no.json b/homeassistant/components/sense/translations/no.json index fdddc6de82d..c3457ccb280 100644 --- a/homeassistant/components/sense/translations/no.json +++ b/homeassistant/components/sense/translations/no.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "email": "E-postadresse", + "email": "E-post", "password": "Passord" }, "title": "Koble til din Sense Energi Monitor" diff --git a/homeassistant/components/simplisafe/translations/no.json b/homeassistant/components/simplisafe/translations/no.json index 6dd8ff68b5d..664bb912528 100644 --- a/homeassistant/components/simplisafe/translations/no.json +++ b/homeassistant/components/simplisafe/translations/no.json @@ -7,7 +7,8 @@ "error": { "identifier_exists": "Konto er allerede registrert", "invalid_credentials": "Ugyldig legitimasjon", - "still_awaiting_mfa": "Forventer fortsatt MFA-e-postklikk" + "still_awaiting_mfa": "Forventer fortsatt MFA-e-postklikk", + "unknown": "Uventet feil" }, "step": { "mfa": { @@ -15,6 +16,9 @@ "title": "SimpliSafe Multi-Factor Autentisering" }, "reauth_confirm": { + "data": { + "password": "Passord" + }, "description": "Adgangstokenet ditt har utl\u00f8pt eller blitt opphevet. Skriv inn passordet ditt for \u00e5 koble kontoen din p\u00e5 nytt.", "title": "Koble SimpliSafe-kontoen p\u00e5 nytt" }, diff --git a/homeassistant/components/smappee/translations/no.json b/homeassistant/components/smappee/translations/no.json index 6b2141fd61e..a6ef71b7448 100644 --- a/homeassistant/components/smappee/translations/no.json +++ b/homeassistant/components/smappee/translations/no.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", - "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen." + "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." }, "step": { "pick_implementation": { diff --git a/homeassistant/components/smarthab/translations/no.json b/homeassistant/components/smarthab/translations/no.json index 15e6962cbe0..de1e50f5c4b 100644 --- a/homeassistant/components/smarthab/translations/no.json +++ b/homeassistant/components/smarthab/translations/no.json @@ -1,10 +1,16 @@ { "config": { "error": { - "service": "Feil under fors\u00f8k p\u00e5 \u00e5 n\u00e5 SmartHab. Tjenesten kan v\u00e6re nede. Sjekk tilkoblingen din." + "service": "Feil under fors\u00f8k p\u00e5 \u00e5 n\u00e5 SmartHab. Tjenesten kan v\u00e6re nede. Sjekk tilkoblingen din.", + "unknown_error": "Uventet feil", + "wrong_login": "Ugyldig godkjenning" }, "step": { "user": { + "data": { + "email": "E-post", + "password": "Passord" + }, "description": "Av tekniske \u00e5rsaker m\u00e5 du s\u00f8rge for \u00e5 bruke en sekund\u00e6r konto som er spesifikk for oppsettet i Home Assistant. Du kan opprette en fra SmartHab-programmet.", "title": "Oppsett av SmartHab" } diff --git a/homeassistant/components/sms/translations/no.json b/homeassistant/components/sms/translations/no.json index 98af331c1dd..7398a7b7b0a 100644 --- a/homeassistant/components/sms/translations/no.json +++ b/homeassistant/components/sms/translations/no.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/sonarr/translations/no.json b/homeassistant/components/sonarr/translations/no.json index 0b98c67d820..694565b72d6 100644 --- a/homeassistant/components/sonarr/translations/no.json +++ b/homeassistant/components/sonarr/translations/no.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "Tjenesten er allerede konfigurert", + "unknown": "Uventet feil" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning" + }, "flow_title": "", "step": { "user": { diff --git a/homeassistant/components/songpal/translations/no.json b/homeassistant/components/songpal/translations/no.json index 4c3ef9e6c0d..eb07eeb2daa 100644 --- a/homeassistant/components/songpal/translations/no.json +++ b/homeassistant/components/songpal/translations/no.json @@ -1,8 +1,12 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "not_songpal_device": "Ikke en Songpal-enhet" }, + "error": { + "cannot_connect": "Tilkobling mislyktes." + }, "flow_title": "", "step": { "init": { diff --git a/homeassistant/components/spider/translations/en.json b/homeassistant/components/spider/translations/en.json index 0eca909fd09..b33c05419a3 100644 --- a/homeassistant/components/spider/translations/en.json +++ b/homeassistant/components/spider/translations/en.json @@ -1,20 +1,20 @@ { - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Username" + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." }, - "title": "Sign-in with your mijn.ithodaalderop.nl account" - } + "error": { + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "title": "Sign-in with mijn.ithodaalderop.nl account" + } + } } - } } \ No newline at end of file diff --git a/homeassistant/components/squeezebox/translations/no.json b/homeassistant/components/squeezebox/translations/no.json index 17abd4c683b..ddda0b61be2 100644 --- a/homeassistant/components/squeezebox/translations/no.json +++ b/homeassistant/components/squeezebox/translations/no.json @@ -1,20 +1,30 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "no_server_found": "Ingen LMS-server funnet." }, "error": { - "no_server_found": "Kan ikke automatisk oppdage serveren." + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "no_server_found": "Kan ikke automatisk oppdage serveren.", + "unknown": "Uventet feil" }, "flow_title": "", "step": { "edit": { "data": { - "port": "" + "host": "Vert", + "password": "Passord", + "port": "", + "username": "Brukernavn" }, "title": "Redigere tilkoblingsinformasjon" }, "user": { + "data": { + "host": "Vert" + }, "title": "Konfigurer Logitech Media Server" } } diff --git a/homeassistant/components/syncthru/translations/no.json b/homeassistant/components/syncthru/translations/no.json index a17786167c6..db24ef5abc7 100644 --- a/homeassistant/components/syncthru/translations/no.json +++ b/homeassistant/components/syncthru/translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, "error": { "invalid_url": "Ugyldig URL-adresse", "syncthru_not_supported": "Enheten st\u00f8tter ikke SyncThru", @@ -7,6 +10,12 @@ }, "flow_title": "Samsung SyncThru-skriver: {name}", "step": { + "confirm": { + "data": { + "name": "Navn", + "url": "URL-adresse for webgrensesnitt" + } + }, "user": { "data": { "name": "Navn", diff --git a/homeassistant/components/tile/translations/no.json b/homeassistant/components/tile/translations/no.json index 1185ebc2bdd..ccd8c0fc176 100644 --- a/homeassistant/components/tile/translations/no.json +++ b/homeassistant/components/tile/translations/no.json @@ -8,6 +8,10 @@ }, "step": { "user": { + "data": { + "password": "Passord", + "username": "E-post" + }, "title": "Konfigurer Tile" } } diff --git a/homeassistant/components/toon/translations/no.json b/homeassistant/components/toon/translations/no.json index 1aa78d734c1..b67f7386a22 100644 --- a/homeassistant/components/toon/translations/no.json +++ b/homeassistant/components/toon/translations/no.json @@ -3,8 +3,10 @@ "abort": { "already_configured": "Den valgte avtalen er allerede konfigurert.", "authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.", + "authorize_url_timeout": "Tidsavbrudd som genererer autorer URL-adresse.", "client_id": "Klient ID fra konfigurasjonen er ugyldig.", "client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.", + "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.", "no_agreements": "Denne kontoen har ingen Toon skjermer.", "no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "Det oppstod en uventet feil under godkjenning." diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json index 11be0aecaeb..5681f95d984 100644 --- a/homeassistant/components/tuya/translations/no.json +++ b/homeassistant/components/tuya/translations/no.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "auth_failed": "Ugyldig godkjenning", + "conn_error": "Tilkobling mislyktes.", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "error": { + "auth_failed": "Ugyldig godkjenning" + }, "flow_title": "Tuya konfigurasjon", "step": { "user": { "data": { "country_code": "Din landskode for kontoen din (f.eks. 1 for USA eller 86 for Kina)", - "platform": "Appen der kontoen din registreres" + "password": "Passord", + "platform": "Appen der kontoen din registreres", + "username": "Brukernavn" }, "description": "Skriv inn din Tuya-legitimasjon.", "title": "" diff --git a/homeassistant/components/unifi/translations/no.json b/homeassistant/components/unifi/translations/no.json index a0d207974ec..a861790ba8d 100644 --- a/homeassistant/components/unifi/translations/no.json +++ b/homeassistant/components/unifi/translations/no.json @@ -52,6 +52,7 @@ }, "simple_options": { "data": { + "block_client": "Nettverkskontrollerte klienter", "track_clients": "Spor nettverksklienter", "track_devices": "Spor nettverksenheter (Ubiquiti enheter)" }, diff --git a/homeassistant/components/vizio/translations/no.json b/homeassistant/components/vizio/translations/no.json index d7e43f56bb1..0f341ce63b5 100644 --- a/homeassistant/components/vizio/translations/no.json +++ b/homeassistant/components/vizio/translations/no.json @@ -1,12 +1,14 @@ { "config": { "abort": { + "already_configured_device": "Enheten er allerede konfigurert", "updated_entry": "Dette innlegget har allerede v\u00e6rt oppsett, men navnet, apps, og/eller alternativer som er definert i konfigurasjon som ikke stemmer med det som tidligere er importert konfigurasjon, s\u00e5 konfigurasjonen innlegget har blitt oppdatert i henhold til dette." }, "error": { + "cannot_connect": "Tilkobling mislyktes.", "complete_pairing_failed": "Kan ikke fullf\u00f8re sammenkoblingen. Forsikre deg om at PIN-koden du oppga er riktig, og at TV-en fortsatt er p\u00e5 og tilkoblet nettverket f\u00f8r du sender inn p\u00e5 nytt.", - "host_exists": "VIZIO-enhet med spesifisert vert allerede konfigurert.", - "name_exists": "VIZIO-enhet med spesifisert navn allerede konfigurert." + "host_exists": "VIZIO SmartCast-enhet with specified host already configured.", + "name_exists": "VIZIO SmartCast-enhet with specified name already configured." }, "step": { "pair_tv": { diff --git a/homeassistant/components/volumio/translations/cs.json b/homeassistant/components/volumio/translations/cs.json new file mode 100644 index 00000000000..c3862ff2f79 --- /dev/null +++ b/homeassistant/components/volumio/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "discovery_confirm": { + "description": "Chcete p\u0159idat Volumio (`{name}`) do Home Assistant?", + "title": "Objeven\u00e9 instance Volumio" + }, + "user": { + "data": { + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/volumio/translations/no.json b/homeassistant/components/volumio/translations/no.json index 48c763af2b1..60b5fc38e68 100644 --- a/homeassistant/components/volumio/translations/no.json +++ b/homeassistant/components/volumio/translations/no.json @@ -1,12 +1,23 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "cannot_connect": "Kan ikke koble til oppdaget Volumio" }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "unknown": "Uventet feil" + }, "step": { "discovery_confirm": { "description": "Vil du legge Volumio (` {name} `) til Home Assistant?", "title": "Oppdaget Volumio" + }, + "user": { + "data": { + "host": "Vert", + "port": "Port" + } } } } diff --git a/homeassistant/components/withings/translations/no.json b/homeassistant/components/withings/translations/no.json index 14fb9573f8b..2b39f8fceab 100644 --- a/homeassistant/components/withings/translations/no.json +++ b/homeassistant/components/withings/translations/no.json @@ -18,7 +18,7 @@ }, "profile": { "data": { - "profile": "Profil" + "profile": "Profil navn" }, "description": "Oppgi et unikt profilnavn for disse dataene. Dette er vanligvis navnet p\u00e5 profilen du valgte i forrige trinn.", "title": "Brukerprofil." diff --git a/homeassistant/components/wolflink/translations/no.json b/homeassistant/components/wolflink/translations/no.json index d8fc1db3db3..a158cba44a2 100644 --- a/homeassistant/components/wolflink/translations/no.json +++ b/homeassistant/components/wolflink/translations/no.json @@ -1,5 +1,13 @@ { "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes.", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, "step": { "device": { "data": { @@ -8,6 +16,10 @@ "title": "Velg WOLF-enhet" }, "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + }, "title": "WOLF SmartSet-tilkobling" } } diff --git a/homeassistant/components/xiaomi_aqara/translations/no.json b/homeassistant/components/xiaomi_aqara/translations/no.json index fd89ad26f93..c94d79b16d5 100644 --- a/homeassistant/components/xiaomi_aqara/translations/no.json +++ b/homeassistant/components/xiaomi_aqara/translations/no.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyt for denne porten p\u00e5g\u00e5r allerede", "not_xiaomi_aqara": "Ikke en Xiaomi Aqara Gateway, oppdaget enhet ikke samsvarer med kjente gatewayer" }, diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json index 24e070323b3..cf978d4a015 100644 --- a/homeassistant/components/xiaomi_miio/translations/no.json +++ b/homeassistant/components/xiaomi_miio/translations/no.json @@ -5,6 +5,7 @@ "already_in_progress": "Konfigurasjonsflyt for denne Xiaomi Miio-enheten p\u00e5g\u00e5r allerede." }, "error": { + "connect_error": "Tilkobling mislyktes.", "no_device_selected": "Ingen enhet valgt, vennligst velg en enhet." }, "flow_title": "Xiaomi Miio: {navn}", diff --git a/homeassistant/components/zerproc/translations/no.json b/homeassistant/components/zerproc/translations/no.json index cdfd3890fb8..5324c4b87b1 100644 --- a/homeassistant/components/zerproc/translations/no.json +++ b/homeassistant/components/zerproc/translations/no.json @@ -1,3 +1,14 @@ { + "config": { + "abort": { + "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket", + "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." + }, + "step": { + "confirm": { + "description": "Vil du starte oppsettet?" + } + } + }, "title": "Zerproc" } \ No newline at end of file From c33f309d5f562a000b725d5761889045105f6ce8 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 5 Aug 2020 02:24:42 +0200 Subject: [PATCH 297/362] Fix upnp error on unload_entry if device does not exist (#38230) --- homeassistant/components/upnp/__init__.py | 20 +++++++++++++------- homeassistant/components/upnp/config_flow.py | 3 ++- homeassistant/components/upnp/const.py | 7 ++++++- homeassistant/components/upnp/device.py | 5 +++-- homeassistant/components/upnp/sensor.py | 8 +++++--- 5 files changed, 29 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 65049db8c4f..98bf3e6f4dd 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -20,6 +20,10 @@ from .const import ( DISCOVERY_UDN, DISCOVERY_USN, DOMAIN, + DOMAIN_CONFIG, + DOMAIN_COORDINATORS, + DOMAIN_DEVICES, + DOMAIN_LOCAL_IP, LOGGER as _LOGGER, ) from .device import Device @@ -78,10 +82,10 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): conf = config.get(DOMAIN, conf_default) local_ip = await hass.async_add_executor_job(get_local_ip) hass.data[DOMAIN] = { - "config": conf, - "devices": {}, - "coordinators": {}, - "local_ip": conf.get(CONF_LOCAL_IP, local_ip), + DOMAIN_CONFIG: conf, + DOMAIN_COORDINATORS: {}, + DOMAIN_DEVICES: {}, + DOMAIN_LOCAL_IP: conf.get(CONF_LOCAL_IP, local_ip), } # Only start if set up via configuration.yaml. @@ -108,7 +112,7 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) raise ConfigEntryNotReady # Save device - hass.data[DOMAIN]["devices"][device.udn] = device + hass.data[DOMAIN][DOMAIN_DEVICES][device.udn] = device # Ensure entry has proper unique_id. if config_entry.unique_id != device.unique_id: @@ -141,8 +145,10 @@ async def async_unload_entry( ) -> bool: """Unload a UPnP/IGD device from a config entry.""" udn = config_entry.data.get(CONFIG_ENTRY_UDN) - del hass.data[DOMAIN]["devices"][udn] - del hass.data[DOMAIN]["coordinators"][udn] + if udn in hass.data[DOMAIN][DOMAIN_DEVICES]: + del hass.data[DOMAIN][DOMAIN_DEVICES][udn] + if udn in hass.data[DOMAIN][DOMAIN_COORDINATORS]: + del hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] _LOGGER.debug("Deleting sensors") return await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 6d85ba94270..0c57a2c243e 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -20,6 +20,7 @@ from .const import ( # pylint: disable=unused-import DISCOVERY_UDN, DISCOVERY_USN, DOMAIN, + DOMAIN_COORDINATORS, LOGGER as _LOGGER, ) from .device import Device @@ -221,7 +222,7 @@ class UpnpOptionsFlowHandler(config_entries.OptionsFlow): """Manage the options.""" if user_input is not None: udn = self.config_entry.data.get(CONFIG_ENTRY_UDN) - coordinator = self.hass.data[DOMAIN]["coordinators"][udn] + coordinator = self.hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] update_interval_sec = user_input.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py index eb0844e2cb0..8256fdd9fc9 100644 --- a/homeassistant/components/upnp/const.py +++ b/homeassistant/components/upnp/const.py @@ -4,9 +4,14 @@ import logging from homeassistant.const import TIME_SECONDS +LOGGER = logging.getLogger(__package__) + CONF_LOCAL_IP = "local_ip" DOMAIN = "upnp" -LOGGER = logging.getLogger(__package__) +DOMAIN_COORDINATORS = "coordinators" +DOMAIN_DEVICES = "devices" +DOMAIN_LOCAL_IP = "local_ip" +DOMAIN_CONFIG = "config" BYTES_RECEIVED = "bytes_received" BYTES_SENT = "bytes_sent" PACKETS_RECEIVED = "packets_received" diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 05113b8f9f6..c4a81db1ff4 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -20,6 +20,7 @@ from .const import ( DISCOVERY_UDN, DISCOVERY_USN, DOMAIN, + DOMAIN_CONFIG, LOGGER as _LOGGER, PACKETS_RECEIVED, PACKETS_SENT, @@ -40,8 +41,8 @@ class Device: """Discover UPnP/IGD devices.""" _LOGGER.debug("Discovering UPnP/IGD devices") local_ip = None - if DOMAIN in hass.data and "config" in hass.data[DOMAIN]: - local_ip = hass.data[DOMAIN]["config"].get(CONF_LOCAL_IP) + if DOMAIN in hass.data and DOMAIN_CONFIG in hass.data[DOMAIN]: + local_ip = hass.data[DOMAIN][DOMAIN_CONFIG].get(CONF_LOCAL_IP) if local_ip: local_ip = IPv4Address(local_ip) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index f4d36da0b4d..5a34405e0c1 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -18,6 +18,8 @@ from .const import ( DATA_RATE_PACKETS_PER_SECOND, DEFAULT_SCAN_INTERVAL, DOMAIN, + DOMAIN_COORDINATORS, + DOMAIN_DEVICES, KIBIBYTE, LOGGER as _LOGGER, PACKETS_RECEIVED, @@ -84,9 +86,9 @@ async def async_setup_entry( udn = data[CONFIG_ENTRY_UDN] else: # any device will do - udn = list(hass.data[DOMAIN]["devices"].keys())[0] + udn = list(hass.data[DOMAIN][DOMAIN_DEVICES].keys())[0] - device: Device = hass.data[DOMAIN]["devices"][udn] + device: Device = hass.data[DOMAIN][DOMAIN_DEVICES][udn] update_interval_sec = config_entry.options.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL @@ -102,7 +104,7 @@ async def async_setup_entry( update_interval=update_interval, ) await coordinator.async_refresh() - hass.data[DOMAIN]["coordinators"][udn] = coordinator + hass.data[DOMAIN][DOMAIN_COORDINATORS][udn] = coordinator sensors = [ RawUpnpSensor(coordinator, device, SENSOR_TYPES[BYTES_RECEIVED]), From dddcb8e2999b02eacd3d46648672cea303a69f1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2020 16:59:19 -1000 Subject: [PATCH 298/362] Add a 60s timeout to shell_command to prevent processes from building up (#38491) If a process never ended, there was not timeout and they would build up in the background until Home Assistant crashed. --- .../components/shell_command/__init__.py | 19 +++++++++++++++- tests/components/shell_command/test_init.py | 22 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index dc9fd8769d6..bce980035dc 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -12,6 +12,8 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType DOMAIN = "shell_command" +COMMAND_TIMEOUT = 60 + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( @@ -74,7 +76,22 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: ) process = await create_process - stdout_data, stderr_data = await process.communicate() + try: + stdout_data, stderr_data = await asyncio.wait_for( + process.communicate(), COMMAND_TIMEOUT + ) + except asyncio.TimeoutError: + _LOGGER.exception( + "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT + ) + if process: + try: + await process.kill() + except TypeError: + pass + del process + + return if stdout_data: _LOGGER.debug( diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index 76f81ea72df..7019d22fac8 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -6,7 +6,7 @@ from typing import Tuple import unittest from homeassistant.components import shell_command -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component, setup_component from tests.async_mock import Mock, patch from tests.common import get_test_home_assistant @@ -178,3 +178,23 @@ class TestShellCommand(unittest.TestCase): self.hass.block_till_done() assert mock_output.call_count == 1 assert test_phrase.encode() + b"\n" == mock_output.call_args_list[0][0][-1] + + +async def test_do_no_run_forever(hass, caplog): + """Test subprocesses terminate after the timeout.""" + + with patch.object(shell_command, "COMMAND_TIMEOUT", 0.001): + assert await async_setup_component( + hass, + shell_command.DOMAIN, + {shell_command.DOMAIN: {"test_service": "sleep 10000"}}, + ) + await hass.async_block_till_done() + + await hass.services.async_call( + shell_command.DOMAIN, "test_service", blocking=True + ) + await hass.async_block_till_done() + + assert "Timed out" in caplog.text + assert "sleep 10000" in caplog.text From 7b728b17f72fe807ea20e71a7d2789ac6e10a233 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Aug 2020 17:00:02 -1000 Subject: [PATCH 299/362] Add missing timeout to command_line platforms: cover, notify, switch (#38497) * Add missing timeout to command_line platforms: cover, notify, switch * add timeout test for notify --- .../components/command_line/__init__.py | 41 +++++++++++++++++ .../components/command_line/binary_sensor.py | 3 +- .../components/command_line/const.py | 4 ++ .../components/command_line/cover.py | 33 ++++++------- .../components/command_line/notify.py | 20 ++++++-- .../components/command_line/sensor.py | 19 +++----- .../components/command_line/switch.py | 46 +++++++++---------- tests/components/command_line/test_cover.py | 3 +- tests/components/command_line/test_notify.py | 24 +++++++++- tests/components/command_line/test_sensor.py | 2 +- tests/components/command_line/test_switch.py | 4 +- 11 files changed, 132 insertions(+), 67 deletions(-) create mode 100644 homeassistant/components/command_line/const.py diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index fe0640d3efa..92f219a13ea 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1 +1,42 @@ """The command_line component.""" + +import logging +import subprocess + +_LOGGER = logging.getLogger(__name__) + + +def call_shell_with_timeout(command, timeout): + """Run a shell command with a timeout.""" + try: + _LOGGER.debug("Running command: %s", command) + subprocess.check_output( + command, shell=True, timeout=timeout # nosec # shell by design + ) + return 0 + except subprocess.CalledProcessError as proc_exception: + _LOGGER.error("Command failed: %s", command) + return proc_exception.returncode + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + return -1 + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + return -1 + + +def check_output_or_log(command, timeout): + """Run a shell command with a timeout and return the output.""" + try: + return_value = subprocess.check_output( + command, shell=True, timeout=timeout # nosec # shell by design + ) + return return_value.strip().decode("utf-8") + except subprocess.CalledProcessError: + _LOGGER.error("Command failed: %s", command) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", command) + except subprocess.SubprocessError: + _LOGGER.error("Error trying to exec command: %s", command) + + return None diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index dc62d8daa9d..86916e86a26 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT from .sensor import CommandSensorData _LOGGER = logging.getLogger(__name__) @@ -29,8 +30,6 @@ DEFAULT_PAYLOAD_OFF = "OFF" SCAN_INTERVAL = timedelta(seconds=60) -CONF_COMMAND_TIMEOUT = "command_timeout" -DEFAULT_TIMEOUT = 15 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py new file mode 100644 index 00000000000..8c5bc0b2967 --- /dev/null +++ b/homeassistant/components/command_line/const.py @@ -0,0 +1,4 @@ +"""Allows to configure custom shell commands to turn a value for a sensor.""" + +CONF_COMMAND_TIMEOUT = "command_timeout" +DEFAULT_TIMEOUT = 15 diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 6f2a038d051..1fdcdf3b3e7 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,6 +1,5 @@ """Support for command line covers.""" import logging -import subprocess import voluptuous as vol @@ -16,6 +15,9 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv +from . import call_shell_with_timeout, check_output_or_log +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) COVER_SCHEMA = vol.Schema( @@ -26,6 +28,7 @@ COVER_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } ) @@ -48,11 +51,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): CommandCover( hass, device_config.get(CONF_FRIENDLY_NAME, device_name), - device_config.get(CONF_COMMAND_OPEN), - device_config.get(CONF_COMMAND_CLOSE), - device_config.get(CONF_COMMAND_STOP), + device_config[CONF_COMMAND_OPEN], + device_config[CONF_COMMAND_CLOSE], + device_config[CONF_COMMAND_STOP], device_config.get(CONF_COMMAND_STATE), value_template, + device_config[CONF_COMMAND_TIMEOUT], ) ) @@ -75,6 +79,7 @@ class CommandCover(CoverEntity): command_stop, command_state, value_template, + timeout, ): """Initialize the cover.""" self._hass = hass @@ -85,31 +90,23 @@ class CommandCover(CoverEntity): self._command_stop = command_stop self._command_state = command_state self._value_template = value_template + self._timeout = timeout - @staticmethod - def _move_cover(command): + def _move_cover(self, command): """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = subprocess.call(command, shell=True) == 0 # nosec # shell by design + success = call_shell_with_timeout(command, self._timeout) == 0 if not success: _LOGGER.error("Command failed: %s", command) return success - @staticmethod - def _query_state_value(command): + def _query_state_value(self, command): """Execute state command for return value.""" - _LOGGER.info("Running state command: %s", command) - - try: - return_value = subprocess.check_output( - command, shell=True # nosec # shell by design - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) + _LOGGER.info("Running state value command: %s", command) + return check_output_or_log(command, self._timeout) @property def should_poll(self): diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 50b0bec74ee..948bda7e45a 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -8,26 +8,34 @@ from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationSer from homeassistant.const import CONF_COMMAND, CONF_NAME import homeassistant.helpers.config_validation as cv +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME): cv.string} + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } ) def get_service(hass, config, discovery_info=None): """Get the Command Line notification service.""" command = config[CONF_COMMAND] + timeout = config[CONF_COMMAND_TIMEOUT] - return CommandLineNotificationService(command) + return CommandLineNotificationService(command, timeout) class CommandLineNotificationService(BaseNotificationService): """Implement the notification service for the Command Line service.""" - def __init__(self, command): + def __init__(self, command, timeout): """Initialize the service.""" self.command = command + self._timeout = timeout def send_message(self, message="", **kwargs): """Send a message to a command line.""" @@ -38,8 +46,10 @@ class CommandLineNotificationService(BaseNotificationService): stdin=subprocess.PIPE, shell=True, # nosec # shell by design ) - proc.communicate(input=message) + proc.communicate(input=message, timeout=self._timeout) if proc.returncode != 0: _LOGGER.error("Command failed: %s", self.command) + except subprocess.TimeoutExpired: + _LOGGER.error("Timeout for command: %s", self.command) except subprocess.SubprocessError: - _LOGGER.error("Error trying to exec Command: %s", self.command) + _LOGGER.error("Error trying to exec command: %s", self.command) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index feb63e18443..778806099aa 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Mapping from datetime import timedelta import json import logging -import subprocess import voluptuous as vol @@ -20,13 +19,14 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from . import check_output_or_log +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) -CONF_COMMAND_TIMEOUT = "command_timeout" CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" -DEFAULT_TIMEOUT = 15 SCAN_INTERVAL = timedelta(seconds=60) @@ -171,13 +171,6 @@ class CommandSensorData: else: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - try: - _LOGGER.debug("Running command: %s", command) - return_value = subprocess.check_output( - command, shell=True, timeout=self.timeout # nosec # shell by design - ) - self.value = return_value.strip().decode("utf-8") - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) - except subprocess.TimeoutExpired: - _LOGGER.error("Timeout for command: %s", command) + + _LOGGER.debug("Running command: %s", command) + self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 7f62970b639..50cda31a537 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,6 +1,5 @@ """Support for custom shell commands to turn a switch on/off.""" import logging -import subprocess import voluptuous as vol @@ -19,6 +18,9 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv +from . import call_shell_with_timeout, check_output_or_log +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT + _LOGGER = logging.getLogger(__name__) SWITCH_SCHEMA = vol.Schema( @@ -28,6 +30,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_STATE): cv.string, vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, } ) @@ -52,10 +55,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hass, object_id, device_config.get(CONF_FRIENDLY_NAME, object_id), - device_config.get(CONF_COMMAND_ON), - device_config.get(CONF_COMMAND_OFF), + device_config[CONF_COMMAND_ON], + device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), value_template, + device_config[CONF_COMMAND_TIMEOUT], ) ) @@ -78,6 +82,7 @@ class CommandSwitch(SwitchEntity): command_off, command_state, value_template, + timeout, ): """Initialize the switch.""" self._hass = hass @@ -88,37 +93,28 @@ class CommandSwitch(SwitchEntity): self._command_off = command_off self._command_state = command_state self._value_template = value_template + self._timeout = timeout - @staticmethod - def _switch(command): + def _switch(self, command): """Execute the actual commands.""" _LOGGER.info("Running command: %s", command) - success = subprocess.call(command, shell=True) == 0 # nosec # shell by design + success = call_shell_with_timeout(command, self._timeout) == 0 if not success: _LOGGER.error("Command failed: %s", command) return success - @staticmethod - def _query_state_value(command): + def _query_state_value(self, command): """Execute state command for return value.""" - _LOGGER.info("Running state command: %s", command) + _LOGGER.info("Running state value command: %s", command) + return check_output_or_log(command, self._timeout) - try: - return_value = subprocess.check_output( - command, shell=True # nosec # shell by design - ) - return return_value.strip().decode("utf-8") - except subprocess.CalledProcessError: - _LOGGER.error("Command failed: %s", command) - - @staticmethod - def _query_state_code(command): + def _query_state_code(self, command): """Execute state command for return code.""" - _LOGGER.info("Running state command: %s", command) - return subprocess.call(command, shell=True) == 0 # nosec # shell by design + _LOGGER.info("Running state code command: %s", command) + return call_shell_with_timeout(command, self._timeout) == 0 @property def should_poll(self): @@ -146,8 +142,8 @@ class CommandSwitch(SwitchEntity): _LOGGER.error("No state command specified") return if self._value_template: - return CommandSwitch._query_state_value(self._command_state) - return CommandSwitch._query_state_code(self._command_state) + return self._query_state_value(self._command_state) + return self._query_state_code(self._command_state) def update(self): """Update device state.""" @@ -159,12 +155,12 @@ class CommandSwitch(SwitchEntity): def turn_on(self, **kwargs): """Turn the device on.""" - if CommandSwitch._switch(self._command_on) and not self._command_state: + if self._switch(self._command_on) and not self._command_state: self._state = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" - if CommandSwitch._switch(self._command_off) and not self._command_state: + if self._switch(self._command_off) and not self._command_state: self._state = False self.schedule_update_ha_state() diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index c4f45ed9704..cc91e521d68 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -27,6 +27,7 @@ def rs(hass): "command_stop", "command_state", None, + 15, ) @@ -45,7 +46,7 @@ def test_query_state_value(rs): assert "foo bar" == result assert mock_run.call_count == 1 assert mock_run.call_args == mock.call( - "runme", shell=True, # nosec # shell by design + "runme", shell=True, timeout=15 # nosec # shell by design ) diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index ecdb5af91da..8509bc785da 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -4,7 +4,7 @@ import tempfile import unittest import homeassistant.components.notify as notify -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component, setup_component from tests.async_mock import patch from tests.common import assert_setup_component, get_test_home_assistant @@ -93,3 +93,25 @@ class TestCommandLine(unittest.TestCase): "notify", "test", {"message": "error"}, blocking=True ) assert mock_error.call_count == 1 + + +async def test_timeout(hass, caplog): + """Test we do not block forever.""" + assert await async_setup_component( + hass, + notify.DOMAIN, + { + "notify": { + "name": "test", + "platform": "command_line", + "command": "sleep 10000", + "command_timeout": 0.0000001, + } + }, + ) + await hass.async_block_till_done() + assert await hass.services.async_call( + "notify", "test", {"message": "error"}, blocking=True + ) + await hass.async_block_till_done() + assert "Timeout" in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 9d7e46002f6..623269b9c16 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -74,7 +74,7 @@ class TestCommandSensorSensor(unittest.TestCase): """Ensure command with templates and quotes get rendered properly.""" self.hass.states.set("sensor.test_state", "Works 2") with patch( - "homeassistant.components.command_line.sensor.subprocess.check_output", + "homeassistant.components.command_line.subprocess.check_output", return_value=b"Works\n", ) as check_output: data = command_line.CommandSensorData( diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index 5c4a1aa336f..a9d9c61444a 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -180,13 +180,14 @@ class TestCommandSwitch(unittest.TestCase): "echo 'off command'", None, None, + 15, ] no_state_device = command_line.CommandSwitch(*init_args) assert no_state_device.assumed_state # Set state command - init_args[-2] = "cat {}" + init_args[-3] = "cat {}" state_device = command_line.CommandSwitch(*init_args) assert not state_device.assumed_state @@ -201,6 +202,7 @@ class TestCommandSwitch(unittest.TestCase): "echo 'off command'", False, None, + 15, ] test_switch = command_line.CommandSwitch(*init_args) From 5b234b80e89d475177cc6871b52bb3b5881a937a Mon Sep 17 00:00:00 2001 From: A C++ MaNong Date: Wed, 5 Aug 2020 02:01:12 -0700 Subject: [PATCH 300/362] Keep webostv source list when TV is off (#38250) * keep source list when TV is off * remove source_list reset as the method ends here --- homeassistant/components/webostv/media_player.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index aa5a2a9c79d..1cbf844b289 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -162,6 +162,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): def update_sources(self): """Update list of sources from current source, apps, inputs and configured list.""" + source_list = self._source_list self._source_list = {} conf_sources = self._customize[CONF_SOURCES] @@ -206,6 +207,8 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity): or any(word in app["id"] for word in conf_sources) ): self._source_list["Live TV"] = app + if not self._source_list and source_list: + self._source_list = source_list @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) async def async_update(self): From e422274085974c090e7e63f913bbeb19e930299b Mon Sep 17 00:00:00 2001 From: Pedro Lamas Date: Wed, 5 Aug 2020 10:26:17 +0100 Subject: [PATCH 301/362] Use IP Address (host) provided by mDNS on Elgato Key Light (#38539) --- .../components/elgato/config_flow.py | 10 ++-- tests/components/elgato/__init__.py | 14 +++-- tests/components/elgato/test_config_flow.py | 59 ++++++++----------- tests/components/elgato/test_init.py | 2 +- 4 files changed, 41 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 2f3e05fd720..8411980ee44 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -55,21 +55,21 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_abort(reason="connection_error") - # Hostname is format: my-ke.local. - host = user_input["hostname"].rstrip(".") try: - info = await self._get_elgato_info(host, user_input[CONF_PORT]) + info = await self._get_elgato_info( + user_input[CONF_HOST], user_input[CONF_PORT] + ) except ElgatoError: return self.async_abort(reason="connection_error") # Check if already configured await self.async_set_unique_id(info.serial_number) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update( { - CONF_HOST: host, + CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT], CONF_SERIAL_NUMBER: info.serial_number, "title_placeholders": {"serial_number": info.serial_number}, diff --git a/tests/components/elgato/__init__.py b/tests/components/elgato/__init__.py index 75fb5fd0bc8..95791161a1f 100644 --- a/tests/components/elgato/__init__.py +++ b/tests/components/elgato/__init__.py @@ -14,28 +14,34 @@ async def init_integration( """Set up the Elgato Key Light integration in Home Assistant.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) aioclient_mock.put( - "http://example.local:9123/elgato/lights", + "http://1.2.3.4:9123/elgato/lights", text=load_fixture("elgato/state.json"), headers={"Content-Type": "application/json"}, ) aioclient_mock.get( - "http://example.local:9123/elgato/lights", + "http://1.2.3.4:9123/elgato/lights", text=load_fixture("elgato/state.json"), headers={"Content-Type": "application/json"}, ) + aioclient_mock.get( + "http://5.6.7.8:9123/elgato/accessory-info", + text=load_fixture("elgato/info.json"), + headers={"Content-Type": "application/json"}, + ) + entry = MockConfigEntry( domain=DOMAIN, unique_id="CN11A1A00001", data={ - CONF_HOST: "example.local", + CONF_HOST: "1.2.3.4", CONF_PORT: 9123, CONF_SERIAL_NUMBER: "CN11A1A00001", }, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 607db56fed6..7d50aec2e22 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -41,7 +41,7 @@ async def test_show_zerconf_form( ) -> None: """Test that the zeroconf confirmation form is served.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) @@ -49,11 +49,9 @@ async def test_show_zerconf_form( flow = config_flow.ElgatoFlowHandler() flow.hass = hass flow.context = {"source": SOURCE_ZEROCONF} - result = await flow.async_step_zeroconf( - {"hostname": "example.local.", "port": 9123} - ) + result = await flow.async_step_zeroconf({"host": "1.2.3.4", "port": 9123}) - assert flow.context[CONF_HOST] == "example.local" + assert flow.context[CONF_HOST] == "1.2.3.4" assert flow.context[CONF_PORT] == 9123 assert flow.context[CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} @@ -65,14 +63,12 @@ async def test_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we show user form on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://example.local/elgato/accessory-info", exc=aiohttp.ClientError - ) + aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_PORT: 9123}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 9123}, ) assert result["errors"] == {"base": "connection_error"} @@ -84,14 +80,12 @@ async def test_zeroconf_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://example.local/elgato/accessory-info", exc=aiohttp.ClientError - ) + aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "port": 9123}, + data={"host": "1.2.3.4", "port": 9123}, ) assert result["reason"] == "connection_error" @@ -102,19 +96,17 @@ async def test_zeroconf_confirm_connection_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test we abort zeroconf flow on Elgato Key Light connection error.""" - aioclient_mock.get( - "http://example.local/elgato/accessory-info", exc=aiohttp.ClientError - ) + aioclient_mock.get("http://1.2.3.4/elgato/accessory-info", exc=aiohttp.ClientError) flow = config_flow.ElgatoFlowHandler() flow.hass = hass flow.context = { "source": SOURCE_ZEROCONF, - CONF_HOST: "example.local", + CONF_HOST: "1.2.3.4", CONF_PORT: 9123, } result = await flow.async_step_zeroconf_confirm( - user_input={CONF_HOST: "example.local", CONF_PORT: 9123} + user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 9123} ) assert result["reason"] == "connection_error" @@ -142,7 +134,7 @@ async def test_user_device_exists_abort( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_USER}, - data={CONF_HOST: "example.local", CONF_PORT: 9123}, + data={CONF_HOST: "1.2.3.4", CONF_PORT: 9123}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -157,7 +149,7 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, - data={"hostname": "example.local.", "port": 9123}, + data={"host": "1.2.3.4", "port": 9123}, ) assert result["reason"] == "already_configured" @@ -165,20 +157,23 @@ async def test_zeroconf_device_exists_abort( result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, - context={"source": SOURCE_ZEROCONF, CONF_HOST: "example.local", "port": 9123}, - data={"hostname": "example.local.", "port": 9123}, + context={"source": SOURCE_ZEROCONF, CONF_HOST: "1.2.3.4", "port": 9123}, + data={"host": "5.6.7.8", "port": 9123}, ) assert result["reason"] == "already_configured" assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + entries = hass.config_entries.async_entries(config_flow.DOMAIN) + assert entries[0].data[CONF_HOST] == "5.6.7.8" + async def test_full_user_flow_implementation( hass: HomeAssistant, aioclient_mock ) -> None: """Test the full manual user flow from start to finish.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) @@ -191,10 +186,10 @@ async def test_full_user_flow_implementation( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "example.local", CONF_PORT: 9123} + result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 9123} ) - assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_HOST] == "1.2.3.4" assert result["data"][CONF_PORT] == 9123 assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["title"] == "CN11A1A00001" @@ -209,7 +204,7 @@ async def test_full_zeroconf_flow_implementation( ) -> None: """Test the full manual user flow from start to finish.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", + "http://1.2.3.4:9123/elgato/accessory-info", text=load_fixture("elgato/info.json"), headers={"Content-Type": "application/json"}, ) @@ -217,21 +212,17 @@ async def test_full_zeroconf_flow_implementation( flow = config_flow.ElgatoFlowHandler() flow.hass = hass flow.context = {"source": SOURCE_ZEROCONF} - result = await flow.async_step_zeroconf( - {"hostname": "example.local.", "port": 9123} - ) + result = await flow.async_step_zeroconf({"host": "1.2.3.4", "port": 9123}) - assert flow.context[CONF_HOST] == "example.local" + assert flow.context[CONF_HOST] == "1.2.3.4" assert flow.context[CONF_PORT] == 9123 assert flow.context[CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["description_placeholders"] == {CONF_SERIAL_NUMBER: "CN11A1A00001"} assert result["step_id"] == "zeroconf_confirm" assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - result = await flow.async_step_zeroconf_confirm( - user_input={CONF_HOST: "example.local"} - ) - assert result["data"][CONF_HOST] == "example.local" + result = await flow.async_step_zeroconf_confirm(user_input={CONF_HOST: "1.2.3.4"}) + assert result["data"][CONF_HOST] == "1.2.3.4" assert result["data"][CONF_PORT] == 9123 assert result["data"][CONF_SERIAL_NUMBER] == "CN11A1A00001" assert result["title"] == "CN11A1A00001" diff --git a/tests/components/elgato/test_init.py b/tests/components/elgato/test_init.py index fd2f86fe2ea..2f0e39e05a8 100644 --- a/tests/components/elgato/test_init.py +++ b/tests/components/elgato/test_init.py @@ -14,7 +14,7 @@ async def test_config_entry_not_ready( ) -> None: """Test the Elgato Key Light configuration entry not ready.""" aioclient_mock.get( - "http://example.local:9123/elgato/accessory-info", exc=aiohttp.ClientError + "http://1.2.3.4:9123/elgato/accessory-info", exc=aiohttp.ClientError ) entry = await init_integration(hass, aioclient_mock) From 74ba209f06335d70cfb9533a00c8b6d935c6a766 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2020 11:32:53 +0200 Subject: [PATCH 302/362] Bump actions/upload-artifact from v2.1.2 to v2.1.3 (#38552) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from v2.1.2 to v2.1.3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v2.1.2...268d7547644ab8a9d0c1163299e59a1f5d93f39b) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a3062e5cc4c..6389a542825 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -737,7 +737,7 @@ jobs: -p no:sugar \ tests - name: Upload coverage artifact - uses: actions/upload-artifact@v2.1.2 + uses: actions/upload-artifact@v2.1.3 with: name: coverage-${{ matrix.python-version }}-group${{ matrix.group }} path: .coverage From c0c30bb1ccd4153362fa4bb8e7efdd165252af32 Mon Sep 17 00:00:00 2001 From: Peter Nijssen Date: Wed, 5 Aug 2020 11:42:34 +0200 Subject: [PATCH 303/362] Update pyrainbird to 0.4.2 (#38542) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index cf604714106..89ca65fd44b 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -2,6 +2,6 @@ "domain": "rainbird", "name": "Rain Bird", "documentation": "https://www.home-assistant.io/integrations/rainbird", - "requirements": ["pyrainbird==0.4.1"], + "requirements": ["pyrainbird==0.4.2"], "codeowners": ["@konikvranik"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce3b5751074..5ac654cdc57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1574,7 +1574,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==0.4.1 +pyrainbird==0.4.2 # homeassistant.components.recswitch pyrecswitch==1.0.2 From 3fc5f9deb82e855d69aa7001df96c5820a62554c Mon Sep 17 00:00:00 2001 From: chewbh Date: Wed, 5 Aug 2020 18:15:19 +0800 Subject: [PATCH 304/362] Add Xiaomi Aqara wireless and light switches (2020 model) (#37985) --- homeassistant/components/xiaomi_aqara/binary_sensor.py | 2 ++ homeassistant/components/xiaomi_aqara/manifest.json | 2 +- homeassistant/components/xiaomi_aqara/switch.py | 8 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 1145f1afa5c..0709c7e83fa 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -57,6 +57,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "sensor_86sw1", "sensor_86sw1.aq1", "remote.b186acn01", + "remote.b186acn02", ]: if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key = "channel_0" @@ -72,6 +73,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): "sensor_86sw2", "sensor_86sw2.aq1", "remote.b286acn01", + "remote.b286acn02", ]: if "proto" not in entity or int(entity["proto"][0:1]) == 1: data_key_left = "channel_0" diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index cb6bb376e3b..1a00fc3afd2 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -3,7 +3,7 @@ "name": "Xiaomi Gateway (Aqara)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", - "requirements": ["PyXiaomiGateway==0.12.4"], + "requirements": ["PyXiaomiGateway==0.13.2"], "after_dependencies": ["discovery"], "codeowners": ["@danielhiversen", "@syssi"], "zeroconf": ["_miio._udp.local."] diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 36dadefee1f..aee66e1a439 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -37,19 +37,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): device, "Plug", data_key, True, gateway, config_entry ) ) - elif model in ["ctrl_neutral1", "ctrl_neutral1.aq1"]: + elif model in ["ctrl_neutral1", "ctrl_neutral1.aq1", "switch_b1lacn02"]: entities.append( XiaomiGenericSwitch( device, "Wall Switch", "channel_0", False, gateway, config_entry ) ) - elif model in ["ctrl_ln1", "ctrl_ln1.aq1"]: + elif model in ["ctrl_ln1", "ctrl_ln1.aq1", "switch_b1nacn02"]: entities.append( XiaomiGenericSwitch( device, "Wall Switch LN", "channel_0", False, gateway, config_entry ) ) - elif model in ["ctrl_neutral2", "ctrl_neutral2.aq1"]: + elif model in ["ctrl_neutral2", "ctrl_neutral2.aq1", "switch_b2lacn02"]: entities.append( XiaomiGenericSwitch( device, @@ -70,7 +70,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config_entry, ) ) - elif model in ["ctrl_ln2", "ctrl_ln2.aq1"]: + elif model in ["ctrl_ln2", "ctrl_ln2.aq1", "switch_b2nacn02"]: entities.append( XiaomiGenericSwitch( device, diff --git a/requirements_all.txt b/requirements_all.txt index 5ac654cdc57..6c0309a66db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -71,7 +71,7 @@ PyTurboJPEG==1.4.0 PyViCare==0.2.0 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.12.4 +PyXiaomiGateway==0.13.2 # homeassistant.components.bmp280 # homeassistant.components.mcp23017 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index feac0608b5e..8bdad7015de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -31,7 +31,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.4.0 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.12.4 +PyXiaomiGateway==0.13.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 3fdec7946ca03cc75a503fbcffcf07900906a532 Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Wed, 5 Aug 2020 06:21:14 -0400 Subject: [PATCH 305/362] Blink auth flow improvement and mini camera support (#38027) --- homeassistant/components/blink/__init__.py | 106 ++++---- homeassistant/components/blink/config_flow.py | 94 ++++--- homeassistant/components/blink/const.py | 1 + homeassistant/components/blink/manifest.json | 2 +- homeassistant/components/blink/strings.json | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/blink/test_config_flow.py | 238 ++++++++++++------ 8 files changed, 271 insertions(+), 178 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 2344ce7b432..e2c43a27547 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,32 +1,25 @@ """Support for Blink Home Camera System.""" import asyncio +from copy import deepcopy import logging +from blinkpy.auth import Auth from blinkpy.blinkpy import Blink import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_FILENAME, - CONF_NAME, - CONF_PASSWORD, - CONF_PIN, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) -from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv - -from .const import ( - DEFAULT_OFFSET, +from homeassistant.components import persistent_notification +from homeassistant.components.blink.const import ( DEFAULT_SCAN_INTERVAL, - DEVICE_ID, DOMAIN, PLATFORMS, SERVICE_REFRESH, SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) +from homeassistant.const import CONF_FILENAME, CONF_NAME, CONF_PIN, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -35,58 +28,50 @@ SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( ) SERVICE_SEND_PIN_SCHEMA = vol.Schema({vol.Optional(CONF_PIN): cv.string}) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -def _blink_startup_wrapper(entry): +def _blink_startup_wrapper(hass, entry): """Startup wrapper for blink.""" - blink = Blink( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - motion_interval=DEFAULT_OFFSET, - legacy_subdomain=False, - no_prompt=True, - device_id=DEVICE_ID, - ) + blink = Blink() + auth_data = deepcopy(dict(entry.data)) + blink.auth = Auth(auth_data, no_prompt=True) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - try: - blink.login_response = entry.data["login_response"] - blink.setup_params(entry.data["login_response"]) - except KeyError: - blink.get_auth_token() + if blink.start(): + blink.setup_post_verify() + elif blink.auth.check_key_required(): + _LOGGER.debug("Attempting a reauth flow") + _reauth_flow_wrapper(hass, auth_data) - blink.setup_params(entry.data["login_response"]) - blink.setup_post_verify() return blink -async def async_setup(hass, config): - """Set up a config entry.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - conf = config.get(DOMAIN, {}) - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) +def _reauth_flow_wrapper(hass, data): + """Reauth flow wrapper.""" + hass.add_job( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=data ) + ) + persistent_notification.async_create( + hass, + "Blink configuration migrated to a new version. Please go to the integrations page to re-configure (such as sending a new 2FA key).", + "Blink Migration", + ) + +async def async_setup(hass, config): + """Set up a Blink component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_migrate_entry(hass, entry): + """Handle migration of a previous version config entry.""" + data = {**entry.data} + if entry.version == 1: + data.pop("login_response", None) + await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + return False return True @@ -95,12 +80,11 @@ async def async_setup_entry(hass, entry): _async_import_options_from_data_if_missing(hass, entry) hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( - _blink_startup_wrapper, entry + _blink_startup_wrapper, hass, entry ) if not hass.data[DOMAIN][entry.entry_id].available: - _LOGGER.error("Blink unavailable for setup") - return False + raise ConfigEntryNotReady for component in PLATFORMS: hass.async_create_task( @@ -118,7 +102,7 @@ async def async_setup_entry(hass, entry): def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - hass.data[DOMAIN][entry.entry_id].login_handler.send_auth_key( + hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( hass.data[DOMAIN][entry.entry_id], pin, ) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 4cd89175ab6..3073a093261 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -1,10 +1,16 @@ """Config flow to configure Blink.""" import logging -from blinkpy.blinkpy import Blink +from blinkpy.auth import Auth, LoginError, TokenRefreshFailed +from blinkpy.blinkpy import Blink, BlinkSetupError import voluptuous as vol from homeassistant import config_entries, core, exceptions +from homeassistant.components.blink.const import ( + DEFAULT_SCAN_INTERVAL, + DEVICE_ID, + DOMAIN, +) from homeassistant.const import ( CONF_PASSWORD, CONF_PIN, @@ -13,36 +19,36 @@ from homeassistant.const import ( ) from homeassistant.core import callback -from .const import DEFAULT_OFFSET, DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN - _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: core.HomeAssistant, blink): +def validate_input(hass: core.HomeAssistant, auth): """Validate the user input allows us to connect.""" - response = await hass.async_add_executor_job(blink.get_auth_token) - if not response: + try: + auth.startup() + except (LoginError, TokenRefreshFailed): raise InvalidAuth - if blink.key_required: + if auth.check_key_required(): raise Require2FA - return blink.login_response + +def _send_blink_2fa_pin(auth, pin): + """Send 2FA pin to blink servers.""" + blink = Blink() + blink.auth = auth + blink.setup_urls() + return auth.send_auth_key(blink, pin) class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Blink config flow.""" - VERSION = 1 + VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL def __init__(self): """Initialize the blink flow.""" - self.blink = None - self.data = { - CONF_USERNAME: "", - CONF_PASSWORD: "", - "login_response": None, - } + self.auth = None @staticmethod @callback @@ -53,28 +59,19 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" errors = {} + data = {CONF_USERNAME: "", CONF_PASSWORD: "", "device_id": DEVICE_ID} if user_input is not None: - self.data[CONF_USERNAME] = user_input["username"] - self.data[CONF_PASSWORD] = user_input["password"] + data[CONF_USERNAME] = user_input["username"] + data[CONF_PASSWORD] = user_input["password"] - await self.async_set_unique_id(self.data[CONF_USERNAME]) - - if CONF_SCAN_INTERVAL in user_input: - self.data[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL] - - self.blink = Blink( - username=self.data[CONF_USERNAME], - password=self.data[CONF_PASSWORD], - motion_interval=DEFAULT_OFFSET, - legacy_subdomain=False, - no_prompt=True, - device_id=DEVICE_ID, - ) + self.auth = Auth(data, no_prompt=True) + await self.async_set_unique_id(data[CONF_USERNAME]) try: - response = await validate_input(self.hass, self.blink) - self.data["login_response"] = response - return self.async_create_entry(title=DOMAIN, data=self.data,) + await self.hass.async_add_executor_job( + validate_input, self.hass, self.auth + ) + return self._async_finish_flow() except Require2FA: return await self.async_step_2fa() except InvalidAuth: @@ -94,23 +91,40 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_2fa(self, user_input=None): """Handle 2FA step.""" + errors = {} if user_input is not None: pin = user_input.get(CONF_PIN) - if await self.hass.async_add_executor_job( - self.blink.login_handler.send_auth_key, self.blink, pin - ): - return await self.async_step_user(user_input=self.data) + try: + valid_token = await self.hass.async_add_executor_job( + _send_blink_2fa_pin, self.auth, pin + ) + except BlinkSetupError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + if valid_token: + return self._async_finish_flow() + errors["base"] = "invalid_access_token" return self.async_show_form( step_id="2fa", data_schema=vol.Schema( {vol.Optional("pin"): vol.All(str, vol.Length(min=1))} ), + errors=errors, ) - async def async_step_import(self, import_data): - """Import blink config from configuration.yaml.""" - return await self.async_step_user(import_data) + async def async_step_reauth(self, entry_data): + """Perform reauth upon migration of old entries.""" + return await self.async_step_user(entry_data) + + @callback + def _async_finish_flow(self): + """Finish with setup.""" + return self.async_create_entry(title=DOMAIN, data=self.auth.login_attributes) class BlinkOptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 5ce22d10914..c93adbec46b 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -2,6 +2,7 @@ DOMAIN = "blink" DEVICE_ID = "Home Assistant" +CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index a42763e5843..ca3f1f6efee 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -2,7 +2,7 @@ "domain": "blink", "name": "Blink", "documentation": "https://www.home-assistant.io/integrations/blink", - "requirements": ["blinkpy==0.15.1"], + "requirements": ["blinkpy==0.16.3"], "codeowners": ["@fronzbot"], "config_flow": true } diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index e3bbe4006f3..db9bdf96273 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -11,11 +11,13 @@ "2fa": { "title": "Two-factor authentication", "data": { "2fa": "Two-factor code" }, - "description": "Enter the pin sent to your email. If the email does not contain a pin, leave blank" + "description": "Enter the pin sent to your email" } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/requirements_all.txt b/requirements_all.txt index 6c0309a66db..3499e300121 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -340,7 +340,7 @@ bizkaibus==0.1.1 blebox_uniapi==1.3.2 # homeassistant.components.blink -blinkpy==0.15.1 +blinkpy==0.16.3 # homeassistant.components.blinksticklight blinkstick==1.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bdad7015de..d39a6bdb6ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -181,7 +181,7 @@ bellows==0.18.0 blebox_uniapi==1.3.2 # homeassistant.components.blink -blinkpy==0.15.1 +blinkpy==0.16.3 # homeassistant.components.bom bomradarloop==0.1.4 diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index e6315aac972..99b20d9a73c 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,4 +1,7 @@ """Test the Blink config flow.""" +from blinkpy.auth import LoginError +from blinkpy.blinkpy import BlinkSetupError + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.blink import DOMAIN @@ -15,13 +18,9 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.blink.config_flow.Blink", - return_value=Mock( - get_auth_token=Mock(return_value=True), - key_required=False, - login_response={}, - ), + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, ), patch( "homeassistant.components.blink.async_setup", return_value=True ) as mock_setup, patch( @@ -37,48 +36,18 @@ async def test_form(hass): assert result2["data"] == { "username": "blink@example.com", "password": "example", - "login_response": {}, + "device_id": "Home Assistant", + "token": None, + "host": None, + "account_id": None, + "client_id": None, + "region_id": None, } await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_import(hass): - """Test we import the config.""" - with patch( - "homeassistant.components.blink.config_flow.Blink", - return_value=Mock( - get_auth_token=Mock(return_value=True), - key_required=False, - login_response={}, - ), - ), patch( - "homeassistant.components.blink.async_setup_entry", return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "blink@example.com", - "password": "example", - "scan_interval": 10, - }, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "blink" - assert result["result"].unique_id == "blink@example.com" - assert result["data"] == { - "username": "blink@example.com", - "password": "example", - "scan_interval": 10, - "login_response": {}, - } - await hass.async_block_till_done() - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_form_2fa(hass): """Test we get the 2fa form.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -86,28 +55,28 @@ async def test_form_2fa(hass): DOMAIN, context={"source": config_entries.SOURCE_USER} ) - mock_blink = Mock( - get_auth_token=Mock(return_value=True), - key_required=True, - login_response={}, - login_handler=Mock(send_auth_key=Mock(return_value=True)), - ) - - with patch( - "homeassistant.components.blink.config_flow.Blink", return_value=mock_blink + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, ), patch( "homeassistant.components.blink.async_setup", return_value=True ) as mock_setup: result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": "blink@example.com", "password": "example"} + result["flow_id"], {"username": "blink@example.com", "password": "example"}, ) assert result2["type"] == "form" assert result2["step_id"] == "2fa" - mock_blink.key_required = False - with patch( - "homeassistant.components.blink.config_flow.Blink", return_value=mock_blink + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=True, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + return_value=True, ), patch( "homeassistant.components.blink.async_setup", return_value=True ) as mock_setup, patch( @@ -125,6 +94,126 @@ async def test_form_2fa(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_2fa_connect_error(hass): + """Test we report a connect error during 2fa setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, + ), patch("homeassistant.components.blink.async_setup", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "blink@example.com", "password": "example"}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "2fa" + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=True, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + side_effect=BlinkSetupError, + ), patch( + "homeassistant.components.blink.async_setup", return_value=True + ), patch( + "homeassistant.components.blink.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"pin": "1234"} + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "cannot_connect"} + + +async def test_form_2fa_invalid_key(hass): + """Test we report an error if key is invalid.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, + ), patch("homeassistant.components.blink.async_setup", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "blink@example.com", "password": "example"}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "2fa" + + with patch("homeassistant.components.blink.config_flow.Auth.startup",), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + return_value=True, + ), patch( + "homeassistant.components.blink.async_setup", return_value=True + ), patch( + "homeassistant.components.blink.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"pin": "1234"} + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "invalid_access_token"} + + +async def test_form_2fa_unknown_error(hass): + """Test we report an unknown error during 2fa setup.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=True, + ), patch("homeassistant.components.blink.async_setup", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"username": "blink@example.com", "password": "example"}, + ) + + assert result2["type"] == "form" + assert result2["step_id"] == "2fa" + + with patch("homeassistant.components.blink.config_flow.Auth.startup"), patch( + "homeassistant.components.blink.config_flow.Auth.check_key_required", + return_value=False, + ), patch( + "homeassistant.components.blink.config_flow.Auth.send_auth_key", + return_value=True, + ), patch( + "homeassistant.components.blink.config_flow.Blink.setup_urls", + side_effect=KeyError, + ), patch( + "homeassistant.components.blink.async_setup", return_value=True + ), patch( + "homeassistant.components.blink.async_setup_entry", return_value=True + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], {"pin": "1234"} + ) + + assert result3["type"] == "form" + assert result3["errors"] == {"base": "unknown"} + + async def test_form_invalid_auth(hass): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -132,8 +221,8 @@ async def test_form_invalid_auth(hass): ) with patch( - "homeassistant.components.blink.config_flow.Blink.get_auth_token", - return_value=None, + "homeassistant.components.blink.config_flow.Auth.startup", + side_effect=LoginError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "blink@example.com", "password": "example"} @@ -150,11 +239,7 @@ async def test_form_unknown_error(hass): ) with patch( - "homeassistant.components.blink.config_flow.Blink.get_auth_token", - return_value=None, - ), patch( - "homeassistant.components.blink.config_flow.validate_input", - side_effect=KeyError, + "homeassistant.components.blink.config_flow.Auth.startup", side_effect=KeyError, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"username": "blink@example.com", "password": "example"} @@ -164,27 +249,34 @@ async def test_form_unknown_error(hass): assert result2["errors"] == {"base": "unknown"} +async def test_reauth_shows_user_step(hass): + """Test reauth shows the user form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + async def test_options_flow(hass): """Test config flow options.""" config_entry = MockConfigEntry( domain=DOMAIN, - data={ - "username": "blink@example.com", - "password": "example", - "login_response": {}, - }, + data={"username": "blink@example.com", "password": "example"}, options={}, entry_id=1, + version=2, ) config_entry.add_to_hass(hass) - mock_blink = Mock( - login_handler=True, - setup_params=Mock(return_value=True), - setup_post_verify=Mock(return_value=True), + mock_auth = Mock( + startup=Mock(return_value=True), check_key_required=Mock(return_value=False) ) + mock_blink = Mock() - with patch("homeassistant.components.blink.Blink", return_value=mock_blink): + with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( + "homeassistant.components.blink.Blink", return_value=mock_blink + ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From a91f5b7192880030d6f423a6e60d450947f1b7b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2020 00:43:35 -1000 Subject: [PATCH 306/362] Prevent ping integration from blocking startup (#38504) --- .coveragerc | 1 + .../components/ping/binary_sensor.py | 25 +++++++++++++----- homeassistant/components/ping/const.py | 3 +++ .../components/ping/device_tracker.py | 10 ++++++- homeassistant/util/process.py | 12 +++++++++ tests/util/test_process.py | 26 +++++++++++++++++++ 6 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/ping/const.py create mode 100644 homeassistant/util/process.py create mode 100644 tests/util/test_process.py diff --git a/.coveragerc b/.coveragerc index 6978958a3f0..0b72c81a5dd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -636,6 +636,7 @@ omit = homeassistant/components/picotts/tts.py homeassistant/components/piglow/light.py homeassistant/components/pilight/* + homeassistant/components/ping/const.py homeassistant/components/ping/binary_sensor.py homeassistant/components/ping/device_tracker.py homeassistant/components/pioneer/media_player.py diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index a9c69f4ddad..6db9d43eeda 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -10,9 +10,13 @@ import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import CONF_HOST, CONF_NAME import homeassistant.helpers.config_validation as cv +from homeassistant.util.process import kill_subprocess + +from .const import PING_TIMEOUT _LOGGER = logging.getLogger(__name__) + ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg" ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" @@ -20,12 +24,14 @@ ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" CONF_PING_COUNT = "count" -DEFAULT_NAME = "Ping Binary sensor" +DEFAULT_NAME = "Ping" DEFAULT_PING_COUNT = 5 DEFAULT_DEVICE_CLASS = "connectivity" SCAN_INTERVAL = timedelta(minutes=5) +PARALLEL_UPDATES = 0 + PING_MATCHER = re.compile( r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" ) @@ -39,17 +45,19 @@ WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): vol.Range( + min=1, max=100 + ), } ) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Ping Binary sensor.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - count = config.get(CONF_PING_COUNT) + host = config[CONF_HOST] + count = config[CONF_PING_COUNT] + name = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}") add_entities([PingBinarySensor(name, PingData(host, count))], True) @@ -129,7 +137,7 @@ class PingData: self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) try: - out = pinger.communicate() + out = pinger.communicate(timeout=self._count + PING_TIMEOUT) _LOGGER.debug("Output is %s", str(out)) if sys.platform == "win32": match = WIN32_PING_MATCHER.search(str(out).split("\n")[-1]) @@ -142,6 +150,9 @@ class PingData: match = PING_MATCHER.search(str(out).split("\n")[-1]) rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} + except subprocess.TimeoutExpired: + kill_subprocess(pinger) + return False except (subprocess.CalledProcessError, AttributeError): return False diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py new file mode 100644 index 00000000000..8be8c1bdaa3 --- /dev/null +++ b/homeassistant/components/ping/const.py @@ -0,0 +1,3 @@ +"""Tracks devices by sending a ICMP echo request (ping).""" + +PING_TIMEOUT = 3 diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index c0effda7a55..f4e2e806143 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -14,9 +14,13 @@ from homeassistant.components.device_tracker.const import ( SOURCE_TYPE_ROUTER, ) import homeassistant.helpers.config_validation as cv +from homeassistant.util.process import kill_subprocess + +from .const import PING_TIMEOUT _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 CONF_PING_COUNT = "count" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -47,8 +51,12 @@ class Host: self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL ) try: - pinger.communicate() + pinger.communicate(timeout=1 + PING_TIMEOUT) return pinger.returncode == 0 + except subprocess.TimeoutExpired: + kill_subprocess(pinger) + return False + except subprocess.CalledProcessError: return False diff --git a/homeassistant/util/process.py b/homeassistant/util/process.py new file mode 100644 index 00000000000..fb2d6dec58e --- /dev/null +++ b/homeassistant/util/process.py @@ -0,0 +1,12 @@ +"""Util to handle processes.""" + +import subprocess + + +def kill_subprocess(process: subprocess.Popen) -> None: + """Force kill a subprocess and wait for it to exit.""" + process.kill() + process.communicate() + process.wait() + + del process diff --git a/tests/util/test_process.py b/tests/util/test_process.py new file mode 100644 index 00000000000..a82df0dbb99 --- /dev/null +++ b/tests/util/test_process.py @@ -0,0 +1,26 @@ +"""Test process util.""" + +import os +import subprocess + +import pytest + +from homeassistant.util import process + + +async def test_kill_process(): + """Test killing a process.""" + sleeper = subprocess.Popen( + "sleep 1000", + shell=True, # nosec # shell by design + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + pid = sleeper.pid + + assert os.kill(pid, 0) is None + + process.kill_subprocess(sleeper) + + with pytest.raises(OSError): + os.kill(pid, 0) From d47900473e463a4560bbc11466f7bf22cde1df26 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 5 Aug 2020 12:47:33 +0200 Subject: [PATCH 307/362] Add device_info to GIOS integration (#38503) --- homeassistant/components/gios/air_quality.py | 12 +++++++++++- homeassistant/components/gios/const.py | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py index d180b0a0ddf..3f6c85bdb97 100644 --- a/homeassistant/components/gios/air_quality.py +++ b/homeassistant/components/gios/air_quality.py @@ -10,7 +10,7 @@ from homeassistant.components.air_quality import ( ) from homeassistant.const import CONF_NAME -from .const import ATTR_STATION, DOMAIN, ICONS_MAP +from .const import ATTR_STATION, DEFAULT_NAME, DOMAIN, ICONS_MAP, MANUFACTURER ATTRIBUTION = "Data provided by GIOŚ" @@ -117,6 +117,16 @@ class GiosAirQuality(AirQualityEntity): """Return a unique_id for this entity.""" return self.coordinator.gios.station_id + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.coordinator.gios.station_id)}, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + @property def should_poll(self): """Return the polling requirement of the entity.""" diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 918b4fba2e4..117eada036b 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -8,6 +8,7 @@ DEFAULT_NAME = "GIOŚ" # Term of service GIOŚ allow downloading data no more than twice an hour. SCAN_INTERVAL = timedelta(minutes=30) DOMAIN = "gios" +MANUFACTURER = "Główny Inspektorat Ochrony Środowiska" AQI_GOOD = "dobry" AQI_MODERATE = "umiarkowany" From 56f8ced26784a0f3d41e620704a475edd9e85fe7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 5 Aug 2020 12:50:34 +0200 Subject: [PATCH 308/362] Add device_info property for AccuWeather integration (#38480) --- homeassistant/components/accuweather/const.py | 2 ++ .../components/accuweather/sensor.py | 12 +++++++++++ .../components/accuweather/weather.py | 20 ++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index c1b09ebd7b2..b189a776750 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -25,6 +25,8 @@ CONCENTRATION_PARTS_PER_CUBIC_METER = f"p/{VOLUME_CUBIC_METERS}" COORDINATOR = "coordinator" DOMAIN = "accuweather" LENGTH_MILIMETERS = "mm" +MANUFACTURER = "AccuWeather, Inc." +NAME = "AccuWeather" UNDO_UPDATE_LISTENER = "undo_update_listener" CONDITION_CLASSES = { diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 4c0634876ef..878f387c35c 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -16,6 +16,8 @@ from .const import ( DOMAIN, FORECAST_DAYS, FORECAST_SENSOR_TYPES, + MANUFACTURER, + NAME, OPTIONAL_SENSORS, SENSOR_TYPES, ) @@ -73,6 +75,16 @@ class AccuWeatherSensor(Entity): return f"{self.coordinator.location_key}-{self.kind}-{self.forecast_day}".lower() return f"{self.coordinator.location_key}-{self.kind}".lower() + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.coordinator.location_key)}, + "name": NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + @property def should_poll(self): """Return the polling requirement of the entity.""" diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 234c03d9e97..47d1fef7b14 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -15,7 +15,15 @@ from homeassistant.components.weather import ( from homeassistant.const import CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.util.dt import utc_from_timestamp -from .const import ATTR_FORECAST, ATTRIBUTION, CONDITION_CLASSES, COORDINATOR, DOMAIN +from .const import ( + ATTR_FORECAST, + ATTRIBUTION, + CONDITION_CLASSES, + COORDINATOR, + DOMAIN, + MANUFACTURER, + NAME, +) PARALLEL_UPDATES = 1 @@ -54,6 +62,16 @@ class AccuWeatherEntity(WeatherEntity): """Return a unique_id for this entity.""" return self.coordinator.location_key + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.coordinator.location_key)}, + "name": NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + @property def should_poll(self): """Return the polling requirement of the entity.""" From 8258dcf41deaa334448fca19d20eaaf609c8d05a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 5 Aug 2020 12:55:14 +0200 Subject: [PATCH 309/362] Add device_info property and simplify generation of unique_id for Airly integration (#38479) --- homeassistant/components/airly/air_quality.py | 23 ++++++++++++++----- homeassistant/components/airly/const.py | 1 + homeassistant/components/airly/sensor.py | 22 ++++++++++++++---- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py index 6e1e90051e0..8ee4e1cd87b 100644 --- a/homeassistant/components/airly/air_quality.py +++ b/homeassistant/components/airly/air_quality.py @@ -18,7 +18,9 @@ from .const import ( ATTR_API_PM25, ATTR_API_PM25_LIMIT, ATTR_API_PM25_PERCENT, + DEFAULT_NAME, DOMAIN, + MANUFACTURER, ) ATTRIBUTION = "Data provided by Airly" @@ -40,9 +42,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): coordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [AirlyAirQuality(coordinator, name, config_entry.unique_id)], False - ) + async_add_entities([AirlyAirQuality(coordinator, name)], False) def round_state(func): @@ -60,11 +60,10 @@ def round_state(func): class AirlyAirQuality(AirQualityEntity): """Define an Airly air quality.""" - def __init__(self, coordinator, name, unique_id): + def __init__(self, coordinator, name): """Initialize.""" self.coordinator = coordinator self._name = name - self._unique_id = unique_id self._icon = "mdi:blur" @property @@ -108,7 +107,19 @@ class AirlyAirQuality(AirQualityEntity): @property def unique_id(self): """Return a unique_id for this entity.""" - return self._unique_id + return f"{self.coordinator.latitude}-{self.coordinator.longitude}" + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": { + (DOMAIN, self.coordinator.latitude, self.coordinator.longitude) + }, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } @property def available(self): diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index d7f8fc12797..dc21d68a8d8 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -15,5 +15,6 @@ ATTR_API_PRESSURE = "PRESSURE" ATTR_API_TEMPERATURE = "TEMPERATURE" DEFAULT_NAME = "Airly" DOMAIN = "airly" +MANUFACTURER = "Airly sp. z o.o." MAX_REQUESTS_PER_DAY = 100 NO_AIRLY_SENSORS = "There are no Airly sensors in this area yet." diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 4f8ba0f11c7..916405be2a5 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -18,7 +18,9 @@ from .const import ( ATTR_API_PM1, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, + DEFAULT_NAME, DOMAIN, + MANUFACTURER, ) ATTRIBUTION = "Data provided by Airly" @@ -65,8 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] for sensor in SENSOR_TYPES: - unique_id = f"{config_entry.unique_id}-{sensor.lower()}" - sensors.append(AirlySensor(coordinator, name, sensor, unique_id)) + sensors.append(AirlySensor(coordinator, name, sensor)) async_add_entities(sensors, False) @@ -74,11 +75,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AirlySensor(Entity): """Define an Airly sensor.""" - def __init__(self, coordinator, name, kind, unique_id): + def __init__(self, coordinator, name, kind): """Initialize.""" self.coordinator = coordinator self._name = name - self._unique_id = unique_id self.kind = kind self._device_class = None self._state = None @@ -125,7 +125,19 @@ class AirlySensor(Entity): @property def unique_id(self): """Return a unique_id for this entity.""" - return self._unique_id + return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": { + (DOMAIN, self.coordinator.latitude, self.coordinator.longitude) + }, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } @property def unit_of_measurement(self): From b50f3103fe62b5007e764b385cc62e22c881d4b0 Mon Sep 17 00:00:00 2001 From: Steffen Zimmermann Date: Wed, 5 Aug 2020 13:41:56 +0200 Subject: [PATCH 310/362] Bump python-wiffi to 1.0.1 (#38556) fix missing exception asyncio.IncompleteReadError --- homeassistant/components/wiffi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wiffi/manifest.json b/homeassistant/components/wiffi/manifest.json index 5be1286ad6f..fa06699ac08 100644 --- a/homeassistant/components/wiffi/manifest.json +++ b/homeassistant/components/wiffi/manifest.json @@ -3,7 +3,7 @@ "name": "Wiffi", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wiffi", - "requirements": ["wiffi==1.0.0"], + "requirements": ["wiffi==1.0.1"], "dependencies": [], "codeowners": [ "@mampfes" diff --git a/requirements_all.txt b/requirements_all.txt index 3499e300121..e0360afcc10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2207,7 +2207,7 @@ webexteamssdk==1.1.1 websocket-client==0.54.0 # homeassistant.components.wiffi -wiffi==1.0.0 +wiffi==1.0.1 # homeassistant.components.wirelesstag wirelesstagpy==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d39a6bdb6ad..3c2c8d7803b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ wakeonlan==1.1.6 watchdog==0.8.3 # homeassistant.components.wiffi -wiffi==1.0.0 +wiffi==1.0.1 # homeassistant.components.withings withings-api==2.1.6 From caca76208880834164f3ff4d8755ddd961ad88c3 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 5 Aug 2020 13:38:29 +0100 Subject: [PATCH 311/362] OVO Energy Integration (#36104) Co-authored-by: Franck Nijhof --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/ovo_energy/__init__.py | 148 +++++++++++++ .../components/ovo_energy/config_flow.py | 66 ++++++ homeassistant/components/ovo_energy/const.py | 7 + .../components/ovo_energy/manifest.json | 9 + homeassistant/components/ovo_energy/sensor.py | 207 ++++++++++++++++++ .../components/ovo_energy/strings.json | 18 ++ .../ovo_energy/translations/en.json | 18 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ovo_energy/__init__.py | 1 + .../components/ovo_energy/test_config_flow.py | 87 ++++++++ 14 files changed, 572 insertions(+) create mode 100644 homeassistant/components/ovo_energy/__init__.py create mode 100644 homeassistant/components/ovo_energy/config_flow.py create mode 100644 homeassistant/components/ovo_energy/const.py create mode 100644 homeassistant/components/ovo_energy/manifest.json create mode 100644 homeassistant/components/ovo_energy/sensor.py create mode 100644 homeassistant/components/ovo_energy/strings.json create mode 100644 homeassistant/components/ovo_energy/translations/en.json create mode 100644 tests/components/ovo_energy/__init__.py create mode 100644 tests/components/ovo_energy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0b72c81a5dd..bcd3b80b812 100644 --- a/.coveragerc +++ b/.coveragerc @@ -624,6 +624,9 @@ omit = homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py + homeassistant/components/ovo_energy/__init__.py + homeassistant/components/ovo_energy/const.py + homeassistant/components/ovo_energy/sensor.py homeassistant/components/panasonic_bluray/media_player.py homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 1d4d38fa4e1..3c4ac8dd0dd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -302,6 +302,7 @@ homeassistant/components/openweathermap/* @fabaff homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu +homeassistant/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare homeassistant/components/panasonic_viera/* @joogps homeassistant/components/panel_custom/* @home-assistant/frontend diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py new file mode 100644 index 00000000000..3aff51fa044 --- /dev/null +++ b/homeassistant/components/ovo_energy/__init__.py @@ -0,0 +1,148 @@ +"""Support for OVO Energy.""" +from datetime import datetime, timedelta +import logging +from typing import Any, Dict + +import aiohttp +import async_timeout +from ovoenergy import OVODailyUsage +from ovoenergy.ovoenergy import OVOEnergy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the OVO Energy components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up OVO Energy from a config entry.""" + + client = OVOEnergy() + + try: + await client.authenticate(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise ConfigEntryNotReady from exception + + async def async_update_data() -> OVODailyUsage: + """Fetch data from OVO Energy.""" + now = datetime.utcnow() + async with async_timeout.timeout(10): + return await client.get_daily_usage(now.strftime("%Y-%m")) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="sensor", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=300), + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_COORDINATOR: coordinator, + } + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + # Setup components + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload OVO Energy config entry.""" + # Unload sensors + await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + del hass.data[DOMAIN][entry.entry_id] + + return True + + +class OVOEnergyEntity(Entity): + """Defines a base OVO Energy entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + client: OVOEnergy, + key: str, + name: str, + icon: str, + ) -> None: + """Initialize the OVO Energy entity.""" + self._coordinator = coordinator + self._client = client + self._key = key + self._name = name + self._icon = icon + self._available = True + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return self._key + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._coordinator.last_update_success and self._available + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_update(self) -> None: + """Update OVO Energy entity.""" + await self._coordinator.async_request_refresh() + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + +class OVOEnergyDeviceEntity(OVOEnergyEntity): + """Defines a OVO Energy device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this OVO Energy instance.""" + return { + "identifiers": {(DOMAIN, self._client.account_id)}, + "manufacturer": "OVO Energy", + "name": self._client.account_id, + "entry_type": "service", + } diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py new file mode 100644 index 00000000000..e4d33865f57 --- /dev/null +++ b/homeassistant/components/ovo_energy/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow to configure the OVO Energy integration.""" +import logging + +import aiohttp +from ovoenergy.ovoenergy import OVOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_ACCOUNT_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class OVOEnergyFlowHandler(ConfigFlow): + """Handle a OVO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize OVO Energy flow.""" + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form() + + errors = {} + + client = OVOEnergy() + + try: + if ( + await client.authenticate( + user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD) + ) + is not True + ): + errors["base"] = "authorization_error" + return await self._show_setup_form(errors) + except aiohttp.ClientError: + errors["base"] = "connection_error" + return await self._show_setup_form(errors) + + return self.async_create_entry( + title=client.account_id, + data={ + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_ACCOUNT_ID: client.account_id, + }, + ) diff --git a/homeassistant/components/ovo_energy/const.py b/homeassistant/components/ovo_energy/const.py new file mode 100644 index 00000000000..e836bb2ca8a --- /dev/null +++ b/homeassistant/components/ovo_energy/const.py @@ -0,0 +1,7 @@ +"""Constants for the OVO Energy integration.""" +DOMAIN = "ovo_energy" + +DATA_CLIENT = "ovo_client" +DATA_COORDINATOR = "coordinator" + +CONF_ACCOUNT_ID = "account_id" diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json new file mode 100644 index 00000000000..27a28863405 --- /dev/null +++ b/homeassistant/components/ovo_energy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ovo_energy", + "name": "OVO Energy", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ovo_energy", + "requirements": ["ovoenergy==1.1.6"], + "dependencies": [], + "codeowners": ["@timmo001"] +} diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py new file mode 100644 index 00000000000..5fe1bb056e7 --- /dev/null +++ b/homeassistant/components/ovo_energy/sensor.py @@ -0,0 +1,207 @@ +"""Support for OVO Energy sensors.""" +from datetime import timedelta +import logging + +from ovoenergy import OVODailyUsage +from ovoenergy.ovoenergy import OVOEnergy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import OVOEnergyDeviceEntity +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up OVO Energy sensor based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + client: OVOEnergy = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + + currency = coordinator.data.electricity[ + len(coordinator.data.electricity) - 1 + ].cost.currency_unit + + async_add_entities( + [ + OVOEnergyLastElectricityReading(coordinator, client), + OVOEnergyLastGasReading(coordinator, client), + OVOEnergyLastElectricityCost(coordinator, client, currency), + OVOEnergyLastGasCost(coordinator, client, currency), + ], + True, + ) + + +class OVOEnergySensor(OVOEnergyDeviceEntity): + """Defines a OVO Energy sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + client: OVOEnergy, + key: str, + name: str, + icon: str, + unit_of_measurement: str = "", + ) -> None: + """Initialize OVO Energy sensor.""" + self._unit_of_measurement = unit_of_measurement + + super().__init__(coordinator, client, key, name, icon) + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class OVOEnergyLastElectricityReading(OVOEnergySensor): + """Defines a OVO Energy last reading sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy): + """Initialize OVO Energy sensor.""" + + super().__init__( + coordinator, + client, + f"{client.account_id}_last_electricity_reading", + "OVO Last Electricity Reading", + "mdi:flash", + "kWh", + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return usage.electricity[-1].consumption + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return { + "start_time": usage.electricity[-1].interval.start, + "end_time": usage.electricity[-1].interval.end, + } + + +class OVOEnergyLastGasReading(OVOEnergySensor): + """Defines a OVO Energy last reading sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy): + """Initialize OVO Energy sensor.""" + + super().__init__( + coordinator, + client, + f"{DOMAIN}_{client.account_id}_last_gas_reading", + "OVO Last Gas Reading", + "mdi:gas-cylinder", + "kWh", + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return usage.gas[-1].consumption + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return { + "start_time": usage.gas[-1].interval.start, + "end_time": usage.gas[-1].interval.end, + } + + +class OVOEnergyLastElectricityCost(OVOEnergySensor): + """Defines a OVO Energy last cost sensor.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str + ): + """Initialize OVO Energy sensor.""" + super().__init__( + coordinator, + client, + f"{DOMAIN}_{client.account_id}_last_electricity_cost", + "OVO Last Electricity Cost", + "mdi:cash-multiple", + currency, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return usage.electricity[-1].cost.amount + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return { + "start_time": usage.electricity[-1].interval.start, + "end_time": usage.electricity[-1].interval.end, + } + + +class OVOEnergyLastGasCost(OVOEnergySensor): + """Defines a OVO Energy last cost sensor.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str + ): + """Initialize OVO Energy sensor.""" + super().__init__( + coordinator, + client, + f"{DOMAIN}_{client.account_id}_last_gas_cost", + "OVO Last Gas Cost", + "mdi:cash-multiple", + currency, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return usage.gas[-1].cost.amount + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return { + "start_time": usage.gas[-1].interval.start, + "end_time": usage.gas[-1].interval.end, + } diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json new file mode 100644 index 00000000000..a98b0223644 --- /dev/null +++ b/homeassistant/components/ovo_energy/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "Could not connect to OVO Energy." + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy" + } + } + } +} diff --git a/homeassistant/components/ovo_energy/translations/en.json b/homeassistant/components/ovo_energy/translations/en.json new file mode 100644 index 00000000000..afe1bb6e301 --- /dev/null +++ b/homeassistant/components/ovo_energy/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "Could not connect to OVO Energy." + }, + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d1f31841a30..7aa9eac6a86 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -122,6 +122,7 @@ FLOWS = [ "onvif", "opentherm_gw", "openuv", + "ovo_energy", "owntracks", "ozw", "panasonic_viera", diff --git a/requirements_all.txt b/requirements_all.txt index e0360afcc10..fb9f631e90a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,6 +1028,9 @@ oru==0.1.11 # homeassistant.components.orvibo orvibo==1.1.1 +# homeassistant.components.ovo_energy +ovoenergy==1.1.6 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c2c8d7803b..0bb523993b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,6 +472,9 @@ onvif-zeep-async==0.4.0 # homeassistant.components.openerz openerz-api==0.1.0 +# homeassistant.components.ovo_energy +ovoenergy==1.1.6 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.0 diff --git a/tests/components/ovo_energy/__init__.py b/tests/components/ovo_energy/__init__.py new file mode 100644 index 00000000000..ea9402fcb0d --- /dev/null +++ b/tests/components/ovo_energy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OVO Energy integration.""" diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py new file mode 100644 index 00000000000..73b2610cc7a --- /dev/null +++ b/tests/components/ovo_energy/test_config_flow.py @@ -0,0 +1,87 @@ +"""Test the OVO Energy config flow.""" +import aiohttp + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.ovo_energy.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch + +FIXTURE_USER_INPUT = {CONF_USERNAME: "example@example.com", CONF_PASSWORD: "something"} + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_full_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result2["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD] From c291d4aa7dcd2ce7dc0b95ce03068140ac9075d4 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 5 Aug 2020 14:58:19 +0200 Subject: [PATCH 312/362] Intelligent timeout handler for setup/bootstrap (#38329) Co-authored-by: Paulus Schoutsen Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 42 +- homeassistant/components/recorder/__init__.py | 4 +- homeassistant/components/recorder/const.py | 1 + .../components/recorder/migration.py | 16 +- homeassistant/core.py | 34 +- homeassistant/helpers/entity_platform.py | 3 +- homeassistant/requirements.py | 18 +- homeassistant/setup.py | 19 +- homeassistant/util/timeout.py | 508 ++++++++++++++++++ .../alarm_control_panel/test_device_action.py | 4 + .../binary_sensor/test_device_condition.py | 1 + .../binary_sensor/test_device_trigger.py | 1 + tests/components/cover/test_device_action.py | 10 + tests/components/dsmr/test_sensor.py | 1 + tests/components/flux/test_switch.py | 20 + tests/components/lock/test_device_action.py | 3 + tests/helpers/test_entity_platform.py | 4 +- tests/test_core.py | 4 +- tests/test_requirements.py | 21 - tests/test_setup.py | 8 +- tests/util/test_timeout.py | 268 +++++++++ 21 files changed, 901 insertions(+), 89 deletions(-) create mode 100644 homeassistant/util/timeout.py create mode 100644 tests/util/test_timeout.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7d20ca0ce90..4cf95d68f05 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -9,7 +9,6 @@ import sys from time import monotonic from typing import TYPE_CHECKING, Any, Dict, Optional, Set -from async_timeout import timeout import voluptuous as vol import yarl @@ -44,6 +43,11 @@ DATA_LOGGING = "logging" LOG_SLOW_STARTUP_INTERVAL = 60 +STAGE_1_TIMEOUT = 120 +STAGE_2_TIMEOUT = 300 +WRAP_UP_TIMEOUT = 300 +COOLDOWN_TIME = 60 + DEBUGGER_INTEGRATIONS = {"debugpy", "ptvsd"} CORE_INTEGRATIONS = ("homeassistant", "persistent_notification") LOGGING_INTEGRATIONS = { @@ -136,7 +140,7 @@ async def async_setup_hass( hass.async_track_tasks() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) with contextlib.suppress(asyncio.TimeoutError): - async with timeout(10): + async with hass.timeout.async_timeout(10): await hass.async_block_till_done() safe_mode = True @@ -496,24 +500,42 @@ async def _async_set_up_integrations( stage_2_domains = domains_to_setup - logging_domains - debuggers - stage_1_domains # Kick off loading the registries. They don't need to be awaited. - asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), - hass.helpers.area_registry.async_get_registry(), - ) + asyncio.create_task(hass.helpers.device_registry.async_get_registry()) + asyncio.create_task(hass.helpers.entity_registry.async_get_registry()) + asyncio.create_task(hass.helpers.area_registry.async_get_registry()) # Start setup if stage_1_domains: _LOGGER.info("Setting up stage 1: %s", stage_1_domains) - await async_setup_multi_components(hass, stage_1_domains, config, setup_started) + try: + async with hass.timeout.async_timeout( + STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME + ): + await async_setup_multi_components( + hass, stage_1_domains, config, setup_started + ) + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for stage 1 - moving forward") # Enables after dependencies async_set_domains_to_be_loaded(hass, stage_1_domains | stage_2_domains) if stage_2_domains: _LOGGER.info("Setting up stage 2: %s", stage_2_domains) - await async_setup_multi_components(hass, stage_2_domains, config, setup_started) + try: + async with hass.timeout.async_timeout( + STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME + ): + await async_setup_multi_components( + hass, stage_2_domains, config, setup_started + ) + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for stage 2 - moving forward") # Wrap up startup _LOGGER.debug("Waiting for startup to wrap up") - await hass.async_block_till_done() + try: + async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): + await hass.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning("Setup timed out for bootstrap - moving forward") diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 5ac4d226082..d0c18256377 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,14 +35,12 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from . import migration, purge -from .const import DATA_INSTANCE, SQLITE_URL_PREFIX +from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX from .models import Base, Events, RecorderRuns, States from .util import session_scope, validate_or_move_away_sqlite_database _LOGGER = logging.getLogger(__name__) -DOMAIN = "recorder" - SERVICE_PURGE = "purge" ATTR_KEEP_DAYS = "keep_days" diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index fb699d13fb3..b2ffc91fdb4 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -2,3 +2,4 @@ DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" +DOMAIN = "recorder" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d8b508ba513..e88852e4a5a 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,22 +1,19 @@ """Schema migration helpers.""" import logging -import os from sqlalchemy import Table, text from sqlalchemy.engine import reflection from sqlalchemy.exc import InternalError, OperationalError, SQLAlchemyError +from .const import DOMAIN from .models import SCHEMA_VERSION, Base, SchemaChanges from .util import session_scope _LOGGER = logging.getLogger(__name__) -PROGRESS_FILE = ".migration_progress" def migrate_schema(instance): """Check if the schema needs to be upgraded.""" - progress_path = instance.hass.config.path(PROGRESS_FILE) - with session_scope(session=instance.get_session()) as session: res = ( session.query(SchemaChanges) @@ -32,20 +29,13 @@ def migrate_schema(instance): ) if current_version == SCHEMA_VERSION: - # Clean up if old migration left file - if os.path.isfile(progress_path): - _LOGGER.warning("Found existing migration file, cleaning up") - os.remove(instance.hass.config.path(PROGRESS_FILE)) return - with open(progress_path, "w"): - pass - _LOGGER.warning( "Database is about to upgrade. Schema version: %s", current_version ) - try: + with instance.hass.timeout.freeze(DOMAIN): for version in range(current_version, SCHEMA_VERSION): new_version = version + 1 _LOGGER.info("Upgrading recorder db schema to version %s", new_version) @@ -53,8 +43,6 @@ def migrate_schema(instance): session.add(SchemaChanges(schema_version=new_version)) _LOGGER.info("Upgrade to version %s done", new_version) - finally: - os.remove(instance.hass.config.path(PROGRESS_FILE)) def _create_index(engine, table_name, index_name): diff --git a/homeassistant/core.py b/homeassistant/core.py index 1d05336e5c4..da40c17c411 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -35,7 +35,6 @@ from typing import ( ) import uuid -from async_timeout import timeout import attr import voluptuous as vol import yarl @@ -76,6 +75,7 @@ from homeassistant.util import location, network from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.thread import fix_threading_exception_logging +from homeassistant.util.timeout import TimeoutManager from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem # Typing imports that create a circular dependency @@ -184,10 +184,12 @@ class HomeAssistant: self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. self.data: dict = {} - self.state = CoreState.not_running - self.exit_code = 0 + self.state: CoreState = CoreState.not_running + self.exit_code: int = 0 # If not None, use to signal end-of-loop self._stopped: Optional[asyncio.Event] = None + # Timeout handler for Core/Helper namespace + self.timeout: TimeoutManager = TimeoutManager() @property def is_running(self) -> bool: @@ -255,7 +257,7 @@ class HomeAssistant: try: # Only block for EVENT_HOMEASSISTANT_START listener self.async_stop_track_tasks() - async with timeout(TIMEOUT_EVENT_START): + async with self.timeout.async_timeout(TIMEOUT_EVENT_START): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( @@ -460,17 +462,35 @@ class HomeAssistant: self.state = CoreState.stopping self.async_track_tasks() self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(120): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 1 to complete, the shutdown will continue" + ) # stage 2 self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(60): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 2 to complete, the shutdown will continue" + ) # stage 3 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - await self.async_block_till_done() + try: + async with self.timeout.async_timeout(30): + await self.async_block_till_done() + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown stage 3 to complete, the shutdown will continue" + ) # Python 3.9+ and backported in runner.py await self.loop.shutdown_default_executor() # type: ignore diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index fb542f660d1..7a581dbd19e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -177,7 +177,8 @@ class EntityPlatform: try: task = async_create_setup_task() - await asyncio.wait_for(asyncio.shield(task), SLOW_SETUP_MAX_WAIT) + async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, self.domain): + await asyncio.shield(task) # Block till all entities are done if self._tasks: diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 0b4560c8ac3..303f6219cae 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -2,7 +2,6 @@ import asyncio import logging import os -from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Set, Union, cast from homeassistant.core import HomeAssistant @@ -14,7 +13,6 @@ DATA_PIP_LOCK = "pip_lock" DATA_PKG_CACHE = "pkg_cache" DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" CONSTRAINT_FILE = "package_constraints.txt" -PROGRESS_FILE = ".pip_progress" _LOGGER = logging.getLogger(__name__) DISCOVERY_INTEGRATIONS: Dict[str, Iterable[str]] = { "ssdp": ("ssdp",), @@ -124,22 +122,16 @@ async def async_process_requirements( if pkg_util.is_installed(req): continue - ret = await hass.async_add_executor_job(_install, hass, req, kwargs) + def _install(req: str, kwargs: Dict) -> bool: + """Install requirement.""" + return pkg_util.install_package(req, **kwargs) + + ret = await hass.async_add_executor_job(_install, req, kwargs) if not ret: raise RequirementsNotFound(name, [req]) -def _install(hass: HomeAssistant, req: str, kwargs: Dict) -> bool: - """Install requirement.""" - progress_path = Path(hass.config.path(PROGRESS_FILE)) - progress_path.touch() - try: - return pkg_util.install_package(req, **kwargs) - finally: - progress_path.unlink() - - def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" is_docker = pkg_util.is_docker_env() diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 3395baa3e86..578cd33b097 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -22,11 +22,7 @@ DATA_SETUP = "setup_tasks" DATA_DEPS_REQS = "deps_reqs_processed" SLOW_SETUP_WARNING = 10 - -# Since its possible for databases to be -# upwards of 36GiB (or larger) in the wild -# we wait up to 3 hours for startup -SLOW_SETUP_MAX_WAIT = 10800 +SLOW_SETUP_MAX_WAIT = 300 @core.callback @@ -89,7 +85,8 @@ async def _async_process_dependencies( return True _LOGGER.debug("Dependency %s will wait for %s", integration.domain, list(tasks)) - results = await asyncio.gather(*tasks.values()) + async with hass.timeout.async_freeze(integration.domain): + results = await asyncio.gather(*tasks.values()) failed = [ domain @@ -190,7 +187,8 @@ async def _async_setup_component( hass.data[DATA_SETUP_STARTED].pop(domain) return False - result = await asyncio.wait_for(task, SLOW_SETUP_MAX_WAIT) + async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, domain): + result = await task except asyncio.TimeoutError: _LOGGER.error( "Setup of %s is taking longer than %s seconds." @@ -319,9 +317,10 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not set up all dependencies.") if not hass.config.skip_pip and integration.requirements: - await requirements.async_get_integration_with_requirements( - hass, integration.domain - ) + async with hass.timeout.async_freeze(integration.domain): + await requirements.async_get_integration_with_requirements( + hass, integration.domain + ) processed.add(integration.domain) diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py new file mode 100644 index 00000000000..908d36a41bb --- /dev/null +++ b/homeassistant/util/timeout.py @@ -0,0 +1,508 @@ +"""Advanced timeout handling. + +Set of helper classes to handle timeouts of tasks with advanced options +like zones and freezing of timeouts. +""" +from __future__ import annotations + +import asyncio +import enum +import logging +from types import TracebackType +from typing import Any, Dict, List, Optional, Type, Union + +from .async_ import run_callback_threadsafe + +ZONE_GLOBAL = "global" + +_LOGGER = logging.getLogger(__name__) + + +class _State(str, enum.Enum): + """States of a task.""" + + INIT = "INIT" + ACTIVE = "ACTIVE" + TIMEOUT = "TIMEOUT" + EXIT = "EXIT" + + +class _GlobalFreezeContext: + """Context manager that freezes the global timeout.""" + + def __init__(self, manager: TimeoutManager) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._manager: TimeoutManager = manager + + async def __aenter__(self) -> _GlobalFreezeContext: + self._enter() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._exit() + return None + + def __enter__(self) -> _GlobalFreezeContext: + self._loop.call_soon_threadsafe(self._enter) + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._loop.call_soon_threadsafe(self._exit) + return True + + def _enter(self) -> None: + """Run freeze.""" + if not self._manager.freezes_done: + return + + # Global reset + for task in self._manager.global_tasks: + task.pause() + + # Zones reset + for zone in self._manager.zones.values(): + if not zone.freezes_done: + continue + zone.pause() + + self._manager.global_freezes.append(self) + + def _exit(self) -> None: + """Finish freeze.""" + self._manager.global_freezes.remove(self) + if not self._manager.freezes_done: + return + + # Global reset + for task in self._manager.global_tasks: + task.reset() + + # Zones reset + for zone in self._manager.zones.values(): + if not zone.freezes_done: + continue + zone.reset() + + +class _ZoneFreezeContext: + """Context manager that freezes a zone timeout.""" + + def __init__(self, zone: _ZoneTimeoutManager) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zone: _ZoneTimeoutManager = zone + + async def __aenter__(self) -> _ZoneFreezeContext: + self._enter() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._exit() + return None + + def __enter__(self) -> _ZoneFreezeContext: + self._loop.call_soon_threadsafe(self._enter) + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._loop.call_soon_threadsafe(self._exit) + return True + + def _enter(self) -> None: + """Run freeze.""" + if self._zone.freezes_done: + self._zone.pause() + self._zone.enter_freeze(self) + + def _exit(self) -> None: + """Finish freeze.""" + self._zone.exit_freeze(self) + if not self._zone.freezes_done: + return + self._zone.reset() + + +class _GlobalTaskContext: + """Context manager that tracks a global task.""" + + def __init__( + self, + manager: TimeoutManager, + task: asyncio.Task[Any], + timeout: float, + cool_down: float, + ) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._manager: TimeoutManager = manager + self._task: asyncio.Task[Any] = task + self._time_left: float = timeout + self._expiration_time: Optional[float] = None + self._timeout_handler: Optional[asyncio.Handle] = None + self._wait_zone: asyncio.Event = asyncio.Event() + self._state: _State = _State.INIT + self._cool_down: float = cool_down + + async def __aenter__(self) -> _GlobalTaskContext: + self._manager.global_tasks.append(self) + self._start_timer() + self._state = _State.ACTIVE + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._stop_timer() + self._manager.global_tasks.remove(self) + + # Timeout on exit + if exc_type is asyncio.CancelledError and self.state == _State.TIMEOUT: + raise asyncio.TimeoutError + + self._state = _State.EXIT + self._wait_zone.set() + return None + + @property + def state(self) -> _State: + """Return state of the Global task.""" + return self._state + + def zones_done_signal(self) -> None: + """Signal that all zones are done.""" + self._wait_zone.set() + + def _start_timer(self) -> None: + """Start timeout handler.""" + if self._timeout_handler: + return + + self._expiration_time = self._loop.time() + self._time_left + self._timeout_handler = self._loop.call_at( + self._expiration_time, self._on_timeout + ) + + def _stop_timer(self) -> None: + """Stop zone timer.""" + if self._timeout_handler is None: + return + + self._timeout_handler.cancel() + self._timeout_handler = None + # Calculate new timeout + assert self._expiration_time + self._time_left = self._expiration_time - self._loop.time() + + def _on_timeout(self) -> None: + """Process timeout.""" + self._state = _State.TIMEOUT + self._timeout_handler = None + + # Reset timer if zones are running + if not self._manager.zones_done: + asyncio.create_task(self._on_wait()) + else: + self._cancel_task() + + def _cancel_task(self) -> None: + """Cancel own task.""" + if self._task.done(): + return + self._task.cancel() + + def pause(self) -> None: + """Pause timers while it freeze.""" + self._stop_timer() + + def reset(self) -> None: + """Reset timer after freeze.""" + self._start_timer() + + async def _on_wait(self) -> None: + """Wait until zones are done.""" + await self._wait_zone.wait() + await asyncio.sleep(self._cool_down) # Allow context switch + if not self.state == _State.TIMEOUT: + return + self._cancel_task() + + +class _ZoneTaskContext: + """Context manager that tracks an active task for a zone.""" + + def __init__( + self, zone: _ZoneTimeoutManager, task: asyncio.Task[Any], timeout: float, + ) -> None: + """Initialize internal timeout context manager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zone: _ZoneTimeoutManager = zone + self._task: asyncio.Task[Any] = task + self._state: _State = _State.INIT + self._time_left: float = timeout + self._expiration_time: Optional[float] = None + self._timeout_handler: Optional[asyncio.Handle] = None + + @property + def state(self) -> _State: + """Return state of the Zone task.""" + return self._state + + async def __aenter__(self) -> _ZoneTaskContext: + self._zone.enter_task(self) + self._state = _State.ACTIVE + + # Zone is on freeze + if self._zone.freezes_done: + self._start_timer() + + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_val: BaseException, + exc_tb: TracebackType, + ) -> Optional[bool]: + self._zone.exit_task(self) + self._stop_timer() + + # Timeout on exit + if exc_type is asyncio.CancelledError and self.state == _State.TIMEOUT: + raise asyncio.TimeoutError + + self._state = _State.EXIT + return None + + def _start_timer(self) -> None: + """Start timeout handler.""" + if self._timeout_handler: + return + + self._expiration_time = self._loop.time() + self._time_left + self._timeout_handler = self._loop.call_at( + self._expiration_time, self._on_timeout + ) + + def _stop_timer(self) -> None: + """Stop zone timer.""" + if self._timeout_handler is None: + return + + self._timeout_handler.cancel() + self._timeout_handler = None + # Calculate new timeout + assert self._expiration_time + self._time_left = self._expiration_time - self._loop.time() + + def _on_timeout(self) -> None: + """Process timeout.""" + self._state = _State.TIMEOUT + self._timeout_handler = None + + # Timeout + if self._task.done(): + return + self._task.cancel() + + def pause(self) -> None: + """Pause timers while it freeze.""" + self._stop_timer() + + def reset(self) -> None: + """Reset timer after freeze.""" + self._start_timer() + + +class _ZoneTimeoutManager: + """Manage the timeouts for a zone.""" + + def __init__(self, manager: TimeoutManager, zone: str) -> None: + """Initialize internal timeout context manager.""" + self._manager: TimeoutManager = manager + self._zone: str = zone + self._tasks: List[_ZoneTaskContext] = [] + self._freezes: List[_ZoneFreezeContext] = [] + + @property + def name(self) -> str: + """Return Zone name.""" + return self._zone + + @property + def active(self) -> bool: + """Return True if zone is active.""" + return len(self._tasks) > 0 or len(self._freezes) > 0 + + @property + def freezes_done(self) -> bool: + """Return True if all freeze are done.""" + return len(self._freezes) == 0 and self._manager.freezes_done + + def enter_task(self, task: _ZoneTaskContext) -> None: + """Start into new Task.""" + self._tasks.append(task) + + def exit_task(self, task: _ZoneTaskContext) -> None: + """Exit a running Task.""" + self._tasks.remove(task) + + # On latest listener + if not self.active: + self._manager.drop_zone(self.name) + + def enter_freeze(self, freeze: _ZoneFreezeContext) -> None: + """Start into new freeze.""" + self._freezes.append(freeze) + + def exit_freeze(self, freeze: _ZoneFreezeContext) -> None: + """Exit a running Freeze.""" + self._freezes.remove(freeze) + + # On latest listener + if not self.active: + self._manager.drop_zone(self.name) + + def pause(self) -> None: + """Stop timers while it freeze.""" + if not self.active: + return + + # Forward pause + for task in self._tasks: + task.pause() + + def reset(self) -> None: + """Reset timer after freeze.""" + if not self.active: + return + + # Forward reset + for task in self._tasks: + task.reset() + + +class TimeoutManager: + """Class to manage timeouts over different zones. + + Manages both global and zone based timeouts. + """ + + def __init__(self) -> None: + """Initialize TimeoutManager.""" + self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + self._zones: Dict[str, _ZoneTimeoutManager] = {} + self._globals: List[_GlobalTaskContext] = [] + self._freezes: List[_GlobalFreezeContext] = [] + + @property + def zones_done(self) -> bool: + """Return True if all zones are finished.""" + return not bool(self._zones) + + @property + def freezes_done(self) -> bool: + """Return True if all freezes are finished.""" + return not self._freezes + + @property + def zones(self) -> Dict[str, _ZoneTimeoutManager]: + """Return all Zones.""" + return self._zones + + @property + def global_tasks(self) -> List[_GlobalTaskContext]: + """Return all global Tasks.""" + return self._globals + + @property + def global_freezes(self) -> List[_GlobalFreezeContext]: + """Return all global Freezes.""" + return self._freezes + + def drop_zone(self, zone_name: str) -> None: + """Drop a zone out of scope.""" + self._zones.pop(zone_name, None) + if self._zones: + return + + # Signal Global task, all zones are done + for task in self._globals: + task.zones_done_signal() + + def async_timeout( + self, timeout: float, zone_name: str = ZONE_GLOBAL, cool_down: float = 0 + ) -> Union[_ZoneTaskContext, _GlobalTaskContext]: + """Timeout based on a zone. + + For using as Async Context Manager. + """ + current_task: Optional[asyncio.Task[Any]] = asyncio.current_task() + assert current_task + + # Global Zone + if zone_name == ZONE_GLOBAL: + task = _GlobalTaskContext(self, current_task, timeout, cool_down) + return task + + # Zone Handling + if zone_name in self.zones: + zone: _ZoneTimeoutManager = self.zones[zone_name] + else: + self.zones[zone_name] = zone = _ZoneTimeoutManager(self, zone_name) + + # Create Task + return _ZoneTaskContext(zone, current_task, timeout) + + def async_freeze( + self, zone_name: str = ZONE_GLOBAL + ) -> Union[_ZoneFreezeContext, _GlobalFreezeContext]: + """Freeze all timer until job is done. + + For using as Async Context Manager. + """ + # Global Freeze + if zone_name == ZONE_GLOBAL: + return _GlobalFreezeContext(self) + + # Zone Freeze + if zone_name in self.zones: + zone: _ZoneTimeoutManager = self.zones[zone_name] + else: + self.zones[zone_name] = zone = _ZoneTimeoutManager(self, zone_name) + + return _ZoneFreezeContext(zone) + + def freeze( + self, zone_name: str = ZONE_GLOBAL + ) -> Union[_ZoneFreezeContext, _GlobalFreezeContext]: + """Freeze all timer until job is done. + + For using as Context Manager. + """ + return run_callback_threadsafe( + self._loop, self.async_freeze, zone_name + ).result() diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 72754c3c96f..74c98da3189 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -133,6 +133,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): device_id=device_entry.id, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "arm_away": {"extra_fields": []}, @@ -170,6 +171,7 @@ async def test_get_action_capabilities_arm_code(hass, device_reg, entity_reg): device_id=device_entry.id, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "arm_away": { @@ -267,6 +269,8 @@ async def test_action(hass): }, ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + assert ( hass.states.get("alarm_control_panel.alarm_no_arm_code").state == STATE_UNKNOWN ) diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index fef12c88f49..9fd50277d23 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -60,6 +60,7 @@ async def test_get_conditions(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_conditions = [ { diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 92fbce27bc1..cea0103acdb 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -60,6 +60,7 @@ async def test_get_triggers(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_triggers = [ { diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index b38521d4051..d302353582c 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -46,6 +46,7 @@ async def test_get_actions(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -87,6 +88,7 @@ async def test_get_actions_tilt(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -140,6 +142,7 @@ async def test_get_actions_set_pos(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -169,6 +172,7 @@ async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): DOMAIN, "test", ent.unique_id, device_id=device_entry.id ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_actions = [ { @@ -217,6 +221,7 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() actions = await async_get_device_automations(hass, "action", device_entry.id) assert len(actions) == 3 # open, close, stop @@ -244,6 +249,7 @@ async def test_get_action_capabilities_set_pos(hass, device_reg, entity_reg): ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "extra_fields": [ @@ -286,6 +292,7 @@ async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg ) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() expected_capabilities = { "extra_fields": [ @@ -352,6 +359,7 @@ async def test_action(hass): ] }, ) + await hass.async_block_till_done() open_calls = async_mock_service(hass, "cover", "open_cover") close_calls = async_mock_service(hass, "cover", "close_cover") @@ -408,6 +416,7 @@ async def test_action_tilt(hass): ] }, ) + await hass.async_block_till_done() open_calls = async_mock_service(hass, "cover", "open_cover_tilt") close_calls = async_mock_service(hass, "cover", "close_cover_tilt") @@ -468,6 +477,7 @@ async def test_action_set_position(hass): ] }, ) + await hass.async_block_till_done() cover_pos_calls = async_mock_service(hass, "cover", "set_cover_position") tilt_pos_calls = async_mock_service(hass, "cover", "set_cover_tilt_position") diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6e6cc8f55c6..ed660eb4f51 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -366,6 +366,7 @@ async def test_reconnect(hass, monkeypatch, mock_connection_factory): protocol.wait_closed = wait_closed await async_setup_component(hass, "sensor", {"sensor": config}) + await hass.async_block_till_done() assert connection_factory.call_count == 1 diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index cc194f4f14a..594b3aff2b2 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -104,6 +104,7 @@ async def test_valid_config_with_info(hass): } }, ) + await hass.async_block_till_done() async def test_valid_config_no_name(hass): @@ -114,6 +115,7 @@ async def test_valid_config_no_name(hass): "switch", {"switch": {"platform": "flux", "lights": ["light.desk", "light.lamp"]}}, ) + await hass.async_block_till_done() async def test_invalid_config_no_lights(hass): @@ -122,6 +124,7 @@ async def test_invalid_config_no_lights(hass): assert await async_setup_component( hass, "switch", {"switch": {"platform": "flux", "name": "flux"}} ) + await hass.async_block_till_done() async def test_flux_when_switch_is_off(hass, legacy_patchable_time): @@ -168,6 +171,7 @@ async def test_flux_when_switch_is_off(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() async_fire_time_changed(hass, test_time) await hass.async_block_till_done() @@ -218,6 +222,7 @@ async def test_flux_before_sunrise(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -271,6 +276,7 @@ async def test_flux_before_sunrise_known_location(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -325,6 +331,7 @@ async def test_flux_after_sunrise_before_sunset(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -380,6 +387,7 @@ async def test_flux_after_sunset_before_stop(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -434,6 +442,7 @@ async def test_flux_after_stop_before_sunrise(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -490,6 +499,7 @@ async def test_flux_with_custom_start_stop_times(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -547,6 +557,7 @@ async def test_flux_before_sunrise_stop_next_day(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -608,6 +619,7 @@ async def test_flux_after_sunrise_before_sunset_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -669,6 +681,7 @@ async def test_flux_after_sunset_before_midnight_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -729,6 +742,7 @@ async def test_flux_after_sunset_after_midnight_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -789,6 +803,7 @@ async def test_flux_after_stop_before_sunrise_stop_next_day( } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -846,6 +861,7 @@ async def test_flux_with_custom_colortemps(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -902,6 +918,7 @@ async def test_flux_with_custom_brightness(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -974,6 +991,7 @@ async def test_flux_with_multiple_lights(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -1033,6 +1051,7 @@ async def test_flux_with_mired(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) common.turn_on(hass, "switch.flux") await hass.async_block_till_done() @@ -1085,6 +1104,7 @@ async def test_flux_with_rgb(hass, legacy_patchable_time): } }, ) + await hass.async_block_till_done() turn_on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) await common.async_turn_on(hass, "switch.flux") await hass.async_block_till_done() diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 0fc98d9460e..dbf390df57b 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -34,6 +34,7 @@ async def test_get_actions_support_open(hass, device_reg, entity_reg): platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -77,6 +78,7 @@ async def test_get_actions_not_support_open(hass, device_reg, entity_reg): platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) @@ -146,6 +148,7 @@ async def test_action(hass): ] }, ) + await hass.async_block_till_done() lock_calls = async_mock_service(hass, "lock", "lock") unlock_calls = async_mock_service(hass, "lock", "unlock") diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 527a89843dd..5912eb42b03 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -188,8 +188,8 @@ async def test_platform_warn_slow_setup(hass): assert mock_call.called # mock_calls[0] is the warning message for component setup - # mock_calls[6] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[6][1][:2] + # mock_calls[4] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[4][1][:2] assert timeout == entity_platform.SLOW_SETUP_WARNING assert logger_method == _LOGGER.warning diff --git a/tests/test_core.py b/tests/test_core.py index 167eda3f6cb..77baa502687 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1140,8 +1140,8 @@ async def test_start_taking_too_long(loop, caplog): caplog.set_level(logging.WARNING) try: - with patch( - "homeassistant.core.timeout", side_effect=asyncio.TimeoutError + with patch.object( + hass, "async_block_till_done", side_effect=asyncio.TimeoutError ), patch("homeassistant.core._async_create_timer") as mock_timer: await hass.async_start() diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 20202f91e89..fcc2d571331 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -1,15 +1,12 @@ """Test requirements module.""" import os -from pathlib import Path import pytest from homeassistant import loader, setup from homeassistant.requirements import ( CONSTRAINT_FILE, - PROGRESS_FILE, RequirementsNotFound, - _install, async_get_integration_with_requirements, async_process_requirements, ) @@ -190,24 +187,6 @@ async def test_install_on_docker(hass): ) -async def test_progress_lock(hass): - """Test an install attempt on an existing package.""" - progress_path = Path(hass.config.path(PROGRESS_FILE)) - kwargs = {"hello": "world"} - - def assert_env(req, **passed_kwargs): - """Assert the env.""" - assert progress_path.exists() - assert req == "hello" - assert passed_kwargs == kwargs - return True - - with patch("homeassistant.util.package.install_package", side_effect=assert_env): - _install(hass, "hello", kwargs) - - assert not progress_path.exists() - - async def test_discovery_requirements_ssdp(hass): """Test that we load discovery requirements.""" hass.config.skip_pip = False diff --git a/tests/test_setup.py b/tests/test_setup.py index cb63f8fa865..abd9cecd9ac 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -488,15 +488,12 @@ async def test_component_warn_slow_setup(hass): assert result assert mock_call.called - assert len(mock_call.mock_calls) == 5 + assert len(mock_call.mock_calls) == 3 timeout, logger_method = mock_call.mock_calls[0][1][:2] assert timeout == setup.SLOW_SETUP_WARNING assert logger_method == setup._LOGGER.warning - timeout, function = mock_call.mock_calls[1][1][:2] - assert timeout == setup.SLOW_SETUP_MAX_WAIT - assert mock_call().cancel.called @@ -508,8 +505,7 @@ async def test_platform_no_warn_slow(hass): with patch.object(hass.loop, "call_later") as mock_call: result = await setup.async_setup_component(hass, "test_component1", {}) assert result - timeout, function = mock_call.mock_calls[0][1][:2] - assert timeout == setup.SLOW_SETUP_MAX_WAIT + assert len(mock_call.mock_calls) == 0 async def test_platform_error_slow_setup(hass, caplog): diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py new file mode 100644 index 00000000000..edd8f4107a4 --- /dev/null +++ b/tests/util/test_timeout.py @@ -0,0 +1,268 @@ +"""Test Home Assistant timeout handler.""" +import asyncio +import time + +import pytest + +from homeassistant.util.timeout import TimeoutManager + + +async def test_simple_global_timeout(): + """Test a simple global timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + await asyncio.sleep(0.3) + + +async def test_simple_global_timeout_with_executor_job(hass): + """Test a simple global timeout with executor job.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + await hass.async_add_executor_job(lambda: time.sleep(0.2)) + + +async def test_simple_global_timeout_freeze(): + """Test a simple global timeout freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_inside_executor_job(hass): + """Test a simple zone timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + async with timeout.async_timeout(1.0): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_simple_global_timeout_freeze_inside_executor_job(hass): + """Test a simple global timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze(): + time.sleep(0.3) + + async with timeout.async_timeout(0.2): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job(hass): + """Test a simple global timeout freeze inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_different_order(hass): + """Test a simple global timeout freeze inside an executor job before timeout was set.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.4) + + async with timeout.async_timeout(0.1): + hass.async_add_executor_job(_some_sync_work) + async with timeout.async_timeout(0.2, zone_name="recorder"): + await asyncio.sleep(0.3) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_executor_job( + hass, +): + """Test a simple global timeout freeze other zone inside an executor job.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("not_recorder"): + time.sleep(0.3) + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + async with timeout.async_timeout(0.2, zone_name="not_recorder"): + await hass.async_add_executor_job(_some_sync_work) + + +async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job_second_job_outside_zone_context( + hass, +): + """Test a simple global timeout freeze inside an executor job with second job outside of zone context.""" + timeout = TimeoutManager() + + def _some_sync_work(): + with timeout.freeze("recorder"): + time.sleep(0.3) + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + async with timeout.async_timeout(0.2, zone_name="recorder"): + await hass.async_add_executor_job(_some_sync_work) + await hass.async_add_executor_job(lambda: time.sleep(0.2)) + + +async def test_simple_global_timeout_freeze_with_executor_job(hass): + """Test a simple global timeout freeze with executor job.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await hass.async_add_executor_job(lambda: time.sleep(0.3)) + + +async def test_simple_global_timeout_freeze_reset(): + """Test a simple global timeout freeze reset.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.2): + async with timeout.async_freeze(): + await asyncio.sleep(0.1) + await asyncio.sleep(0.2) + + +async def test_simple_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + + +async def test_multiple_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_timeout(0.5, "test"): + await asyncio.sleep(0.3) + + +async def test_different_zone_timeout(): + """Test a simple zone timeout.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_timeout(0.5, "other"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze(): + """Test a simple zone timeout freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_without_timeout(): + """Test a simple zone timeout freeze on a zone that does not have a timeout set.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_simple_zone_timeout_freeze_reset(): + """Test a simple zone timeout freeze reset.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.1) + await asyncio.sleep(0.2, "test") + + +async def test_mix_zone_timeout_freeze_and_global_freeze(): + """Test a mix zone timeout freeze and global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze("test"): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_mix_global_and_zone_timeout_freeze_(): + """Test a mix zone timeout freeze and global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze(): + async with timeout.async_freeze("test"): + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout_freeze(): + """Test a mix zone timeout global freeze.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.2, "test"): + async with timeout.async_freeze(): + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout(): + """Test a mix zone timeout global.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1): + try: + async with timeout.async_timeout(0.2, "test"): + await asyncio.sleep(0.4) + except asyncio.TimeoutError: + pass + + +async def test_mix_zone_timeout_trigger_global(): + """Test a mix zone timeout global with trigger it.""" + timeout = TimeoutManager() + + with pytest.raises(asyncio.TimeoutError): + async with timeout.async_timeout(0.1): + try: + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + except asyncio.TimeoutError: + pass + + await asyncio.sleep(0.3) + + +async def test_mix_zone_timeout_trigger_global_cool_down(): + """Test a mix zone timeout global with trigger it with cool_down.""" + timeout = TimeoutManager() + + async with timeout.async_timeout(0.1, cool_down=0.3): + try: + async with timeout.async_timeout(0.1, "test"): + await asyncio.sleep(0.3) + except asyncio.TimeoutError: + pass + + await asyncio.sleep(0.2) From 1ebc420c757eb0ba07aba8799ef21bb5c3a971fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 5 Aug 2020 14:59:33 +0200 Subject: [PATCH 313/362] Bump frontend to 20200805.0 (#38557) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 41b9ad9a591..c4d11297e9b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200803.0"], + "requirements": ["home-assistant-frontend==20200805.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c8f1e81cc2c..0339c779291 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 hass-nabucasa==0.35.0 -home-assistant-frontend==20200803.0 +home-assistant-frontend==20200805.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index fb9f631e90a..c144f8207fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -733,7 +733,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200803.0 +home-assistant-frontend==20200805.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bb523993b6..722fbe0bcc5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200803.0 +home-assistant-frontend==20200805.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From d0d0403664235bf4d418da58ce0e425cc0d7c5cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2020 06:50:56 -0700 Subject: [PATCH 314/362] Add zeroconf/homekit/ssdp discovery support for custom components (#38466) Co-authored-by: Paulus Schoutsen --- homeassistant/components/ssdp/__init__.py | 9 ++- homeassistant/components/zeroconf/__init__.py | 35 ++++++--- homeassistant/loader.py | 68 ++++++++++++++++ tests/components/ssdp/test_init.py | 41 +++++----- tests/test_loader.py | 77 +++++++++++++++++++ 5 files changed, 193 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 11e58020c4f..555d68cd5d4 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -8,8 +8,8 @@ from defusedxml import ElementTree from netdisco import ssdp, util from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.generated.ssdp import SSDP from homeassistant.helpers.event import async_track_time_interval +from homeassistant.loader import async_get_ssdp DOMAIN = "ssdp" SCAN_INTERVAL = timedelta(seconds=60) @@ -35,7 +35,7 @@ async def async_setup(hass, config): """Set up the SSDP integration.""" async def initialize(_): - scanner = Scanner(hass) + scanner = Scanner(hass, await async_get_ssdp(hass)) await scanner.async_scan(None) async_track_time_interval(hass, scanner.async_scan, SCAN_INTERVAL) @@ -47,10 +47,11 @@ async def async_setup(hass, config): class Scanner: """Class to manage SSDP scanning.""" - def __init__(self, hass): + def __init__(self, hass, integration_matchers): """Initialize class.""" self.hass = hass self.seen = set() + self._integration_matchers = integration_matchers self._description_cache = {} async def async_scan(self, _): @@ -121,7 +122,7 @@ class Scanner: info.update(await info_req) domains = set() - for domain, matchers in SSDP.items(): + for domain, matchers in self._integration_matchers.items(): for matcher in matchers: if all(info.get(k) == v for (k, v) in matcher.items()): domains.add(domain) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 4da6fd5ab80..71e2f67bad7 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -25,10 +25,10 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, __version__, ) -from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.singleton import singleton +from homeassistant.loader import async_get_homekit, async_get_zeroconf _LOGGER = logging.getLogger(__name__) @@ -197,8 +197,14 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, zeroconf_hass_start) + zeroconf_types = {} + homekit_models = {} + def service_update(zeroconf, service_type, name, state_change): """Service state changed.""" + nonlocal zeroconf_types + nonlocal homekit_models + if state_change != ServiceStateChange.Added: return @@ -219,7 +225,7 @@ def setup(hass, config): # If we can handle it as a HomeKit discovery, we do that here. if service_type == HOMEKIT_TYPE: - discovery_was_forwarded = handle_homekit(hass, info) + discovery_was_forwarded = handle_homekit(hass, homekit_models, info) # Continue on here as homekit_controller # still needs to get updates on devices # so it can see when the 'c#' field is updated. @@ -241,20 +247,25 @@ def setup(hass, config): # likely bad homekit data return - for domain in ZEROCONF[service_type]: + for domain in zeroconf_types[service_type]: hass.add_job( hass.config_entries.flow.async_init( domain, context={"source": DOMAIN}, data=info ) ) - types = list(ZEROCONF) - - if HOMEKIT_TYPE not in ZEROCONF: - types.append(HOMEKIT_TYPE) - - def zeroconf_hass_started(_event): + async def zeroconf_hass_started(_event): """Start the service browser.""" + nonlocal zeroconf_types + nonlocal homekit_models + + zeroconf_types = await async_get_zeroconf(hass) + homekit_models = await async_get_homekit(hass) + + types = list(zeroconf_types) + + if HOMEKIT_TYPE not in zeroconf_types: + types.append(HOMEKIT_TYPE) _LOGGER.debug("Starting Zeroconf browser") HaServiceBrowser(zeroconf, types, handlers=[service_update]) @@ -264,7 +275,7 @@ def setup(hass, config): return True -def handle_homekit(hass, info) -> bool: +def handle_homekit(hass, homekit_models, info) -> bool: """Handle a HomeKit discovery. Return if discovery was forwarded. @@ -280,7 +291,7 @@ def handle_homekit(hass, info) -> bool: if model is None: return False - for test_model in HOMEKIT: + for test_model in homekit_models: if ( model != test_model and not model.startswith(f"{test_model} ") @@ -290,7 +301,7 @@ def handle_homekit(hass, info) -> bool: hass.add_job( hass.config_entries.flow.async_init( - HOMEKIT[test_model], context={"source": "homekit"}, data=info + homekit_models[test_model], context={"source": "homekit"}, data=info ) ) return True diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 315165bf27f..b82f2c0109a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,6 +25,9 @@ from typing import ( cast, ) +from homeassistant.generated.ssdp import SSDP +from homeassistant.generated.zeroconf import HOMEKIT, ZEROCONF + # Typing imports that create a circular dependency if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -142,6 +145,56 @@ async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]: return flows +async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List]: + """Return cached list of zeroconf types.""" + zeroconf: Dict[str, List] = ZEROCONF.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.zeroconf: + continue + for typ in integration.zeroconf: + zeroconf.setdefault(typ, []) + if integration.domain not in zeroconf[typ]: + zeroconf[typ].append(integration.domain) + + return zeroconf + + +async def async_get_homekit(hass: "HomeAssistant") -> Dict[str, str]: + """Return cached list of homekit models.""" + + homekit: Dict[str, str] = HOMEKIT.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if ( + not integration.homekit + or "models" not in integration.homekit + or not integration.homekit["models"] + ): + continue + for model in integration.homekit["models"]: + homekit[model] = integration.domain + + return homekit + + +async def async_get_ssdp(hass: "HomeAssistant") -> Dict[str, List]: + """Return cached list of ssdp mappings.""" + + ssdp: Dict[str, List] = SSDP.copy() + + integrations = await async_get_custom_components(hass) + for integration in integrations.values(): + if not integration.ssdp: + continue + + ssdp[integration.domain] = integration.ssdp + + return ssdp + + class Integration: """An integration in Home Assistant.""" @@ -258,6 +311,21 @@ class Integration: """Return Integration Quality Scale.""" return cast(str, self.manifest.get("quality_scale")) + @property + def ssdp(self) -> Optional[list]: + """Return Integration SSDP entries.""" + return cast(List[dict], self.manifest.get("ssdp")) + + @property + def zeroconf(self) -> Optional[list]: + """Return Integration zeroconf entries.""" + return cast(List[str], self.manifest.get("zeroconf")) + + @property + def homekit(self) -> Optional[dict]: + """Return Integration homekit entries.""" + return cast(Dict[str, List], self.manifest.get("homekit")) + @property def is_built_in(self) -> bool: """Test if package is a built-in integration.""" diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index b6499af5601..6e36778b75d 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -6,18 +6,17 @@ import aiohttp import pytest from homeassistant.components import ssdp -from homeassistant.generated import ssdp as gn_ssdp from tests.common import mock_coro async def test_scan_match_st(hass): """Test matching based on ST.""" - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {"mock-domain": [{"st": "mock-st"}]}) with patch( "netdisco.ssdp.scan", return_value=[Mock(st="mock-st", location=None)] - ), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{"st": "mock-st"}]}), patch.object( + ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: await scanner.async_scan(None) @@ -42,12 +41,12 @@ async def test_scan_match_upnp_devicedesc(hass, aioclient_mock, key): """, ) - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {"mock-domain": [{key: "Paulus"}]}) with patch( "netdisco.ssdp.scan", return_value=[Mock(st="mock-st", location="http://1.1.1.1")], - ), patch.dict(gn_ssdp.SSDP, {"mock-domain": [{key: "Paulus"}]}), patch.object( + ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: await scanner.async_scan(None) @@ -69,13 +68,8 @@ async def test_scan_not_all_present(hass, aioclient_mock): """, ) - scanner = ssdp.Scanner(hass) - - with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1")], - ), patch.dict( - gn_ssdp.SSDP, + scanner = ssdp.Scanner( + hass, { "mock-domain": [ { @@ -84,6 +78,11 @@ async def test_scan_not_all_present(hass, aioclient_mock): } ] }, + ) + + with patch( + "netdisco.ssdp.scan", + return_value=[Mock(st="mock-st", location="http://1.1.1.1")], ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -105,13 +104,8 @@ async def test_scan_not_all_match(hass, aioclient_mock): """, ) - scanner = ssdp.Scanner(hass) - - with patch( - "netdisco.ssdp.scan", - return_value=[Mock(st="mock-st", location="http://1.1.1.1")], - ), patch.dict( - gn_ssdp.SSDP, + scanner = ssdp.Scanner( + hass, { "mock-domain": [ { @@ -120,6 +114,11 @@ async def test_scan_not_all_match(hass, aioclient_mock): } ] }, + ) + + with patch( + "netdisco.ssdp.scan", + return_value=[Mock(st="mock-st", location="http://1.1.1.1")], ), patch.object( hass.config_entries.flow, "async_init", return_value=mock_coro() ) as mock_init: @@ -132,7 +131,7 @@ async def test_scan_not_all_match(hass, aioclient_mock): async def test_scan_description_fetch_fail(hass, aioclient_mock, exc): """Test failing to fetch description.""" aioclient_mock.get("http://1.1.1.1", exc=exc) - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {}) with patch( "netdisco.ssdp.scan", @@ -149,7 +148,7 @@ async def test_scan_description_parse_fail(hass, aioclient_mock): INVALIDXML """, ) - scanner = ssdp.Scanner(hass) + scanner = ssdp.Scanner(hass, {}) with patch( "netdisco.ssdp.scan", diff --git a/tests/test_loader.py b/tests/test_loader.py index 20669588180..272b0453469 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -168,10 +168,36 @@ def test_integration_properties(hass): "domain": "hue", "dependencies": ["test-dep"], "requirements": ["test-req==1.0.0"], + "zeroconf": ["_hue._tcp.local."], + "homekit": {"models": ["BSB002"]}, + "ssdp": [ + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2012", + }, + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2015", + }, + {"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}, + ], }, ) assert integration.name == "Philips Hue" assert integration.domain == "hue" + assert integration.homekit == {"models": ["BSB002"]} + assert integration.zeroconf == ["_hue._tcp.local."] + assert integration.ssdp == [ + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2012", + }, + { + "manufacturer": "Royal Philips Electronics", + "modelName": "Philips hue bridge 2015", + }, + {"manufacturer": "Signify", "modelName": "Philips hue bridge 2015"}, + ] assert integration.dependencies == ["test-dep"] assert integration.requirements == ["test-req==1.0.0"] assert integration.is_built_in is True @@ -188,6 +214,9 @@ def test_integration_properties(hass): }, ) assert integration.is_built_in is False + assert integration.homekit is None + assert integration.zeroconf is None + assert integration.ssdp is None async def test_integrations_only_once(hass): @@ -217,6 +246,9 @@ def _get_test_integration(hass, name, config_flow): "config_flow": config_flow, "dependencies": [], "requirements": [], + "zeroconf": [f"_{name}._tcp.local."], + "homekit": {"models": [name]}, + "ssdp": [{"manufacturer": name, "modelName": name}], }, ) @@ -254,6 +286,51 @@ async def test_get_config_flows(hass): assert "test_1" not in flows +async def test_get_zeroconf(hass): + """Verify that custom components with zeroconf are found.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration(hass, "test_2", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + zeroconf = await loader.async_get_zeroconf(hass) + assert zeroconf["_test_1._tcp.local."] == ["test_1"] + assert zeroconf["_test_2._tcp.local."] == ["test_2"] + + +async def test_get_homekit(hass): + """Verify that custom components with homekit are found.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration(hass, "test_2", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + homekit = await loader.async_get_homekit(hass) + assert homekit["test_1"] == "test_1" + assert homekit["test_2"] == "test_2" + + +async def test_get_ssdp(hass): + """Verify that custom components with ssdp are found.""" + test_1_integration = _get_test_integration(hass, "test_1", True) + test_2_integration = _get_test_integration(hass, "test_2", True) + + with patch("homeassistant.loader.async_get_custom_components") as mock_get: + mock_get.return_value = { + "test_1": test_1_integration, + "test_2": test_2_integration, + } + ssdp = await loader.async_get_ssdp(hass) + assert ssdp["test_1"] == [{"manufacturer": "test_1", "modelName": "test_1"}] + assert ssdp["test_2"] == [{"manufacturer": "test_2", "modelName": "test_2"}] + + async def test_get_custom_components_safe_mode(hass): """Test that we get empty custom components in safe mode.""" hass.config.safe_mode = True From d66ddeb69eac2c6f3f67be126559453ec89ef403 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Aug 2020 17:42:23 +0200 Subject: [PATCH 315/362] Allow to set default dark theme and persist frontend default themes (#38548) Co-authored-by: Paulus Schoutsen --- homeassistant/components/frontend/__init__.py | 78 +++++++++-- .../components/frontend/services.yaml | 5 +- tests/components/frontend/test_init.py | 132 +++++++++++++++++- 3 files changed, 199 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index f950ca3441d..90089d50340 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -74,9 +74,16 @@ DATA_EXTRA_HTML_URL = "frontend_extra_html_url" DATA_EXTRA_HTML_URL_ES5 = "frontend_extra_html_url_es5" DATA_EXTRA_MODULE_URL = "frontend_extra_module_url" DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5" + +THEMES_STORAGE_KEY = f"{DOMAIN}_theme" +THEMES_STORAGE_VERSION = 1 +THEMES_SAVE_DELAY = 60 +DATA_THEMES_STORE = "frontend_themes_store" DATA_THEMES = "frontend_themes" DATA_DEFAULT_THEME = "frontend_default_theme" +DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme" DEFAULT_THEME = "default" +VALUE_NO_THEME = "none" PRIMARY_COLOR = "primary-color" @@ -114,6 +121,7 @@ CONFIG_SCHEMA = vol.Schema( SERVICE_SET_THEME = "set_theme" SERVICE_RELOAD_THEMES = "reload_themes" +CONF_MODE = "mode" class Panel: @@ -321,17 +329,31 @@ async def async_setup(hass, config): for url in conf.get(CONF_EXTRA_JS_URL_ES5, []): add_extra_js_url(hass, url, True) - _async_setup_themes(hass, conf.get(CONF_THEMES)) + await _async_setup_themes(hass, conf.get(CONF_THEMES)) return True -@callback -def _async_setup_themes(hass, themes): +async def _async_setup_themes(hass, themes): """Set up themes data and services.""" - hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME hass.data[DATA_THEMES] = themes or {} + store = hass.data[DATA_THEMES_STORE] = hass.helpers.storage.Store( + THEMES_STORAGE_VERSION, THEMES_STORAGE_KEY + ) + + theme_data = await store.async_load() or {} + theme_name = theme_data.get(DATA_DEFAULT_THEME, DEFAULT_THEME) + dark_theme_name = theme_data.get(DATA_DEFAULT_DARK_THEME) + + if theme_name == DEFAULT_THEME or theme_name in hass.data[DATA_THEMES]: + hass.data[DATA_DEFAULT_THEME] = theme_name + else: + hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + + if dark_theme_name == DEFAULT_THEME or dark_theme_name in hass.data[DATA_THEMES]: + hass.data[DATA_DEFAULT_DARK_THEME] = dark_theme_name + @callback def update_theme_and_fire_event(): """Update theme_color in manifest.""" @@ -348,14 +370,35 @@ def _async_setup_themes(hass, themes): @callback def set_theme(call): """Set backend-preferred theme.""" - data = call.data - name = data[CONF_NAME] - if name == DEFAULT_THEME or name in hass.data[DATA_THEMES]: - _LOGGER.info("Theme %s set as default", name) - hass.data[DATA_DEFAULT_THEME] = name - update_theme_and_fire_event() + name = call.data[CONF_NAME] + mode = call.data.get("mode", "light") + + if ( + name not in (DEFAULT_THEME, VALUE_NO_THEME) + and name not in hass.data[DATA_THEMES] + ): + _LOGGER.warning("Theme %s not found", name) + return + + light_mode = mode == "light" + + theme_key = DATA_DEFAULT_THEME if light_mode else DATA_DEFAULT_DARK_THEME + + if name == VALUE_NO_THEME: + to_set = DEFAULT_THEME if light_mode else None else: - _LOGGER.warning("Theme %s is not defined", name) + _LOGGER.info("Theme %s set as default %s theme", name, mode) + to_set = name + + hass.data[theme_key] = to_set + store.async_delay_save( + lambda: { + DATA_DEFAULT_THEME: hass.data[DATA_DEFAULT_THEME], + DATA_DEFAULT_DARK_THEME: hass.data.get(DATA_DEFAULT_DARK_THEME), + }, + THEMES_SAVE_DELAY, + ) + update_theme_and_fire_event() async def reload_themes(_): """Reload themes.""" @@ -364,6 +407,11 @@ def _async_setup_themes(hass, themes): hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME + if ( + hass.data.get(DATA_DEFAULT_DARK_THEME) + and hass.data.get(DATA_DEFAULT_DARK_THEME) not in new_themes + ): + hass.data[DATA_DEFAULT_DARK_THEME] = None update_theme_and_fire_event() service.async_register_admin_service( @@ -371,7 +419,12 @@ def _async_setup_themes(hass, themes): DOMAIN, SERVICE_SET_THEME, set_theme, - vol.Schema({vol.Required(CONF_NAME): cv.string}), + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_MODE): vol.Any("dark", "light"), + } + ), ) service.async_register_admin_service( @@ -536,6 +589,7 @@ def websocket_get_themes(hass, connection, msg): { "themes": hass.data[DATA_THEMES], "default_theme": hass.data[DATA_DEFAULT_THEME], + "default_dark_theme": hass.data.get(DATA_DEFAULT_DARK_THEME), }, ) ) diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 489164ce7bd..31eb4d5d1ca 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -4,8 +4,11 @@ set_theme: description: Set a theme unless the client selected per-device theme. fields: name: - description: Name of a predefined theme or 'default'. + description: Name of a predefined theme, 'default' or 'none'. example: "light" + mode: + description: The mode the theme is for, either 'dark' or 'light' (default). + example: "dark" reload_themes: description: Reload themes from yaml configuration. diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 10f55bd4db3..5e6bbe8b2d4 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -1,4 +1,5 @@ """The tests for Home Assistant frontend.""" +from datetime import timedelta import re import pytest @@ -10,16 +11,25 @@ from homeassistant.components.frontend import ( CONF_THEMES, DOMAIN, EVENT_PANELS_UPDATED, + THEMES_STORAGE_KEY, ) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import HTTP_NOT_FOUND from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util import dt from tests.async_mock import patch -from tests.common import async_capture_events +from tests.common import async_capture_events, async_fire_time_changed -CONFIG_THEMES = {DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "red"}}}} +CONFIG_THEMES = { + DOMAIN: { + CONF_THEMES: { + "happy": {"primary-color": "red"}, + "dark": {"primary-color": "black"}, + } + } +} @pytest.fixture @@ -117,7 +127,11 @@ async def test_themes_api(hass, hass_ws_client): msg = await client.receive_json() assert msg["result"]["default_theme"] == "default" - assert msg["result"]["themes"] == {"happy": {"primary-color": "red"}} + assert msg["result"]["default_dark_theme"] is None + assert msg["result"]["themes"] == { + "happy": {"primary-color": "red"}, + "dark": {"primary-color": "black"}, + } # safe mode hass.config.safe_mode = True @@ -130,6 +144,58 @@ async def test_themes_api(hass, hass_ws_client): } +async def test_themes_persist(hass, hass_ws_client, hass_storage): + """Test that theme settings are restores after restart.""" + + hass_storage[THEMES_STORAGE_KEY] = { + "key": THEMES_STORAGE_KEY, + "version": 1, + "data": { + "frontend_default_theme": "happy", + "frontend_default_dark_theme": "dark", + }, + } + + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "happy" + assert msg["result"]["default_dark_theme"] == "dark" + + +async def test_themes_save_storage(hass, hass_storage): + """Test that theme settings are restores after restart.""" + + hass_storage[THEMES_STORAGE_KEY] = { + "key": THEMES_STORAGE_KEY, + "version": 1, + "data": {}, + } + + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True + ) + + # To trigger the call_later + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=60)) + # To execute the save + await hass.async_block_till_done() + + assert hass_storage[THEMES_STORAGE_KEY]["data"] == { + "frontend_default_theme": "happy", + "frontend_default_dark_theme": "dark", + } + + async def test_themes_set_theme(hass, hass_ws_client): """Test frontend.set_theme service.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES) @@ -153,6 +219,17 @@ async def test_themes_set_theme(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "happy"}, blocking=True + ) + + await hass.services.async_call(DOMAIN, "set_theme", {"name": "none"}, blocking=True) + + await client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "default" + async def test_themes_set_theme_wrong_name(hass, hass_ws_client): """Test frontend.set_theme service called with wrong name.""" @@ -170,6 +247,55 @@ async def test_themes_set_theme_wrong_name(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" +async def test_themes_set_dark_theme(hass, hass_ws_client): + """Test frontend.set_theme service called with dark mode.""" + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "dark", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] == "dark" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "default", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] == "default" + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "none", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] is None + + +async def test_themes_set_dark_theme_wrong_name(hass, hass_ws_client): + """Test frontend.set_theme service called with mode dark and wrong name.""" + assert await async_setup_component(hass, "frontend", CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, "set_theme", {"name": "wrong", "mode": "dark"}, blocking=True + ) + + await client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await client.receive_json() + + assert msg["result"]["default_dark_theme"] is None + + async def test_themes_reload_themes(hass, hass_ws_client): """Test frontend.reload_themes service.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES) From 7590af393077fbee840a7e91921717a4b699a553 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Aug 2020 09:06:21 -0700 Subject: [PATCH 316/362] Add a timeout for async_add_entities (#38474) --- homeassistant/helpers/entity_platform.py | 28 ++++++++++++++++-- tests/helpers/test_entity_platform.py | 36 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7a581dbd19e..5f6d2349ec7 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,5 +1,6 @@ """Class to manage the entities for a single platform.""" import asyncio +from contextlib import suppress from contextvars import ContextVar from datetime import datetime, timedelta from logging import Logger @@ -23,6 +24,8 @@ if TYPE_CHECKING: SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 +SLOW_ADD_ENTITIES_MAX_WAIT = 60 + PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds @@ -282,8 +285,10 @@ class EntityPlatform: device_registry = await hass.helpers.device_registry.async_get_registry() entity_registry = await hass.helpers.entity_registry.async_get_registry() tasks = [ - self._async_add_entity( # type: ignore - entity, update_before_add, entity_registry, device_registry + asyncio.create_task( + self._async_add_entity( # type: ignore + entity, update_before_add, entity_registry, device_registry + ) ) for entity in new_entities ] @@ -292,7 +297,24 @@ class EntityPlatform: if not tasks: return - await asyncio.gather(*tasks) + await asyncio.wait(tasks, timeout=SLOW_ADD_ENTITIES_MAX_WAIT) + + for idx, entity in enumerate(new_entities): + task = tasks[idx] + if task.done(): + await task + continue + + self.logger.warning( + "Timed out adding entity %s for domain %s with platform %s after %ds.", + entity.entity_id, + self.domain, + self.platform_name, + SLOW_ADD_ENTITIES_MAX_WAIT, + ) + task.cancel() + with suppress(asyncio.CancelledError): + await task if self._async_unsub_polling is not None or not any( entity.should_poll for entity in self.entities.values() diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 5912eb42b03..3de68dca4c2 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -931,3 +931,39 @@ async def test_invalid_entity_id(hass): await platform.async_add_entities([entity]) assert entity.hass is None assert entity.platform is None + + +class MockBlockingEntity(MockEntity): + """Class to mock an entity that will block adding entities.""" + + async def async_added_to_hass(self): + """Block for a long time.""" + await asyncio.sleep(1000) + + +async def test_setup_entry_with_entities_that_block_forever(hass, caplog): + """Test we cancel adding entities when we reach the timeout.""" + registry = mock_registry(hass) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([MockBlockingEntity(name="test1", unique_id="unique")]) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + mock_entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + with patch.object(entity_platform, "SLOW_ADD_ENTITIES_MAX_WAIT", 0.01): + assert await mock_entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + full_name = f"{mock_entity_platform.domain}.{config_entry.domain}" + assert full_name in hass.config.components + assert len(hass.states.async_entity_ids()) == 0 + assert len(registry.entities) == 1 + assert "Timed out adding entity" in caplog.text + assert "test_domain.test1" in caplog.text + assert "test_domain" in caplog.text + assert "test" in caplog.text From 6c5bcbfc3ee40927458e9188d6b79bf63933d3f9 Mon Sep 17 00:00:00 2001 From: Markus Bong Date: Wed, 5 Aug 2020 18:16:21 +0200 Subject: [PATCH 317/362] Add devolo light devices (#37366) --- .coveragerc | 2 + .../components/devolo_home_control/const.py | 2 +- .../devolo_multi_level_switch.py | 35 ++++++++ .../components/devolo_home_control/light.py | 80 +++++++++++++++++++ .../components/devolo_home_control/switch.py | 14 ++-- 5 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/devolo_home_control/devolo_multi_level_switch.py create mode 100644 homeassistant/components/devolo_home_control/light.py diff --git a/.coveragerc b/.coveragerc index bcd3b80b812..6f286fc6a69 100644 --- a/.coveragerc +++ b/.coveragerc @@ -171,6 +171,8 @@ omit = homeassistant/components/devolo_home_control/binary_sensor.py homeassistant/components/devolo_home_control/const.py homeassistant/components/devolo_home_control/devolo_device.py + homeassistant/components/devolo_home_control/devolo_multi_level_switch.py + homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py homeassistant/components/devolo_home_control/subscriber.py homeassistant/components/devolo_home_control/switch.py diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index 599e44fe8f0..60923235916 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -3,6 +3,6 @@ DOMAIN = "devolo_home_control" DEFAULT_MYDEVOLO = "https://www.mydevolo.com" DEFAULT_MPRM = "https://homecontrol.mydevolo.com" -PLATFORMS = ["binary_sensor", "sensor", "switch"] +PLATFORMS = ["binary_sensor", "light", "sensor", "switch"] CONF_MYDEVOLO = "mydevolo_url" CONF_HOMECONTROL = "home_control_url" diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py new file mode 100644 index 00000000000..897899e725c --- /dev/null +++ b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py @@ -0,0 +1,35 @@ +"""Base class for multi level switches in devolo Home Control.""" +import logging + +from .devolo_device import DevoloDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): + """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" + + def __init__(self, homecontrol, device_instance, element_uid): + """Initialize a multi level switch within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + name=f"{device_instance.itemName}", + sync=self._sync, + ) + self._multi_level_switch_property = device_instance.multi_level_switch_property[ + element_uid + ] + + self._value = self._multi_level_switch_property.value + + def _sync(self, message): + """Update the multi level switch state.""" + if message[0] == self._multi_level_switch_property.element_uid: + self._value = message[1] + elif message[0].startswith("hdm"): + self._available = self._device_instance.is_online() + else: + _LOGGER.debug("No valid message received: %s", message) + self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py new file mode 100644 index 00000000000..c2f678425be --- /dev/null +++ b/homeassistant/components/devolo_home_control/light.py @@ -0,0 +1,80 @@ +"""Platform for light integration.""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + SUPPORT_BRIGHTNESS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN +from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Get all light devices and setup them via config entry.""" + entities = [] + + for device in hass.data[DOMAIN]["homecontrol"].multi_level_switch_devices: + for multi_level_switch in device.multi_level_switch_property.values(): + if multi_level_switch.switch_type == "dimmer": + entities.append( + DevoloLightDeviceEntity( + homecontrol=hass.data[DOMAIN]["homecontrol"], + device_instance=device, + element_uid=multi_level_switch.element_uid, + ) + ) + + async_add_entities(entities, False) + + +class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): + """Representation of a light within devolo Home Control.""" + + def __init__(self, homecontrol, device_instance, element_uid): + """Initialize a devolo multi level switch.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + + self._binary_switch_property = device_instance.binary_switch_property.get( + element_uid.replace("Dimmer", "BinarySwitch") + ) + + @property + def brightness(self): + """Return the brightness value of the light.""" + return round(self._value / 100 * 255) + + @property + def is_on(self): + """Return the state of the light.""" + return bool(self._value) + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS + + def turn_on(self, **kwargs) -> None: + """Turn device on.""" + if kwargs.get(ATTR_BRIGHTNESS) is not None: + self._multi_level_switch_property.set( + round(kwargs[ATTR_BRIGHTNESS] / 255 * 100) + ) + else: + if self._binary_switch_property is not None: + # Turn on the light device to the latest known value. The value is known by the device itself. + self._binary_switch_property.set(True) + else: + # If there is no binary switch attached to the device, turn it on to 100 %. + self._multi_level_switch_property.set(100) + + def turn_off(self, **kwargs) -> None: + """Turn device off.""" + self._multi_level_switch_property.set(0) diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index e70474a8d5d..d14b6c059de 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -19,13 +19,15 @@ async def async_setup_entry( entities = [] for device in devices: for binary_switch in device.binary_switch_property: - entities.append( - DevoloSwitch( - homecontrol=hass.data[DOMAIN]["homecontrol"], - device_instance=device, - element_uid=binary_switch, + # Exclude the binary switch which have also a multi_level_switches here, because they are implemented as light devices now. + if not hasattr(device, "multi_level_switch_property"): + entities.append( + DevoloSwitch( + homecontrol=hass.data[DOMAIN]["homecontrol"], + device_instance=device, + element_uid=binary_switch, + ) ) - ) async_add_entities(entities) From 485752a033abff30b92d4a417fb5cdc7dc7f3fde Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Aug 2020 20:32:53 +0200 Subject: [PATCH 318/362] Bumped version to 0.114.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cedfced2f7c..f6b54465239 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 114 -PATCH_VERSION = "0.dev0" +PATCH_VERSION = "0b0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) From e43fb649a4534dda917cda2d73e96bb980802762 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 6 Aug 2020 12:54:18 +0200 Subject: [PATCH 319/362] Improve Xioami Aqara zeroconf discovery handling (#37469) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .../components/xiaomi_aqara/__init__.py | 2 +- .../components/xiaomi_aqara/config_flow.py | 108 +++++++++---- .../components/xiaomi_aqara/strings.json | 11 +- .../xiaomi_aqara/translations/en.json | 11 +- .../xiaomi_aqara/test_config_flow.py | 150 ++++++++++++++---- 5 files changed, 209 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index d759785f49f..c5b74e68af5 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -142,11 +142,11 @@ async def async_setup_entry( xiaomi_gateway = await hass.async_add_executor_job( XiaomiGateway, entry.data[CONF_HOST], - entry.data[CONF_PORT], entry.data[CONF_SID], entry.data[CONF_KEY], DEFAULT_DISCOVERY_RETRY, entry.data[CONF_INTERFACE], + entry.data[CONF_PORT], entry.data[CONF_PROTOCOL], ) hass.data[DOMAIN][GATEWAYS_KEY][entry.entry_id] = xiaomi_gateway diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index b9cfe58ac4b..fb66be76635 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -3,10 +3,11 @@ import logging from socket import gaierror import voluptuous as vol -from xiaomi_gateway import XiaomiGatewayDiscovery +from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT +from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac # pylint: disable=unused-import @@ -15,6 +16,7 @@ from .const import ( CONF_KEY, CONF_PROTOCOL, CONF_SID, + DEFAULT_DISCOVERY_RETRY, DOMAIN, ZEROCONF_GATEWAY, ) @@ -28,6 +30,11 @@ DEFAULT_INTERFACE = "any" GATEWAY_CONFIG = vol.Schema( {vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): str} ) +CONFIG_HOST = { + vol.Optional(CONF_HOST): str, + vol.Optional(CONF_MAC): str, +} +GATEWAY_CONFIG_HOST = GATEWAY_CONFIG.extend(CONFIG_HOST) GATEWAY_SETTINGS = vol.Schema( { vol.Optional(CONF_KEY): vol.All(str, vol.Length(min=16, max=16)), @@ -46,44 +53,78 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize.""" self.host = None self.interface = DEFAULT_INTERFACE + self.sid = None self.gateways = None self.selected_gateway = None + @callback + def async_show_form_step_user(self, errors): + """Show the form belonging to the user step.""" + schema = GATEWAY_CONFIG + if (self.host is None and self.sid is None) or errors: + schema = GATEWAY_CONFIG_HOST + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" errors = {} - if user_input is not None: - self.interface = user_input[CONF_INTERFACE] + if user_input is None: + return self.async_show_form_step_user(errors) - # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. - xiaomi = XiaomiGatewayDiscovery(self.hass.add_job, [], self.interface) - try: - await self.hass.async_add_executor_job(xiaomi.discover_gateways) - except gaierror: - errors[CONF_INTERFACE] = "invalid_interface" + self.interface = user_input[CONF_INTERFACE] - if not errors: - self.gateways = xiaomi.gateways + # allow optional manual setting of host and mac + if self.host is None and self.sid is None: + self.host = user_input.get(CONF_HOST) + mac_address = user_input.get(CONF_MAC) - # if host is already known by zeroconf discovery - if self.host is not None: - self.selected_gateway = self.gateways.get(self.host) - if self.selected_gateway is not None: - return await self.async_step_settings() + # format sid from mac_address + if mac_address is not None: + self.sid = format_mac(mac_address).replace(":", "") - errors["base"] = "not_found_error" - else: - if len(self.gateways) == 1: - self.selected_gateway = list(self.gateways.values())[0] - return await self.async_step_settings() - if len(self.gateways) > 1: - return await self.async_step_select() + # if host is already known by zeroconf discovery or manual optional settings + if self.host is not None and self.sid is not None: + # Connect to Xiaomi Aqara Gateway + self.selected_gateway = await self.hass.async_add_executor_job( + XiaomiGateway, + self.host, + self.sid, + None, + DEFAULT_DISCOVERY_RETRY, + self.interface, + MULTICAST_PORT, + None, + ) - errors["base"] = "discovery_error" + if self.selected_gateway.connection_error: + errors[CONF_HOST] = "invalid_host" + if self.selected_gateway.mac_error: + errors[CONF_MAC] = "invalid_mac" + if errors: + return self.async_show_form_step_user(errors) - return self.async_show_form( - step_id="user", data_schema=GATEWAY_CONFIG, errors=errors - ) + return await self.async_step_settings() + + # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. + xiaomi = XiaomiGatewayDiscovery(self.hass.add_job, [], self.interface) + try: + await self.hass.async_add_executor_job(xiaomi.discover_gateways) + except gaierror: + errors[CONF_INTERFACE] = "invalid_interface" + return self.async_show_form_step_user(errors) + + self.gateways = xiaomi.gateways + + if len(self.gateways) == 1: + self.selected_gateway = list(self.gateways.values())[0] + self.sid = self.selected_gateway.sid + return await self.async_step_settings() + if len(self.gateways) > 1: + return await self.async_step_select() + + errors["base"] = "discovery_error" + return self.async_show_form_step_user(errors) async def async_step_select(self, user_input=None): """Handle multiple aqara gateways found.""" @@ -91,6 +132,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: ip_adress = user_input["select_ip"] self.selected_gateway = self.gateways[ip_adress] + self.sid = self.selected_gateway.sid return await self.async_step_settings() select_schema = vol.Schema( @@ -123,9 +165,12 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="not_xiaomi_aqara") - # format mac (include semicolns and make uppercase) + # format mac (include semicolns and make lowercase) mac_address = format_mac(mac_address) + # format sid from mac_address + self.sid = mac_address.replace(":", "") + unique_id = mac_address await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured({CONF_HOST: self.host}) @@ -144,19 +189,18 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): key = user_input.get(CONF_KEY) ip_adress = self.selected_gateway.ip_adress port = self.selected_gateway.port - sid = self.selected_gateway.sid protocol = self.selected_gateway.proto if key is not None: # validate key by issuing stop ringtone playback command. self.selected_gateway.key = key - valid_key = self.selected_gateway.write_to_hub(sid, mid=10000) + valid_key = self.selected_gateway.write_to_hub(self.sid, mid=10000) else: valid_key = True if valid_key: # format_mac, for a gateway the sid equels the mac address - mac_address = format_mac(sid) + mac_address = format_mac(self.sid) # set unique_id unique_id = mac_address @@ -172,7 +216,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_INTERFACE: self.interface, CONF_PROTOCOL: protocol, CONF_KEY: key, - CONF_SID: sid, + CONF_SID: self.sid, }, ) diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 87e1d37cb93..5cbdc91a661 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -4,9 +4,11 @@ "step": { "user": { "title": "Xiaomi Aqara Gateway", - "description": "Connect to your Xiaomi Aqara Gateway", + "description": "Connect to your Xiaomi Aqara Gateway, if the IP and mac addresses are left empty, auto-discovery is used", "data": { - "interface": "The network interface to use" + "interface": "The network interface to use", + "host": "[%key:common::config_flow::data::ip%] (optional)", + "mac": "Mac Address (optional)" } }, "settings": { @@ -27,9 +29,10 @@ }, "error": { "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", - "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface", "invalid_interface": "Invalid network interface", - "invalid_key": "Invalid gateway key" + "invalid_key": "Invalid gateway key", + "invalid_host": "Invalid [%key:common::config_flow::data::ip%]", + "invalid_mac": "Invalid Mac Address" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/xiaomi_aqara/translations/en.json b/homeassistant/components/xiaomi_aqara/translations/en.json index 7b801e33089..b9f6fa7ab2a 100644 --- a/homeassistant/components/xiaomi_aqara/translations/en.json +++ b/homeassistant/components/xiaomi_aqara/translations/en.json @@ -1,15 +1,16 @@ { "config": { "abort": { - "already_configured": "Device is already configured", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "Config flow for this gateway is already in progress", "not_xiaomi_aqara": "Not a Xiaomi Aqara Gateway, discovered device did not match known gateways" }, "error": { "discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface", + "invalid_host": "Invalid [%key:common::config_flow::data::ip%]", "invalid_interface": "Invalid network interface", "invalid_key": "Invalid gateway key", - "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface" + "invalid_mac": "Invalid Mac Address" }, "flow_title": "Xiaomi Aqara Gateway: {name}", "step": { @@ -30,9 +31,11 @@ }, "user": { "data": { - "interface": "The network interface to use" + "host": "[%key:common::config_flow::data::ip%] (optional)", + "interface": "The network interface to use", + "mac": "Mac Address (optional)" }, - "description": "Connect to your Xiaomi Aqara Gateway", + "description": "Connect to your Xiaomi Aqara Gateway, if the IP and mac addresses are left empty, auto-discovery is used", "title": "Xiaomi Aqara Gateway" } } diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index b7762317fdf..06fda84c934 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -34,13 +34,22 @@ def xiaomi_aqara_fixture(): with patch( "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", return_value=mock_gateway_discovery, + ), patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGateway", + return_value=mock_gateway_discovery.gateways[TEST_HOST], ), patch( "homeassistant.components.xiaomi_aqara.async_setup_entry", return_value=True ): yield -def get_mock_discovery(host_list, invalid_interface=False, invalid_key=False): +def get_mock_discovery( + host_list, + invalid_interface=False, + invalid_key=False, + invalid_host=False, + invalid_mac=False, +): """Return a mock gateway info instance.""" gateway_discovery = Mock() @@ -52,6 +61,8 @@ def get_mock_discovery(host_list, invalid_interface=False, invalid_key=False): gateway.port = TEST_PORT gateway.sid = TEST_SID gateway.proto = TEST_PROTOCOL + gateway.connection_error = invalid_host + gateway.mac_error = invalid_mac if invalid_key: gateway.write_to_hub = Mock(return_value=False) @@ -185,6 +196,52 @@ async def test_config_flow_user_no_key_success(hass): } +async def test_config_flow_user_host_mac_success(hass): + """Test a successful config flow initialized by the user with a host and mac specified.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([]) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", + return_value=mock_gateway_discovery, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "settings" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_NAME: TEST_NAME}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + CONF_MAC: TEST_MAC, + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + const.CONF_PROTOCOL: TEST_PROTOCOL, + const.CONF_KEY: None, + const.CONF_SID: TEST_SID, + } + + async def test_config_flow_user_discovery_error(hass): """Test a failed config flow initialized by the user with no gateways discoverd.""" result = await hass.config_entries.flow.async_init( @@ -235,6 +292,66 @@ async def test_config_flow_user_invalid_interface(hass): assert result["errors"] == {const.CONF_INTERFACE: "invalid_interface"} +async def test_config_flow_user_invalid_host(hass): + """Test a failed config flow initialized by the user with an invalid host.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST], invalid_host=True) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGateway", + return_value=mock_gateway_discovery.gateways[TEST_HOST], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + CONF_HOST: "0.0.0.0", + CONF_MAC: TEST_MAC, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"host": "invalid_host"} + + +async def test_config_flow_user_invalid_mac(hass): + """Test a failed config flow initialized by the user with an invalid mac.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + mock_gateway_discovery = get_mock_discovery([TEST_HOST], invalid_mac=True) + + with patch( + "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGateway", + return_value=mock_gateway_discovery.gateways[TEST_HOST], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE, + CONF_HOST: TEST_HOST, + CONF_MAC: "in:va:li:d0:0m:ac", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"mac": "invalid_mac"} + + async def test_config_flow_user_invalid_key(hass): """Test a failed config flow initialized by the user with an invalid key.""" result = await hass.config_entries.flow.async_init( @@ -335,34 +452,3 @@ async def test_zeroconf_unknown_device(hass): assert result["type"] == "abort" assert result["reason"] == "not_xiaomi_aqara" - - -async def test_zeroconf_not_found_error(hass): - """Test a failed zeroconf discovery because the correct gateway could not be found.""" - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data={ - zeroconf.ATTR_HOST: TEST_HOST, - ZEROCONF_NAME: TEST_ZEROCONF_NAME, - ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, - }, - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {} - - mock_gateway_discovery = get_mock_discovery([TEST_HOST_2]) - - with patch( - "homeassistant.components.xiaomi_aqara.config_flow.XiaomiGatewayDiscovery", - return_value=mock_gateway_discovery, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "user" - assert result["errors"] == {"base": "not_found_error"} From 187f47233c18bc8b9f9377ff5451efba40c0dc4e Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 6 Aug 2020 11:18:05 +0200 Subject: [PATCH 320/362] Remove Linky integration (#38565) --- .coveragerc | 2 - CODEOWNERS | 1 - homeassistant/components/linky/__init__.py | 64 ------ homeassistant/components/linky/config_flow.py | 99 ---------- homeassistant/components/linky/const.py | 5 - homeassistant/components/linky/manifest.json | 8 - homeassistant/components/linky/sensor.py | 162 ---------------- homeassistant/components/linky/strings.json | 23 --- .../components/linky/translations/bg.json | 20 -- .../components/linky/translations/ca.json | 23 --- .../components/linky/translations/cs.json | 15 -- .../components/linky/translations/da.json | 23 --- .../components/linky/translations/de.json | 23 --- .../components/linky/translations/en.json | 23 --- .../components/linky/translations/es-419.json | 23 --- .../components/linky/translations/es.json | 23 --- .../components/linky/translations/fr.json | 23 --- .../components/linky/translations/hu.json | 21 -- .../components/linky/translations/it.json | 23 --- .../components/linky/translations/ko.json | 23 --- .../components/linky/translations/lb.json | 23 --- .../components/linky/translations/lv.json | 11 -- .../components/linky/translations/nl.json | 23 --- .../components/linky/translations/nn.json | 9 - .../components/linky/translations/no.json | 23 --- .../components/linky/translations/pl.json | 23 --- .../components/linky/translations/pt-BR.json | 17 -- .../components/linky/translations/pt.json | 12 -- .../components/linky/translations/ru.json | 23 --- .../components/linky/translations/sl.json | 23 --- .../components/linky/translations/sv.json | 23 --- .../linky/translations/zh-Hans.json | 16 -- .../linky/translations/zh-Hant.json | 23 --- homeassistant/generated/config_flows.py | 1 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/linky/__init__.py | 1 - tests/components/linky/conftest.py | 11 -- tests/components/linky/test_config_flow.py | 182 ------------------ 39 files changed, 1077 deletions(-) delete mode 100644 homeassistant/components/linky/__init__.py delete mode 100644 homeassistant/components/linky/config_flow.py delete mode 100644 homeassistant/components/linky/const.py delete mode 100644 homeassistant/components/linky/manifest.json delete mode 100644 homeassistant/components/linky/sensor.py delete mode 100644 homeassistant/components/linky/strings.json delete mode 100644 homeassistant/components/linky/translations/bg.json delete mode 100644 homeassistant/components/linky/translations/ca.json delete mode 100644 homeassistant/components/linky/translations/cs.json delete mode 100644 homeassistant/components/linky/translations/da.json delete mode 100644 homeassistant/components/linky/translations/de.json delete mode 100644 homeassistant/components/linky/translations/en.json delete mode 100644 homeassistant/components/linky/translations/es-419.json delete mode 100644 homeassistant/components/linky/translations/es.json delete mode 100644 homeassistant/components/linky/translations/fr.json delete mode 100644 homeassistant/components/linky/translations/hu.json delete mode 100644 homeassistant/components/linky/translations/it.json delete mode 100644 homeassistant/components/linky/translations/ko.json delete mode 100644 homeassistant/components/linky/translations/lb.json delete mode 100644 homeassistant/components/linky/translations/lv.json delete mode 100644 homeassistant/components/linky/translations/nl.json delete mode 100644 homeassistant/components/linky/translations/nn.json delete mode 100644 homeassistant/components/linky/translations/no.json delete mode 100644 homeassistant/components/linky/translations/pl.json delete mode 100644 homeassistant/components/linky/translations/pt-BR.json delete mode 100644 homeassistant/components/linky/translations/pt.json delete mode 100644 homeassistant/components/linky/translations/ru.json delete mode 100644 homeassistant/components/linky/translations/sl.json delete mode 100644 homeassistant/components/linky/translations/sv.json delete mode 100644 homeassistant/components/linky/translations/zh-Hans.json delete mode 100644 homeassistant/components/linky/translations/zh-Hant.json delete mode 100644 tests/components/linky/__init__.py delete mode 100644 tests/components/linky/conftest.py delete mode 100644 tests/components/linky/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 6f286fc6a69..f340202cdb8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -460,8 +460,6 @@ omit = homeassistant/components/lightwave/* homeassistant/components/limitlessled/light.py homeassistant/components/linksys_smart/device_tracker.py - homeassistant/components/linky/__init__.py - homeassistant/components/linky/sensor.py homeassistant/components/linode/* homeassistant/components/linux_battery/sensor.py homeassistant/components/lirc/* diff --git a/CODEOWNERS b/CODEOWNERS index 3c4ac8dd0dd..0081057f086 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -226,7 +226,6 @@ homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus homeassistant/components/life360/* @pnbruckner -homeassistant/components/linky/* @Quentame homeassistant/components/linux_battery/* @fabaff homeassistant/components/local_ip/* @issacg homeassistant/components/logger/* @home-assistant/core diff --git a/homeassistant/components/linky/__init__.py b/homeassistant/components/linky/__init__.py deleted file mode 100644 index d21c007762c..00000000000 --- a/homeassistant/components/linky/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -"""The linky component.""" -import logging - -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import HomeAssistantType - -from .const import DEFAULT_TIMEOUT, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -ACCOUNT_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]))}, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass, config): - """Set up Linky sensors from legacy config file.""" - - conf = config.get(DOMAIN) - if conf is None: - return True - - for linky_account_conf in conf: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=linky_account_conf.copy(), - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): - """Set up Linky sensors.""" - # For backwards compat - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, unique_id=entry.data[CONF_USERNAME] - ) - - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) - return True - - -async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): - """Unload Linky sensors.""" - return await hass.config_entries.async_forward_entry_unload(entry, "sensor") diff --git a/homeassistant/components/linky/config_flow.py b/homeassistant/components/linky/config_flow.py deleted file mode 100644 index 88fa725cc4a..00000000000 --- a/homeassistant/components/linky/config_flow.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Config flow to configure the Linky integration.""" -import logging - -from pylinky.client import LinkyClient -from pylinky.exceptions import ( - PyLinkyAccessException, - PyLinkyEnedisException, - PyLinkyException, - PyLinkyWrongLoginException, -) -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME - -from .const import DEFAULT_TIMEOUT -from .const import DOMAIN # pylint: disable=unused-import - -_LOGGER = logging.getLogger(__name__) - - -class LinkyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - - def _show_setup_form(self, user_input=None, errors=None): - """Show the setup form to the user.""" - - if user_input is None: - user_input = {} - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") - ): str, - vol.Required( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") - ): str, - } - ), - errors=errors or {}, - ) - - async def async_step_user(self, user_input=None): - """Handle a flow initiated by the user.""" - errors = {} - - if user_input is None: - return self._show_setup_form(user_input, None) - - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - - # Check if already configured - if self.unique_id is None: - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - - client = LinkyClient(username, password, None, timeout) - try: - await self.hass.async_add_executor_job(client.login) - await self.hass.async_add_executor_job(client.fetch_data) - except PyLinkyAccessException as exp: - _LOGGER.error(exp) - errors["base"] = "access" - return self._show_setup_form(user_input, errors) - except PyLinkyEnedisException as exp: - _LOGGER.error(exp) - errors["base"] = "enedis" - return self._show_setup_form(user_input, errors) - except PyLinkyWrongLoginException as exp: - _LOGGER.error(exp) - errors["base"] = "wrong_login" - return self._show_setup_form(user_input, errors) - except PyLinkyException as exp: - _LOGGER.error(exp) - errors["base"] = "unknown" - return self._show_setup_form(user_input, errors) - finally: - client.close_session() - - return self.async_create_entry( - title=username, - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_TIMEOUT: timeout, - }, - ) - - async def async_step_import(self, user_input=None): - """Import a config entry.""" - return await self.async_step_user(user_input) diff --git a/homeassistant/components/linky/const.py b/homeassistant/components/linky/const.py deleted file mode 100644 index e8e68867528..00000000000 --- a/homeassistant/components/linky/const.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Linky component constants.""" - -DOMAIN = "linky" - -DEFAULT_TIMEOUT = 10 diff --git a/homeassistant/components/linky/manifest.json b/homeassistant/components/linky/manifest.json deleted file mode 100644 index 18ee74a78ce..00000000000 --- a/homeassistant/components/linky/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "linky", - "name": "Enedis Linky", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/linky", - "requirements": ["pylinky==0.4.0"], - "codeowners": ["@Quentame"] -} diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py deleted file mode 100644 index 7e9da01eb9a..00000000000 --- a/homeassistant/components/linky/sensor.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Support for Linky.""" -from datetime import timedelta -import json -import logging - -from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient, PyLinkyException - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_USERNAME, - ENERGY_KILO_WATT_HOUR, -) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import HomeAssistantType - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(hours=4) -ICON_ENERGY = "mdi:flash" -CONSUMPTION = "conso" -TIME = "time" -INDEX_CURRENT = -1 -INDEX_LAST = -2 -ATTRIBUTION = "Data provided by Enedis" - - -async def async_setup_entry( - hass: HomeAssistantType, entry: ConfigEntry, async_add_entities -) -> None: - """Add Linky entries.""" - account = LinkyAccount( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry.data[CONF_TIMEOUT] - ) - - await hass.async_add_executor_job(account.update_linky_data) - - sensors = [ - LinkySensor("Linky yesterday", account, DAILY, INDEX_LAST), - LinkySensor("Linky current month", account, MONTHLY, INDEX_CURRENT), - LinkySensor("Linky last month", account, MONTHLY, INDEX_LAST), - LinkySensor("Linky current year", account, YEARLY, INDEX_CURRENT), - LinkySensor("Linky last year", account, YEARLY, INDEX_LAST), - ] - - async_track_time_interval(hass, account.update_linky_data, SCAN_INTERVAL) - - async_add_entities(sensors, True) - - -class LinkyAccount: - """Representation of a Linky account.""" - - def __init__(self, username, password, timeout): - """Initialise the Linky account.""" - self._username = username - self._password = password - self._timeout = timeout - self._data = None - - def update_linky_data(self, event_time=None): - """Fetch new state data for the sensor.""" - client = LinkyClient(self._username, self._password, None, self._timeout) - try: - client.login() - client.fetch_data() - self._data = client.get_data() - _LOGGER.debug(json.dumps(self._data, indent=2)) - except PyLinkyException as exp: - _LOGGER.error(exp) - raise PlatformNotReady - finally: - client.close_session() - - @property - def username(self): - """Return the username.""" - return self._username - - @property - def data(self): - """Return the data.""" - return self._data - - -class LinkySensor(Entity): - """Representation of a sensor entity for Linky.""" - - def __init__(self, name, account: LinkyAccount, scale, when): - """Initialize the sensor.""" - self._name = name - self._account = account - self._scale = scale - self._when = when - self._username = account.username - self._time = None - self._consumption = None - self._unique_id = f"{self._username}_{scale}_{when}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @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._consumption - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return ENERGY_KILO_WATT_HOUR - - @property - def icon(self): - """Return the icon of the sensor.""" - return ICON_ENERGY - - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "time": self._time, - CONF_USERNAME: self._username, - } - - @property - def device_info(self): - """Return device information.""" - return { - "identifiers": {(DOMAIN, self._username)}, - "name": "Linky meter", - "manufacturer": "Enedis", - } - - async def async_update(self) -> None: - """Retrieve the new data for the sensor.""" - if self._account.data is None: - return - - data = self._account.data[self._scale][self._when] - self._consumption = data[CONSUMPTION] - self._time = data[TIME] - - if self._scale is not YEARLY: - year_index = INDEX_CURRENT - if self._time.endswith("Dec"): - year_index = INDEX_LAST - self._time += f" {self._account.data[YEARLY][year_index][TIME]}" diff --git a/homeassistant/components/linky/strings.json b/homeassistant/components/linky/strings.json deleted file mode 100644 index dea7062d213..00000000000 --- a/homeassistant/components/linky/strings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Linky", - "description": "Enter your credentials", - "data": { - "username": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "access": "Could not access to Enedis.fr, please check your internet connection", - "enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)", - "wrong_login": "Login error: please check your email & password", - "unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)" - }, - "abort": { - "already_configured": "Account already configured" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/bg.json b/homeassistant/components/linky/translations/bg.json deleted file mode 100644 index dd337013f59..00000000000 --- a/homeassistant/components/linky/translations/bg.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "config": { - "error": { - "access": "\u041d\u044f\u043c\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e Enedis.fr, \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e\u0441\u0442\u0442\u0430 \u0441\u0438", - "enedis": "Enedis.fr \u043e\u0442\u0433\u043e\u0432\u043e\u0440\u0438 \u0441 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", - "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", - "wrong_login": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0432\u043b\u0438\u0437\u0430\u043d\u0435: \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0438\u043c\u0435\u0439\u043b\u0430 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0441\u0438" - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "username": "E-mail" - }, - "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043d\u0434\u0435\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438\u0442\u0435 \u0441\u0438 \u0434\u0430\u043d\u043d\u0438", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/ca.json b/homeassistant/components/linky/translations/ca.json deleted file mode 100644 index 954b873083a..00000000000 --- a/homeassistant/components/linky/translations/ca.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El compte ja ha estat configurat" - }, - "error": { - "access": "No s'ha pogut accedir a Enedis.fr, comprova la teva connexi\u00f3 a Internet", - "enedis": "Enedis.fr ha respost amb un error: torna-ho a provar m\u00e9s tard (millo no entre les 23:00 i les 14:00)", - "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard (millor no entre les 23:00 i les 14:00)", - "wrong_login": "Error d'inici de sessi\u00f3: comprova el teu correu electr\u00f2nic i la contrasenya" - }, - "step": { - "user": { - "data": { - "password": "Contrasenya", - "username": "Correu electr\u00f2nic" - }, - "description": "Introdueix les teves credencials", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/cs.json b/homeassistant/components/linky/translations/cs.json deleted file mode 100644 index 8f8c4648d5f..00000000000 --- a/homeassistant/components/linky/translations/cs.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u00da\u010det je ji\u017e nakonfigurov\u00e1n" - }, - "step": { - "user": { - "data": { - "password": "Heslo", - "username": "E-mail" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/da.json b/homeassistant/components/linky/translations/da.json deleted file mode 100644 index 2fa885d1ffa..00000000000 --- a/homeassistant/components/linky/translations/da.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigureret" - }, - "error": { - "access": "Kunne ikke f\u00e5 adgang til Enedis.fr, kontroller din internetforbindelse", - "enedis": "Enedis.fr svarede med en fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", - "unknown": "Ukendt fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", - "wrong_login": "Loginfejl: Kontroller din e-mail og adgangskode" - }, - "step": { - "user": { - "data": { - "password": "Adgangskode", - "username": "E-mail" - }, - "description": "Indtast dine legitimationsoplysninger", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/de.json b/homeassistant/components/linky/translations/de.json deleted file mode 100644 index c915ddf0881..00000000000 --- a/homeassistant/components/linky/translations/de.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto bereits konfiguriert" - }, - "error": { - "access": "Konnte nicht auf Enedis.fr zugreifen, \u00fcberpr\u00fcfe bitte die Internetverbindung", - "enedis": "Enedis.fr antwortete mit einem Fehler: wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", - "unknown": "Unbekannter Fehler: Wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", - "wrong_login": "Login-Fehler: Pr\u00fcfe bitte E-Mail & Passwort" - }, - "step": { - "user": { - "data": { - "password": "Passwort", - "username": "E-Mail-Adresse" - }, - "description": "Gib deine Zugangsdaten ein", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/en.json b/homeassistant/components/linky/translations/en.json deleted file mode 100644 index 512c0567444..00000000000 --- a/homeassistant/components/linky/translations/en.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account already configured" - }, - "error": { - "access": "Could not access to Enedis.fr, please check your internet connection", - "enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)", - "unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)", - "wrong_login": "Login error: please check your email & password" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Email" - }, - "description": "Enter your credentials", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/es-419.json b/homeassistant/components/linky/translations/es-419.json deleted file mode 100644 index 58e44695fc8..00000000000 --- a/homeassistant/components/linky/translations/es-419.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya ha sido configurada" - }, - "error": { - "access": "No se pudo acceder a Enedis.fr, compruebe su conexi\u00f3n a Internet.", - "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11 p.m. y las 2 a.m.)", - "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11 p.m. y las 2 a.m.)", - "wrong_login": "Error de inicio de sesi\u00f3n: por favor revise su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Correo electr\u00f3nico" - }, - "description": "Ingrese sus credenciales", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/es.json b/homeassistant/components/linky/translations/es.json deleted file mode 100644 index ef07dc2ca75..00000000000 --- a/homeassistant/components/linky/translations/es.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" - }, - "error": { - "access": "No se pudo acceder a Enedis.fr, comprueba tu conexi\u00f3n a Internet", - "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11:00 y las 2 de la ma\u00f1ana)", - "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 23:00 y las 02:00 horas).", - "wrong_login": "Error de inicio de sesi\u00f3n: comprueba tu direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Correo electr\u00f3nico" - }, - "description": "Introduzca sus credenciales", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/fr.json b/homeassistant/components/linky/translations/fr.json deleted file mode 100644 index 71dba36dbe8..00000000000 --- a/homeassistant/components/linky/translations/fr.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Compte d\u00e9j\u00e0 configur\u00e9" - }, - "error": { - "access": "Impossible d'acc\u00e9der \u00e0 Enedis.fr, merci de v\u00e9rifier votre connexion internet", - "enedis": "Erreur d'Enedis.fr: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", - "unknown": "Erreur inconnue: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", - "wrong_login": "Erreur de connexion: veuillez v\u00e9rifier votre e-mail et votre mot de passe" - }, - "step": { - "user": { - "data": { - "password": "Mot de passe", - "username": "Email" - }, - "description": "Entrez vos identifiants", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/hu.json b/homeassistant/components/linky/translations/hu.json deleted file mode 100644 index 9b450985375..00000000000 --- a/homeassistant/components/linky/translations/hu.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" - }, - "error": { - "access": "Nem siker\u00fclt el\u00e9rni az Enedis.fr webhelyet, ellen\u0151rizze internet-kapcsolat\u00e1t", - "enedis": "Az Enedis.fr hib\u00e1val v\u00e1laszolt: k\u00e9rj\u00fck, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra (\u00e1ltal\u00e1ban nem 23:00 \u00e9s 2:00 k\u00f6z\u00f6tt)", - "unknown": "Ismeretlen hiba: pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb (\u00e1ltal\u00e1ban nem 23:00 \u00e9s 2:00 \u00f3ra k\u00f6z\u00f6tt)" - }, - "step": { - "user": { - "data": { - "password": "Jelsz\u00f3", - "username": "E-mail" - }, - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/it.json b/homeassistant/components/linky/translations/it.json deleted file mode 100644 index ff5e226dcbe..00000000000 --- a/homeassistant/components/linky/translations/it.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account gi\u00e0 configurato" - }, - "error": { - "access": "Impossibile accedere a Enedis.fr, si prega di controllare la connessione internet", - "enedis": "Enedis.fr ha risposto con un errore: si prega di riprovare pi\u00f9 tardi (di solito non tra le 23:00 e le 02:00).", - "unknown": "Errore sconosciuto: riprova pi\u00f9 tardi (in genere non tra le 23:00 e le 02:00)", - "wrong_login": "Errore di accesso: si prega di controllare la tua E-mail e la password" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "E-mail" - }, - "description": "Inserisci le tue credenziali", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/ko.json b/homeassistant/components/linky/translations/ko.json deleted file mode 100644 index cd83aad724f..00000000000 --- a/homeassistant/components/linky/translations/ko.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." - }, - "error": { - "access": "Enedis.fr \uc5d0 \uc811\uc18d\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc778\ud130\ub137 \uc5f0\uacb0\uc744 \ud655\uc778\ud574\ubcf4\uc138\uc694", - "enedis": "Enedis.fr \uc774 \uc624\ub958\ub85c \uc751\ub2f5\ud588\uc2b5\ub2c8\ub2e4: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", - "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", - "wrong_login": "\ub85c\uadf8\uc778 \uc624\ub958: \uc774\uba54\uc77c \ubc0f \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694" - }, - "step": { - "user": { - "data": { - "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc774\uba54\uc77c" - }, - "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/lb.json b/homeassistant/components/linky/translations/lb.json deleted file mode 100644 index 091a3b8d699..00000000000 --- a/homeassistant/components/linky/translations/lb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kont ass scho konfigur\u00e9iert" - }, - "error": { - "access": "Keng Verbindung zu Enedis.fr, iwwerpr\u00e9ift d'Internet Verbindung", - "enedis": "Enedis.fr huet mat engem Feeler ge\u00e4ntwert: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", - "unknown": "Onbekannte Feeler: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", - "wrong_login": "Feeler beim Login: iwwerpr\u00e9ift \u00e4r E-Mail & Passwuert" - }, - "step": { - "user": { - "data": { - "password": "Passwuert", - "username": "E-Mail" - }, - "description": "F\u00ebllt \u00e4r Login Informatiounen aus", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/lv.json b/homeassistant/components/linky/translations/lv.json deleted file mode 100644 index 973833a5470..00000000000 --- a/homeassistant/components/linky/translations/lv.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "username": "E-pasts" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/nl.json b/homeassistant/components/linky/translations/nl.json deleted file mode 100644 index 2c05353be3f..00000000000 --- a/homeassistant/components/linky/translations/nl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account al geconfigureerd" - }, - "error": { - "access": "Geen toegang tot Enedis.fr, controleer uw internetverbinding", - "enedis": "Enedis.fr antwoordde met een fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", - "unknown": "Onbekende fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", - "wrong_login": "Aanmeldingsfout: controleer uw e-mailadres en wachtwoord" - }, - "step": { - "user": { - "data": { - "password": "Wachtwoord", - "username": "E-mail" - }, - "description": "Voer uw gegevens in", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/nn.json b/homeassistant/components/linky/translations/nn.json deleted file mode 100644 index 6cdaaf837a4..00000000000 --- a/homeassistant/components/linky/translations/nn.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/no.json b/homeassistant/components/linky/translations/no.json deleted file mode 100644 index 5cf8ea2da34..00000000000 --- a/homeassistant/components/linky/translations/no.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigurert" - }, - "error": { - "access": "Kunne ikke f\u00e5 tilgang til Enedis.fr, vennligst sjekk internettforbindelsen din", - "enedis": "Enedis.fr svarte med en feil: vennligst pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", - "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", - "wrong_login": "Innloggingsfeil: vennligst sjekk e-postadressen og passordet ditt" - }, - "step": { - "user": { - "data": { - "password": "Passord", - "username": "E-post" - }, - "description": "Fyll inn legitimasjonen din", - "title": "" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/pl.json b/homeassistant/components/linky/translations/pl.json deleted file mode 100644 index 1fc09298fd7..00000000000 --- a/homeassistant/components/linky/translations/pl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane." - }, - "error": { - "access": "Nie mo\u017cna uzyska\u0107 dost\u0119pu do Enedis.fr, sprawd\u017a po\u0142\u0105czenie internetowe", - "enedis": "Enedis.fr odpowiedzia\u0142 b\u0142\u0119dem: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy 23:00, a 2:00)", - "unknown": "Nieznany b\u0142\u0105d: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy godzin\u0105 23:00, a 2:00)", - "wrong_login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o" - }, - "step": { - "user": { - "data": { - "password": "Has\u0142o", - "username": "Adres e-mail" - }, - "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/pt-BR.json b/homeassistant/components/linky/translations/pt-BR.json deleted file mode 100644 index bf2bc7070ae..00000000000 --- a/homeassistant/components/linky/translations/pt-BR.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "error": { - "wrong_login": "Erro de Login: por favor, verifique seu e-mail e senha" - }, - "step": { - "user": { - "data": { - "password": "Senha", - "username": "E-mail" - }, - "description": "Insira suas credenciais", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/pt.json b/homeassistant/components/linky/translations/pt.json deleted file mode 100644 index 54619af958e..00000000000 --- a/homeassistant/components/linky/translations/pt.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "password": "Palavra-passe", - "username": "O email" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/ru.json b/homeassistant/components/linky/translations/ru.json deleted file mode 100644 index 65e0269967a..00000000000 --- a/homeassistant/components/linky/translations/ru.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430." - }, - "error": { - "access": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a Enedis.fr, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0443.", - "enedis": "Enedis.fr \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b \u043e\u0442\u0432\u0435\u0442 \u0441 \u043e\u0448\u0438\u0431\u043a\u043e\u0439: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00).", - "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00).", - "wrong_login": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u0445\u043e\u0434\u0430: \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b \u0438 \u043f\u0430\u0440\u043e\u043b\u044c." - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" - }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/sl.json b/homeassistant/components/linky/translations/sl.json deleted file mode 100644 index 3df56ac5bbb..00000000000 --- a/homeassistant/components/linky/translations/sl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ra\u010dun \u017ee nastavljen" - }, - "error": { - "access": "Do Enedis.fr ni bilo mogo\u010de dostopati, preverite internetno povezavo", - "enedis": "Enedis.fr je odgovoril z napako: poskusite pozneje (ponavadi med 23. in 2. uro)", - "unknown": "Neznana napaka: Prosimo, poskusite pozneje (obi\u010dajno ne med 23. in 2. uro)", - "wrong_login": "Napaka pri prijavi: preverite svoj e-po\u0161tni naslov in geslo" - }, - "step": { - "user": { - "data": { - "password": "Geslo", - "username": "E-po\u0161tni naslov" - }, - "description": "Vnesite svoje poverilnice", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/sv.json b/homeassistant/components/linky/translations/sv.json deleted file mode 100644 index 2d8c2b7177a..00000000000 --- a/homeassistant/components/linky/translations/sv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontot har redan konfigurerats." - }, - "error": { - "access": "Det gick inte att komma \u00e5t Enedis.fr, kontrollera din internetanslutning", - "enedis": "Enedis.fr svarade med ett fel: f\u00f6rs\u00f6k igen senare (vanligtvis inte mellan 23:00 och 02:00)", - "unknown": "Ok\u00e4nt fel: f\u00f6rs\u00f6k igen senare (vanligtvis inte mellan 23:00 och 02:00)", - "wrong_login": "Inloggningsfel: v\u00e4nligen kontrollera din e-post och l\u00f6senord" - }, - "step": { - "user": { - "data": { - "password": "L\u00f6senord", - "username": "E-post" - }, - "description": "Ange dina autentiseringsuppgifter", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/zh-Hans.json b/homeassistant/components/linky/translations/zh-Hans.json deleted file mode 100644 index 62138856078..00000000000 --- a/homeassistant/components/linky/translations/zh-Hans.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "error": { - "wrong_login": "\u767b\u5f55\u51fa\u9519\uff1a\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u5b50\u90ae\u7bb1\u548c\u5bc6\u7801" - }, - "step": { - "user": { - "data": { - "password": "\u5bc6\u7801", - "username": "\u7535\u5b50\u90ae\u7bb1" - }, - "description": "\u8f93\u5165\u60a8\u7684\u8eab\u4efd\u8ba4\u8bc1" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/linky/translations/zh-Hant.json b/homeassistant/components/linky/translations/zh-Hant.json deleted file mode 100644 index 7a28dd692f6..00000000000 --- a/homeassistant/components/linky/translations/zh-Hant.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" - }, - "error": { - "access": "\u7121\u6cd5\u8a2a\u554f Enedis.fr\uff0c\u8acb\u6aa2\u67e5\u60a8\u7684\u7db2\u969b\u7db2\u8def\u9023\u7dda", - "enedis": "Endis.fr \u56de\u5831\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", - "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", - "wrong_login": "\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u78ba\u8a8d\u96fb\u5b50\u90f5\u4ef6\u8207\u5bc6\u78bc" - }, - "step": { - "user": { - "data": { - "password": "\u5bc6\u78bc", - "username": "\u96fb\u5b50\u90f5\u4ef6" - }, - "description": "\u8f38\u5165\u6191\u8b49", - "title": "Linky" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7aa9eac6a86..3b4216377e5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -94,7 +94,6 @@ FLOWS = [ "konnected", "life360", "lifx", - "linky", "local_ip", "locative", "logi_circle", diff --git a/requirements_all.txt b/requirements_all.txt index c144f8207fc..a18e41e096b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1439,9 +1439,6 @@ pylgnetcast-homeassistant==0.2.0.dev0 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 -# homeassistant.components.linky -pylinky==0.4.0 - # homeassistant.components.litejet pylitejet==0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 722fbe0bcc5..814b422ee7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -676,9 +676,6 @@ pylast==3.2.1 # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 -# homeassistant.components.linky -pylinky==0.4.0 - # homeassistant.components.litejet pylitejet==0.1 diff --git a/tests/components/linky/__init__.py b/tests/components/linky/__init__.py deleted file mode 100644 index f461885e384..00000000000 --- a/tests/components/linky/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Linky component.""" diff --git a/tests/components/linky/conftest.py b/tests/components/linky/conftest.py deleted file mode 100644 index 93e3ff78d2b..00000000000 --- a/tests/components/linky/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Linky generic test utils.""" -import pytest - -from tests.async_mock import patch - - -@pytest.fixture(autouse=True) -def patch_fakeuseragent(): - """Stub out fake useragent dep that makes requests.""" - with patch("pylinky.client.UserAgent", return_value="Test Browser"): - yield diff --git a/tests/components/linky/test_config_flow.py b/tests/components/linky/test_config_flow.py deleted file mode 100644 index f39f0da7d99..00000000000 --- a/tests/components/linky/test_config_flow.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tests for the Linky config flow.""" -from pylinky.exceptions import ( - PyLinkyAccessException, - PyLinkyEnedisException, - PyLinkyException, - PyLinkyWrongLoginException, -) -import pytest - -from homeassistant import data_entry_flow -from homeassistant.components.linky.const import DEFAULT_TIMEOUT, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.helpers.typing import HomeAssistantType - -from tests.async_mock import Mock, patch -from tests.common import MockConfigEntry - -USERNAME = "username@hotmail.fr" -USERNAME_2 = "username@free.fr" -PASSWORD = "password" -TIMEOUT = 20 - - -@pytest.fixture(name="login") -def mock_controller_login(): - """Mock a successful login.""" - with patch( - "homeassistant.components.linky.config_flow.LinkyClient" - ) as service_mock: - service_mock.return_value.login = Mock(return_value=True) - service_mock.return_value.close_session = Mock(return_value=None) - yield service_mock - - -@pytest.fixture(name="fetch_data") -def mock_controller_fetch_data(): - """Mock a successful get data.""" - with patch( - "homeassistant.components.linky.config_flow.LinkyClient" - ) as service_mock: - service_mock.return_value.fetch_data = Mock(return_value={}) - service_mock.return_value.close_session = Mock(return_value=None) - yield service_mock - - -async def test_user(hass: HomeAssistantType, login, fetch_data): - """Test user config.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=None - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "user" - - # test with all provided - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == USERNAME - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT - - -async def test_import(hass: HomeAssistantType, login, fetch_data): - """Test import step.""" - # import with username and password - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == USERNAME - assert result["title"] == USERNAME - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT - - # import with all - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_USERNAME: USERNAME_2, - CONF_PASSWORD: PASSWORD, - CONF_TIMEOUT: TIMEOUT, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == USERNAME_2 - assert result["title"] == USERNAME_2 - assert result["data"][CONF_USERNAME] == USERNAME_2 - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_TIMEOUT] == TIMEOUT - - -async def test_abort_if_already_setup(hass: HomeAssistantType, login, fetch_data): - """Test we abort if Linky is already setup.""" - MockConfigEntry( - domain=DOMAIN, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - unique_id=USERNAME, - ).add_to_hass(hass) - - # Should fail, same USERNAME (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - # Should fail, same USERNAME (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_login_failed(hass: HomeAssistantType, login): - """Test when we have errors during login.""" - login.return_value.login.side_effect = PyLinkyAccessException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "access"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - login.return_value.login.side_effect = PyLinkyWrongLoginException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "wrong_login"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - -async def test_fetch_failed(hass: HomeAssistantType, login): - """Test when we have errors during fetch.""" - login.return_value.fetch_data.side_effect = PyLinkyAccessException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "access"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - login.return_value.fetch_data.side_effect = PyLinkyEnedisException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "enedis"} - hass.config_entries.flow.async_abort(result["flow_id"]) - - login.return_value.fetch_data.side_effect = PyLinkyException() - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - hass.config_entries.flow.async_abort(result["flow_id"]) From 5bdeb46c125d42c9a57b4980de69c8a32a5140f5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 6 Aug 2020 10:43:47 +0200 Subject: [PATCH 321/362] Suppress MQTT discovery updates without changes (#38568) --- homeassistant/components/mqtt/__init__.py | 12 +++++++--- .../components/mqtt/binary_sensor.py | 3 ++- .../components/mqtt/light/schema_template.py | 4 ++-- .../mqtt/test_alarm_control_panel.py | 16 +++++++++++++ tests/components/mqtt/test_binary_sensor.py | 15 ++++++++++++ tests/components/mqtt/test_camera.py | 19 ++++++++++++--- tests/components/mqtt/test_climate.py | 18 ++++++++++++--- tests/components/mqtt/test_common.py | 23 +++++++++++++++++++ tests/components/mqtt/test_cover.py | 23 +++++++++++++++---- tests/components/mqtt/test_fan.py | 21 +++++++++++++---- tests/components/mqtt/test_legacy_vacuum.py | 13 +++++++++++ tests/components/mqtt/test_light.py | 16 +++++++++++++ tests/components/mqtt/test_light_json.py | 17 ++++++++++++++ tests/components/mqtt/test_light_template.py | 19 +++++++++++++++ tests/components/mqtt/test_lock.py | 17 ++++++++++++++ tests/components/mqtt/test_sensor.py | 22 ++++++++++++++---- tests/components/mqtt/test_state_vacuum.py | 23 +++++++++++++++---- tests/components/mqtt/test_switch.py | 16 +++++++++++++ 18 files changed, 266 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index a0527cfe427..81c44ac8aea 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -44,6 +44,7 @@ from . import config_flow # noqa: F401 pylint: disable=unused-import from . import debug_info, discovery from .const import ( ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, ATTR_PAYLOAD, ATTR_QOS, @@ -1169,6 +1170,7 @@ class MqttDiscoveryUpdate(Entity): _LOGGER.info( "Got update for entity with hash: %s '%s'", discovery_hash, payload, ) + old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) if not payload: # Empty payload: Remove component @@ -1176,9 +1178,13 @@ class MqttDiscoveryUpdate(Entity): self._cleanup_discovery_on_remove() await _async_remove_state_and_registry_entry(self) elif self._discovery_update: - # Non-empty payload: Notify component - _LOGGER.info("Updating component: %s", self.entity_id) - await self._discovery_update(payload) + if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: + # Non-empty, changed payload: Notify component + _LOGGER.info("Updating component: %s", self.entity_id) + await self._discovery_update(payload) + else: + # Non-empty, unchanged payload: Ignore to avoid changing states + _LOGGER.info("Ignoring unchanged update for: %s", self.entity_id) if discovery_hash: debug_info.add_entity_discovery_data( diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index cd69967e6a7..5d69bfde4f6 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -169,7 +169,8 @@ class MqttBinarySensor( if expire_after is not None and expire_after > 0: - # When expire_after is set, and we receive a message, assume device is not expired since it has to be to receive the message + # When expire_after is set, and we receive a message, assume device is + # not expired since it has to be to receive the message self._expired = False # Reset old trigger diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index a9f18e7039b..d14cda70bb6 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -101,10 +101,10 @@ async def async_setup_entity_template( config, async_add_entities, config_entry, discovery_data ): """Set up a MQTT Template light.""" - async_add_entities([MqttTemplate(config, config_entry, discovery_data)]) + async_add_entities([MqttLightTemplate(config, config_entry, discovery_data)]) -class MqttTemplate( +class MqttLightTemplate( MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index aa6452fd9c8..734e1fd552f 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -28,6 +28,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -42,6 +43,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import assert_setup_component, async_fire_mqtt_message from tests.components.alarm_control_panel import common @@ -575,6 +577,20 @@ async def test_discovery_update_alarm(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_alarm(hass, mqtt_mock, caplog): + """Test update of discovered alarm_control_panel.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) + config1["name"] = "Beer" + + data1 = json.dumps(config1) + with patch( + "homeassistant.components.mqtt.alarm_control_panel.MqttAlarm.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index b909a0592e0..c739f4378d1 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -26,6 +26,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -593,6 +594,20 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_binary_sensor(hass, mqtt_mock, caplog): + """Test update of discovered binary_sensor.""" + config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) + config1["name"] = "Beer" + + data1 = json.dumps(config1) + with patch( + "homeassistant.components.mqtt.binary_sensor.MqttBinarySensor.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, discovery_update + ) + + async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass, mqtt_mock, legacy_patchable_time, caplog ): diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 6869b530668..22f714fdcf7 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -16,6 +16,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -30,6 +31,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message DEFAULT_CONFIG = { @@ -153,14 +155,25 @@ async def test_discovery_update_camera(hass, mqtt_mock, caplog): entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] await async_start(hass, "homeassistant", entry) - data1 = '{ "name": "Beer",' ' "topic": "test_topic"}' - data2 = '{ "name": "Milk",' ' "topic": "test_topic"}' + data1 = '{ "name": "Beer", "topic": "test_topic"}' + data2 = '{ "name": "Milk", "topic": "test_topic"}' await help_test_discovery_update( hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_camera(hass, mqtt_mock, caplog): + """Test update of discovered camera.""" + data1 = '{ "name": "Beer", "topic": "test_topic"}' + with patch( + "homeassistant.components.mqtt.camera.MqttCamera.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, camera.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" @@ -168,7 +181,7 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): await async_start(hass, "homeassistant", entry) data1 = '{ "name": "Beer" }' - data2 = '{ "name": "Milk",' ' "topic": "test_topic"}' + data2 = '{ "name": "Milk", "topic": "test_topic"}' await help_test_discovery_broken( hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2 diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 6a7bdf0b7e6..d60af211d71 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -34,6 +34,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -48,7 +49,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.async_mock import call +from tests.async_mock import call, patch from tests.common import async_fire_mqtt_message from tests.components.climate import common @@ -909,11 +910,22 @@ async def test_discovery_update_climate(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_climate(hass, mqtt_mock, caplog): + """Test update of discovered climate.""" + data1 = '{ "name": "Beer" }' + with patch( + "homeassistant.components.mqtt.climate.MqttClimate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer",' ' "power_command_topic": "test_topic#" }' - data2 = '{ "name": "Milk", ' ' "power_command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "power_command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "power_command_topic": "test_topic" }' await help_test_discovery_broken( hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 31566885a37..89bfde22d87 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -497,6 +497,29 @@ async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, dat assert state is None +async def help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, domain, data1, discovery_update +): + """Test update of discovered component without changes. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + await async_start(hass, "homeassistant", entry) + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.beer") + assert state is not None + assert state.name == "Beer" + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) + await hass.async_block_till_done() + + assert not discovery_update.called + + async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, data2): """Test handling of bad discovery message.""" entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index c3f00badef8..f9036bcfa0f 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -38,6 +38,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -52,6 +53,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message DEFAULT_CONFIG = { @@ -1862,24 +1864,35 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_cover(hass, mqtt_mock, caplog): """Test removal of discovered cover.""" - data = '{ "name": "test",' ' "command_topic": "test_topic" }' + data = '{ "name": "test", "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, cover.DOMAIN, data) async def test_discovery_update_cover(hass, mqtt_mock, caplog): """Test update of discovered cover.""" - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_update( hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_cover(hass, mqtt_mock, caplog): + """Test update of discovered cover.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.cover.MqttCover.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, cover.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6114fe48ff4..e1801c5c15a 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -19,6 +19,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -33,6 +34,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message from tests.components.fan import common @@ -689,22 +691,33 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_fan(hass, mqtt_mock, caplog): """Test removal of discovered fan.""" - data = '{ "name": "test",' ' "command_topic": "test_topic" }' + data = '{ "name": "test", "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, fan.DOMAIN, data) async def test_discovery_update_fan(hass, mqtt_mock, caplog): """Test update of discovered fan.""" - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_update(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) +async def test_discovery_update_unchanged_fan(hass, mqtt_mock, caplog): + """Test update of discovered fan.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.fan.MqttFan.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, fan.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 893c1b78f1e..aacea4e345e 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -31,6 +31,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -45,6 +46,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message from tests.components.vacuum import common @@ -643,6 +645,17 @@ async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_vacuum(hass, mqtt_mock, caplog): + """Test update of discovered vacuum.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.vacuum.schema_legacy.MqttVacuum.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 5fa8fa181e5..75d3e694838 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -170,6 +170,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -1450,6 +1451,21 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): + """Test update of discovered light.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.light.schema_basic.MqttLight.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7bb3763654e..54292aeeb7b 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -110,6 +110,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -1179,6 +1180,22 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): + """Test update of discovered light.""" + data1 = ( + '{ "name": "Beer",' + ' "schema": "json",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.light.schema_json.MqttLightJson.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index f0e226d2095..17b3332da40 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -47,6 +47,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -923,6 +924,24 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): + """Test update of discovered light.""" + data1 = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + with patch( + "homeassistant.components.mqtt.light.schema_template.MqttLightTemplate.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index ff130077a95..cd37543d94e 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -20,6 +20,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -34,6 +35,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message DEFAULT_CONFIG = { @@ -382,6 +384,21 @@ async def test_discovery_update_lock(hass, mqtt_mock, caplog): await help_test_discovery_update(hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, data2) +async def test_discovery_update_unchanged_lock(hass, mqtt_mock, caplog): + """Test update of discovered lock.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "command_topic" }' + ) + with patch( + "homeassistant.components.mqtt.lock.MqttLock.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 5ec5fccbe28..0d31b9f33f2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -24,6 +24,7 @@ from .test_common import ( help_test_discovery_update, help_test_discovery_update_attr, help_test_discovery_update_availability, + help_test_discovery_update_unchanged, help_test_entity_debug_info, help_test_entity_debug_info_max_messages, help_test_entity_debug_info_message, @@ -425,24 +426,35 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): """Test removal of discovered sensor.""" - data = '{ "name": "test",' ' "state_topic": "test_topic" }' + data = '{ "name": "test", "state_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data) async def test_discovery_update_sensor(hass, mqtt_mock, caplog): """Test update of discovered sensor.""" - data1 = '{ "name": "Beer",' ' "state_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' + data1 = '{ "name": "Beer", "state_topic": "test_topic" }' + data2 = '{ "name": "Milk", "state_topic": "test_topic" }' await help_test_discovery_update( hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_sensor(hass, mqtt_mock, caplog): + """Test update of discovered sensor.""" + data1 = '{ "name": "Beer", "state_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.sensor.MqttSensor.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, sensor.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "name": "Beer",' ' "state_topic": "test_topic#" }' - data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' + data1 = '{ "name": "Beer", "state_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "state_topic": "test_topic" }' await help_test_discovery_broken( hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index c8ca7d3691b..fe410821395 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -41,6 +41,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -55,6 +56,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) +from tests.async_mock import patch from tests.common import async_fire_mqtt_message from tests.components.vacuum import common @@ -410,24 +412,35 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): """Test removal of discovered vacuum.""" - data = '{ "schema": "state", "name": "test",' ' "command_topic": "test_topic"}' + data = '{ "schema": "state", "name": "test", "command_topic": "test_topic"}' await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): """Test update of discovered vacuum.""" - data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic"}' - data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}' + data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' + data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_update( hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 ) +async def test_discovery_update_unchanged_vacuum(hass, mqtt_mock, caplog): + """Test update of discovered vacuum.""" + data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' + with patch( + "homeassistant.components.mqtt.vacuum.schema_state.MqttStateVacuum.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic#"}' - data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}' + data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic#"}' + data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_broken( hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 869a413eb6b..a6edb8d6f14 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -15,6 +15,7 @@ from .test_common import ( help_test_discovery_removal, help_test_discovery_update, help_test_discovery_update_attr, + help_test_discovery_update_unchanged, help_test_entity_debug_info_message, help_test_entity_device_info_remove, help_test_entity_device_info_update, @@ -320,6 +321,21 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog): ) +async def test_discovery_update_unchanged_switch(hass, mqtt_mock, caplog): + """Test update of discovered switch.""" + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + with patch( + "homeassistant.components.mqtt.switch.MqttSwitch.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, switch.DOMAIN, data1, discovery_update + ) + + @pytest.mark.no_fail_on_log_exception async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" From a25f1cc6c354a32ec7bf1e5fbc084ca53f7645e7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 6 Aug 2020 08:32:53 +0200 Subject: [PATCH 322/362] Fix missing rfxtrx strings (#38570) * Fix missing rfxtrx strings * Clean --- homeassistant/components/rfxtrx/strings.json | 9 ++++++++- homeassistant/components/rfxtrx/translations/en.json | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/rfxtrx/translations/en.json diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 7a73a41bfdf..e19265dec32 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -1,2 +1,9 @@ { -} \ No newline at end of file + "config": { + "step": {}, + "error": {}, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/rfxtrx/translations/en.json b/homeassistant/components/rfxtrx/translations/en.json new file mode 100644 index 00000000000..263b2a9467b --- /dev/null +++ b/homeassistant/components/rfxtrx/translations/en.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": {}, + "step": {} + } +} From e287c9cf0833d33963b5830ce8eba9e5d2347bcd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 6 Aug 2020 09:32:42 +0200 Subject: [PATCH 323/362] Revert "Add a timeout for async_add_entities (#38474)" (#38584) This reverts commit 7590af393077fbee840a7e91921717a4b699a553. --- homeassistant/helpers/entity_platform.py | 28 ++---------------- tests/helpers/test_entity_platform.py | 36 ------------------------ 2 files changed, 3 insertions(+), 61 deletions(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 5f6d2349ec7..7a581dbd19e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,6 +1,5 @@ """Class to manage the entities for a single platform.""" import asyncio -from contextlib import suppress from contextvars import ContextVar from datetime import datetime, timedelta from logging import Logger @@ -24,8 +23,6 @@ if TYPE_CHECKING: SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 -SLOW_ADD_ENTITIES_MAX_WAIT = 60 - PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds @@ -285,10 +282,8 @@ class EntityPlatform: device_registry = await hass.helpers.device_registry.async_get_registry() entity_registry = await hass.helpers.entity_registry.async_get_registry() tasks = [ - asyncio.create_task( - self._async_add_entity( # type: ignore - entity, update_before_add, entity_registry, device_registry - ) + self._async_add_entity( # type: ignore + entity, update_before_add, entity_registry, device_registry ) for entity in new_entities ] @@ -297,24 +292,7 @@ class EntityPlatform: if not tasks: return - await asyncio.wait(tasks, timeout=SLOW_ADD_ENTITIES_MAX_WAIT) - - for idx, entity in enumerate(new_entities): - task = tasks[idx] - if task.done(): - await task - continue - - self.logger.warning( - "Timed out adding entity %s for domain %s with platform %s after %ds.", - entity.entity_id, - self.domain, - self.platform_name, - SLOW_ADD_ENTITIES_MAX_WAIT, - ) - task.cancel() - with suppress(asyncio.CancelledError): - await task + await asyncio.gather(*tasks) if self._async_unsub_polling is not None or not any( entity.should_poll for entity in self.entities.values() diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 3de68dca4c2..5912eb42b03 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -931,39 +931,3 @@ async def test_invalid_entity_id(hass): await platform.async_add_entities([entity]) assert entity.hass is None assert entity.platform is None - - -class MockBlockingEntity(MockEntity): - """Class to mock an entity that will block adding entities.""" - - async def async_added_to_hass(self): - """Block for a long time.""" - await asyncio.sleep(1000) - - -async def test_setup_entry_with_entities_that_block_forever(hass, caplog): - """Test we cancel adding entities when we reach the timeout.""" - registry = mock_registry(hass) - - async def async_setup_entry(hass, config_entry, async_add_entities): - """Mock setup entry method.""" - async_add_entities([MockBlockingEntity(name="test1", unique_id="unique")]) - return True - - platform = MockPlatform(async_setup_entry=async_setup_entry) - config_entry = MockConfigEntry(entry_id="super-mock-id") - mock_entity_platform = MockEntityPlatform( - hass, platform_name=config_entry.domain, platform=platform - ) - - with patch.object(entity_platform, "SLOW_ADD_ENTITIES_MAX_WAIT", 0.01): - assert await mock_entity_platform.async_setup_entry(config_entry) - await hass.async_block_till_done() - full_name = f"{mock_entity_platform.domain}.{config_entry.domain}" - assert full_name in hass.config.components - assert len(hass.states.async_entity_ids()) == 0 - assert len(registry.entities) == 1 - assert "Timed out adding entity" in caplog.text - assert "test_domain.test1" in caplog.text - assert "test_domain" in caplog.text - assert "test" in caplog.text From 9713b5806f4bd2a2f1b9822c8065c4fafdd269cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Aug 2020 12:36:59 +0200 Subject: [PATCH 324/362] Do not print warning when command line switch queries off (#38591) --- homeassistant/components/command_line/__init__.py | 11 ++++++++--- homeassistant/components/command_line/switch.py | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 92f219a13ea..4f98818d9b3 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -6,8 +6,12 @@ import subprocess _LOGGER = logging.getLogger(__name__) -def call_shell_with_timeout(command, timeout): - """Run a shell command with a timeout.""" +def call_shell_with_timeout(command, timeout, *, log_return_code=True): + """Run a shell command with a timeout. + + If log_return_code is set to False, it will not print an error if a non-zero + return code is returned. + """ try: _LOGGER.debug("Running command: %s", command) subprocess.check_output( @@ -15,7 +19,8 @@ def call_shell_with_timeout(command, timeout): ) return 0 except subprocess.CalledProcessError as proc_exception: - _LOGGER.error("Command failed: %s", command) + if log_return_code: + _LOGGER.error("Command failed: %s", command) return proc_exception.returncode except subprocess.TimeoutExpired: _LOGGER.error("Timeout for command: %s", command) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 50cda31a537..804e3c6a4d5 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -114,7 +114,9 @@ class CommandSwitch(SwitchEntity): def _query_state_code(self, command): """Execute state command for return code.""" _LOGGER.info("Running state code command: %s", command) - return call_shell_with_timeout(command, self._timeout) == 0 + return ( + call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 + ) @property def should_poll(self): From ec14bf215ac46621ca563a5200ad1091b6446ec2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 6 Aug 2020 10:58:06 +0000 Subject: [PATCH 325/362] Bumped version to 0.114.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index f6b54465239..2b893eb84fe 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 114 -PATCH_VERSION = "0b0" +PATCH_VERSION = "0b1" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) From f0f112ff4219d1ac18137568252170c80f23fce3 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Fri, 7 Aug 2020 02:56:28 -0400 Subject: [PATCH 326/362] Upgrade to TensorFlow 2 (#38384) Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare Co-authored-by: Franck Nijhof --- azure-pipelines-wheels.yml | 1 + .../components/tensorflow/image_processing.py | 139 ++++++++++++------ .../components/tensorflow/manifest.json | 7 +- pylintrc | 2 +- requirements_all.txt | 13 +- script/gen_requirements_all.py | 1 + 6 files changed, 115 insertions(+), 48 deletions(-) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index c8943595429..ebe704f12e2 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -89,5 +89,6 @@ jobs: sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} sed -i "s|# bme680|bme680|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + sed -i "s|# tf-models-official|tf-models-official|g" ${requirement_file} done displayName: 'Prepare requirements files for Home Assistant wheels' diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f4eb5342c46..d6d20c63f56 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -3,9 +3,11 @@ import io import logging import os import sys +import time from PIL import Image, ImageDraw, UnidentifiedImageError import numpy as np +import tensorflow as tf import voluptuous as vol from homeassistant.components.image_processing import ( @@ -16,16 +18,21 @@ from homeassistant.components.image_processing import ( PLATFORM_SCHEMA, ImageProcessingEntity, ) +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv from homeassistant.util.pil import draw_box +os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" + +DOMAIN = "tensorflow" _LOGGER = logging.getLogger(__name__) ATTR_MATCHES = "matches" ATTR_SUMMARY = "summary" ATTR_TOTAL_MATCHES = "total_matches" +ATTR_PROCESS_TIME = "process_time" CONF_AREA = "area" CONF_BOTTOM = "bottom" @@ -34,6 +41,7 @@ CONF_CATEGORY = "category" CONF_FILE_OUT = "file_out" CONF_GRAPH = "graph" CONF_LABELS = "labels" +CONF_LABEL_OFFSET = "label_offset" CONF_LEFT = "left" CONF_MODEL = "model" CONF_MODEL_DIR = "model_dir" @@ -58,12 +66,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_FILE_OUT, default=[]): vol.All(cv.ensure_list, [cv.template]), vol.Required(CONF_MODEL): vol.Schema( { - vol.Required(CONF_GRAPH): cv.isfile, + vol.Required(CONF_GRAPH): cv.isdir, vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_CATEGORIES, default=[]): vol.All( cv.ensure_list, [vol.Any(cv.string, CATEGORY_SCHEMA)] ), vol.Optional(CONF_LABELS): cv.isfile, + vol.Optional(CONF_LABEL_OFFSET, default=1): int, vol.Optional(CONF_MODEL_DIR): cv.isdir, } ), @@ -71,17 +80,40 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +def get_model_detection_function(model): + """Get a tf.function for detection.""" + + @tf.function + def detect_fn(image): + """Detect objects in image.""" + + image, shapes = model.preprocess(image) + prediction_dict = model.predict(image, shapes) + detections = model.postprocess(prediction_dict, shapes) + + return detections + + return detect_fn + + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the TensorFlow image processing platform.""" - model_config = config.get(CONF_MODEL) + model_config = config[CONF_MODEL] model_dir = model_config.get(CONF_MODEL_DIR) or hass.config.path("tensorflow") labels = model_config.get(CONF_LABELS) or hass.config.path( "tensorflow", "object_detection", "data", "mscoco_label_map.pbtxt" ) + checkpoint = os.path.join(model_config[CONF_GRAPH], "checkpoint") + pipeline_config = os.path.join(model_config[CONF_GRAPH], "pipeline.config") # Make sure locations exist - if not os.path.isdir(model_dir) or not os.path.exists(labels): - _LOGGER.error("Unable to locate tensorflow models or label map") + if ( + not os.path.isdir(model_dir) + or not os.path.isdir(checkpoint) + or not os.path.exists(pipeline_config) + or not os.path.exists(labels) + ): + _LOGGER.error("Unable to locate tensorflow model or label map") return # append custom model path to sys.path @@ -89,18 +121,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: # Verify that the TensorFlow Object Detection API is pre-installed - os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" # These imports shouldn't be moved to the top, because they depend on code from the model_dir. # (The model_dir is created during the manual setup process. See integration docs.) - import tensorflow as tf # pylint: disable=import-outside-toplevel # pylint: disable=import-outside-toplevel - from object_detection.utils import label_map_util + from object_detection.utils import config_util, label_map_util + from object_detection.builders import model_builder except ImportError: _LOGGER.error( "No TensorFlow Object Detection library found! Install or compile " "for your system following instructions here: " - "https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/installation.md" + "https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/tf2.md#installation" ) return @@ -113,22 +144,45 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "PIL at reduced resolution" ) - # Set up Tensorflow graph, session, and label map to pass to processor - # pylint: disable=no-member - detection_graph = tf.Graph() - with detection_graph.as_default(): - od_graph_def = tf.GraphDef() - with tf.gfile.GFile(model_config.get(CONF_GRAPH), "rb") as fid: - serialized_graph = fid.read() - od_graph_def.ParseFromString(serialized_graph) - tf.import_graph_def(od_graph_def, name="") + hass.data[DOMAIN] = {CONF_MODEL: None} - session = tf.Session(graph=detection_graph) - label_map = label_map_util.load_labelmap(labels) - categories = label_map_util.convert_label_map_to_categories( - label_map, max_num_classes=90, use_display_name=True + def tensorflow_hass_start(_event): + """Set up TensorFlow model on hass start.""" + start = time.perf_counter() + + # Load pipeline config and build a detection model + pipeline_configs = config_util.get_configs_from_pipeline_file(pipeline_config) + detection_model = model_builder.build( + model_config=pipeline_configs["model"], is_training=False + ) + + # Restore checkpoint + ckpt = tf.compat.v2.train.Checkpoint(model=detection_model) + ckpt.restore(os.path.join(checkpoint, "ckpt-0")).expect_partial() + + _LOGGER.debug( + "Model checkpoint restore took %d seconds", time.perf_counter() - start + ) + + model = get_model_detection_function(detection_model) + + # Preload model cache with empty image tensor + inp = np.zeros([2160, 3840, 3], dtype=np.uint8) + # The input needs to be a tensor, convert it using `tf.convert_to_tensor`. + input_tensor = tf.convert_to_tensor(inp, dtype=tf.float32) + # The model expects a batch of images, so add an axis with `tf.newaxis`. + input_tensor = input_tensor[tf.newaxis, ...] + # Run inference + model(input_tensor) + + _LOGGER.debug("Model load took %d seconds", time.perf_counter() - start) + hass.data[DOMAIN][CONF_MODEL] = model + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, tensorflow_hass_start) + + category_index = label_map_util.create_category_index_from_labelmap( + labels, use_display_name=True ) - category_index = label_map_util.create_category_index(categories) entities = [] @@ -138,8 +192,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), - session, - detection_graph, category_index, config, ) @@ -152,14 +204,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): """Representation of an TensorFlow image processor.""" def __init__( - self, - hass, - camera_entity, - name, - session, - detection_graph, - category_index, - config, + self, hass, camera_entity, name, category_index, config, ): """Initialize the TensorFlow entity.""" model_config = config.get(CONF_MODEL) @@ -169,13 +214,12 @@ class TensorFlowImageProcessor(ImageProcessingEntity): self._name = name else: self._name = "TensorFlow {}".format(split_entity_id(camera_entity)[1]) - self._session = session - self._graph = detection_graph self._category_index = category_index self._min_confidence = config.get(CONF_CONFIDENCE) self._file_out = config.get(CONF_FILE_OUT) # handle categories and specific detection areas + self._label_id_offset = model_config.get(CONF_LABEL_OFFSET) categories = model_config.get(CONF_CATEGORIES) self._include_categories = [] self._category_areas = {} @@ -212,6 +256,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): self._matches = {} self._total_matches = 0 self._last_image = None + self._process_time = 0 @property def camera_entity(self): @@ -237,6 +282,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): category: len(values) for category, values in self._matches.items() }, ATTR_TOTAL_MATCHES: self._total_matches, + ATTR_PROCESS_TIME: self._process_time, } def _save_image(self, image, matches, paths): @@ -281,10 +327,16 @@ class TensorFlowImageProcessor(ImageProcessingEntity): def process_image(self, image): """Process the image.""" + model = self.hass.data[DOMAIN][CONF_MODEL] + if not model: + _LOGGER.debug("Model not yet ready.") + return + start = time.perf_counter() try: import cv2 # pylint: disable=import-error, import-outside-toplevel + # pylint: disable=no-member img = cv2.imdecode(np.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) inp = img[:, :, [2, 1, 0]] # BGR->RGB inp_expanded = inp.reshape(1, inp.shape[0], inp.shape[1], 3) @@ -303,15 +355,15 @@ class TensorFlowImageProcessor(ImageProcessingEntity): ) inp_expanded = np.expand_dims(inp, axis=0) - image_tensor = self._graph.get_tensor_by_name("image_tensor:0") - boxes = self._graph.get_tensor_by_name("detection_boxes:0") - scores = self._graph.get_tensor_by_name("detection_scores:0") - classes = self._graph.get_tensor_by_name("detection_classes:0") - boxes, scores, classes = self._session.run( - [boxes, scores, classes], feed_dict={image_tensor: inp_expanded} - ) - boxes, scores, classes = map(np.squeeze, [boxes, scores, classes]) - classes = classes.astype(int) + # The input needs to be a tensor, convert it using `tf.convert_to_tensor`. + input_tensor = tf.convert_to_tensor(inp_expanded, dtype=tf.float32) + + detections = model(input_tensor) + boxes = detections["detection_boxes"][0].numpy() + scores = detections["detection_scores"][0].numpy() + classes = ( + detections["detection_classes"][0].numpy() + self._label_id_offset + ).astype(int) matches = {} total_matches = 0 @@ -367,3 +419,4 @@ class TensorFlowImageProcessor(ImageProcessingEntity): self._matches = matches self._total_matches = total_matches + self._process_time = time.perf_counter() - start diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index b74633d36d4..f9ee39ec7be 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -3,9 +3,12 @@ "name": "TensorFlow", "documentation": "https://www.home-assistant.io/integrations/tensorflow", "requirements": [ - "tensorflow==1.13.2", + "tensorflow==2.2.0", + "tf-slim==1.1.0", + "tf-models-official==2.2.1", + "pycocotools==2.0.1", "numpy==1.19.0", - "protobuf==3.6.1", + "protobuf==3.12.2", "pillow==7.1.2" ], "codeowners": [] diff --git a/pylintrc b/pylintrc index df53c2f67a2..f2860026cd8 100644 --- a/pylintrc +++ b/pylintrc @@ -5,7 +5,7 @@ ignore=tests jobs=2 load-plugins=pylint_strict_informational persistent=no -extension-pkg-whitelist=ciso8601 +extension-pkg-whitelist=ciso8601,cv2 [BASIC] good-names=id,i,j,k,ex,Run,_,fp,T,ev diff --git a/requirements_all.txt b/requirements_all.txt index a18e41e096b..0b25ccc63c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1120,7 +1120,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.tensorflow -protobuf==3.6.1 +protobuf==3.12.2 # homeassistant.components.proxmoxve proxmoxer==1.1.1 @@ -1261,6 +1261,9 @@ pychromecast==7.2.0 # homeassistant.components.cmus pycmus==0.1.1 +# homeassistant.components.tensorflow +pycocotools==2.0.1 + # homeassistant.components.comfoconnect pycomfoconnect==0.3 @@ -2098,7 +2101,7 @@ temescal==0.1 temperusb==1.5.3 # homeassistant.components.tensorflow -# tensorflow==1.13.2 +# tensorflow==2.2.0 # homeassistant.components.powerwall tesla-powerwall==0.2.12 @@ -2106,6 +2109,12 @@ tesla-powerwall==0.2.12 # homeassistant.components.tesla teslajsonpy==0.10.1 +# homeassistant.components.tensorflow +# tf-models-official==2.2.1 + +# homeassistant.components.tensorflow +tf-slim==1.1.0 + # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4625924da29..772b9af5034 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -41,6 +41,7 @@ COMMENT_REQUIREMENTS = ( "RPi.GPIO", "smbus-cffi", "tensorflow", + "tf-models-official", "VL53L1X2", ) From 93cdd4dbf3724013cf2990e9f8a446644155a1af Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 6 Aug 2020 19:43:42 +0100 Subject: [PATCH 327/362] Improve the OVO Energy integration (#38598) Co-authored-by: Martin Hjelmare --- .../components/ovo_energy/config_flow.py | 70 ++++++++----------- .../components/ovo_energy/manifest.json | 1 - .../components/ovo_energy/strings.json | 29 ++++---- .../ovo_energy/translations/en.json | 29 ++++---- 4 files changed, 60 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py index e4d33865f57..ac3e8371123 100644 --- a/homeassistant/components/ovo_energy/config_flow.py +++ b/homeassistant/components/ovo_energy/config_flow.py @@ -9,58 +9,48 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import CONF_ACCOUNT_ID, DOMAIN +from .const import CONF_ACCOUNT_ID, DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) +USER_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) -@config_entries.HANDLERS.register(DOMAIN) -class OVOEnergyFlowHandler(ConfigFlow): + +class OVOEnergyFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a OVO Energy config flow.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def __init__(self): - """Initialize OVO Energy flow.""" - - async def _show_setup_form(self, errors=None): - """Show the setup form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), - errors=errors or {}, - ) - async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" - if user_input is None: - return await self._show_setup_form() - errors = {} - - client = OVOEnergy() - - try: - if ( - await client.authenticate( - user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD) + if user_input is not None: + client = OVOEnergy() + try: + authenticated = await client.authenticate( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) - is not True - ): - errors["base"] = "authorization_error" - return await self._show_setup_form(errors) - except aiohttp.ClientError: - errors["base"] = "connection_error" - return await self._show_setup_form(errors) + except aiohttp.ClientError: + errors["base"] = "connection_error" + else: + if authenticated: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() - return self.async_create_entry( - title=client.account_id, - data={ - CONF_USERNAME: user_input.get(CONF_USERNAME), - CONF_PASSWORD: user_input.get(CONF_PASSWORD), - CONF_ACCOUNT_ID: client.account_id, - }, + return self.async_create_entry( + title=client.account_id, + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_ACCOUNT_ID: client.account_id, + }, + ) + + errors["base"] = "authorization_error" + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors ) diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json index 27a28863405..2da08d3339b 100644 --- a/homeassistant/components/ovo_energy/manifest.json +++ b/homeassistant/components/ovo_energy/manifest.json @@ -4,6 +4,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ovo_energy", "requirements": ["ovoenergy==1.1.6"], - "dependencies": [], "codeowners": ["@timmo001"] } diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index a98b0223644..0132f3582b6 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -1,18 +1,19 @@ { - "config": { - "error": { - "authorization_error": "Authorization error. Check your credentials.", - "connection_error": "Could not connect to OVO Energy." - }, - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "config": { + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, - "description": "Set up an OVO Energy instance to access your energy usage.", - "title": "Add OVO Energy" - } + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy Account" + } + } } - } } diff --git a/homeassistant/components/ovo_energy/translations/en.json b/homeassistant/components/ovo_energy/translations/en.json index afe1bb6e301..0132f3582b6 100644 --- a/homeassistant/components/ovo_energy/translations/en.json +++ b/homeassistant/components/ovo_energy/translations/en.json @@ -1,18 +1,19 @@ { - "config": { - "error": { - "authorization_error": "Authorization error. Check your credentials.", - "connection_error": "Could not connect to OVO Energy." - }, - "step": { - "user": { - "data": { - "username": "Username", - "password": "Password" + "config": { + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]" }, - "description": "Set up an OVO Energy instance to access your energy usage.", - "title": "Add OVO Energy" - } + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy Account" + } + } } - } } From 4fc56cec1cd1eb23f745e35c3857a0c509b65013 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Aug 2020 08:36:38 +0200 Subject: [PATCH 328/362] V2 timeout for async_add_entities (#38601) Co-authored-by: Martin Hjelmare Co-authored-by: J. Nick Koston Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/entity_platform.py | 15 +++++++++- tests/helpers/test_entity_platform.py | 38 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 7a581dbd19e..6d9a1275b06 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -23,6 +23,9 @@ if TYPE_CHECKING: SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 +SLOW_ADD_ENTITY_MAX_WAIT = 10 # Per Entity +SLOW_ADD_MIN_TIMEOUT = 60 + PLATFORM_NOT_READY_RETRIES = 10 DATA_ENTITY_PLATFORM = "entity_platform" PLATFORM_NOT_READY_BASE_WAIT_TIME = 30 # seconds @@ -292,7 +295,17 @@ class EntityPlatform: if not tasks: return - await asyncio.gather(*tasks) + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(tasks), SLOW_ADD_MIN_TIMEOUT) + try: + async with self.hass.timeout.async_timeout(timeout, self.domain): + await asyncio.gather(*tasks) + except asyncio.TimeoutError: + self.logger.warning( + "Timed out adding entities for domain %s with platform %s after %ds", + self.domain, + self.platform_name, + timeout, + ) if self._async_unsub_polling is not None or not any( entity.should_poll for entity in self.entities.values() diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 5912eb42b03..6d03b087151 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -931,3 +931,41 @@ async def test_invalid_entity_id(hass): await platform.async_add_entities([entity]) assert entity.hass is None assert entity.platform is None + + +class MockBlockingEntity(MockEntity): + """Class to mock an entity that will block adding entities.""" + + async def async_added_to_hass(self): + """Block for a long time.""" + await asyncio.sleep(1000) + + +async def test_setup_entry_with_entities_that_block_forever(hass, caplog): + """Test we cancel adding entities when we reach the timeout.""" + registry = mock_registry(hass) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([MockBlockingEntity(name="test1", unique_id="unique")]) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + mock_entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + with patch.object(entity_platform, "SLOW_ADD_ENTITY_MAX_WAIT", 0.01), patch.object( + entity_platform, "SLOW_ADD_MIN_TIMEOUT", 0.01 + ): + assert await mock_entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + full_name = f"{mock_entity_platform.domain}.{config_entry.domain}" + assert full_name in hass.config.components + assert len(hass.states.async_entity_ids()) == 0 + assert len(registry.entities) == 1 + assert "Timed out adding entities" in caplog.text + assert "test_domain.test1" in caplog.text + assert "test_domain" in caplog.text + assert "test" in caplog.text From 790a136c0a82cb1d0d392cb2dbc63c52789a6f58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 6 Aug 2020 22:26:43 -0500 Subject: [PATCH 329/362] Ensure homekit pairing barcode is usable on dark themes (#38609) --- homeassistant/components/homekit/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 201a0529f82..2199371c00d 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -354,7 +354,7 @@ def show_setup_message(hass, entry_id, bridge_name, pincode, uri): buffer = io.BytesIO() url = pyqrcode.create(uri) - url.svg(buffer, scale=5) + url.svg(buffer, scale=5, module_color="#000", background="#FFF") pairing_secret = secrets.token_hex(32) hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue() From 6baded622b5cb945c5331cba3d60982c3e4f3287 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Aug 2020 08:17:00 +0200 Subject: [PATCH 330/362] Handle unavailable input_select in Google Assistant (#38611) --- .../components/google_assistant/trait.py | 77 ++++++++++--------- .../google_assistant/test_smart_home.py | 2 - .../components/google_assistant/test_trait.py | 5 ++ 3 files changed, 45 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 90b5016260d..36863fd86c8 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1267,46 +1267,49 @@ class ModesTrait(_Trait): return features & media_player.SUPPORT_SELECT_SOUND_MODE + def _generate(self, name, settings): + """Generate a list of modes.""" + mode = { + "name": name, + "name_values": [ + {"name_synonym": self.SYNONYMS.get(name, [name]), "lang": "en"} + ], + "settings": [], + "ordered": False, + } + for setting in settings: + mode["settings"].append( + { + "setting_name": setting, + "setting_values": [ + { + "setting_synonym": self.SYNONYMS.get(setting, [setting]), + "lang": "en", + } + ], + } + ) + return mode + def sync_attributes(self): """Return mode attributes for a sync request.""" - - def _generate(name, settings): - mode = { - "name": name, - "name_values": [ - {"name_synonym": self.SYNONYMS.get(name, [name]), "lang": "en"} - ], - "settings": [], - "ordered": False, - } - for setting in settings: - mode["settings"].append( - { - "setting_name": setting, - "setting_values": [ - { - "setting_synonym": self.SYNONYMS.get( - setting, [setting] - ), - "lang": "en", - } - ], - } - ) - return mode - - attrs = self.state.attributes modes = [] - if self.state.domain == media_player.DOMAIN: - if media_player.ATTR_SOUND_MODE_LIST in attrs: - modes.append( - _generate("sound mode", attrs[media_player.ATTR_SOUND_MODE_LIST]) - ) - elif self.state.domain == input_select.DOMAIN: - modes.append(_generate("option", attrs[input_select.ATTR_OPTIONS])) - elif self.state.domain == humidifier.DOMAIN: - if humidifier.ATTR_AVAILABLE_MODES in attrs: - modes.append(_generate("mode", attrs[humidifier.ATTR_AVAILABLE_MODES])) + + for domain, attr, name in ( + (media_player.DOMAIN, media_player.ATTR_SOUND_MODE_LIST, "sound mode"), + (input_select.DOMAIN, input_select.ATTR_OPTIONS, "option"), + (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), + ): + if self.state.domain != domain: + continue + + items = self.state.attributes.get(attr) + + if items is not None: + modes.append(self._generate(name, items)) + + # Shortcut since all domains are currently unique + break payload = {"availableModes": modes} diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index e9795a9320f..6cd99d1fdd1 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -337,8 +337,6 @@ async def test_execute(hass): const.SOURCE_CLOUD, ) - print(result) - assert result == { "requestId": REQ_ID, "payload": { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index faad53fbc66..854e040119d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1433,6 +1433,11 @@ async def test_modes_input_select(hass): assert helpers.get_google_type(input_select.DOMAIN, None) is not None assert trait.ModesTrait.supported(input_select.DOMAIN, None, None) + trt = trait.ModesTrait( + hass, State("input_select.bla", "unavailable"), BASIC_CONFIG, + ) + assert trt.sync_attributes() == {"availableModes": []} + trt = trait.ModesTrait( hass, State( From 64749a0f8597f16b6b0a3ad2573ff86edb409ee7 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 7 Aug 2020 08:37:10 +0200 Subject: [PATCH 331/362] Bump OpenCV 4.3.0 and Numpy 1.19.1 (#38616) --- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/opencv/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 5d880888ef5..1f862bb1bbf 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,6 +3,6 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.19.0", "pyiqvia==0.2.1"], + "requirements": ["numpy==1.19.1", "pyiqvia==0.2.1"], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index ed8fd9c662c..1fb7096d5fa 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,6 +2,6 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.19.0", "opencv-python-headless==4.2.0.32"], + "requirements": ["numpy==1.19.1", "opencv-python-headless==4.3.0.36"], "codeowners": [] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index f9ee39ec7be..fc87b5cdbff 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -7,7 +7,7 @@ "tf-slim==1.1.0", "tf-models-official==2.2.1", "pycocotools==2.0.1", - "numpy==1.19.0", + "numpy==1.19.1", "protobuf==3.12.2", "pillow==7.1.2" ], diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index dabeabd2757..a43c2bb0cce 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.19.0"], + "requirements": ["numpy==1.19.1"], "codeowners": [], "quality_scale": "internal" } diff --git a/requirements_all.txt b/requirements_all.txt index 0b25ccc63c6..b15a653f05f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -981,7 +981,7 @@ numato-gpio==0.8.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.19.0 +numpy==1.19.1 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1002,7 +1002,7 @@ onvif-zeep-async==0.4.0 open-garage==0.1.4 # homeassistant.components.opencv -# opencv-python-headless==4.2.0.32 +# opencv-python-headless==4.3.0.36 # homeassistant.components.openerz openerz-api==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 814b422ee7d..c38f78a2877 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -461,7 +461,7 @@ numato-gpio==0.8.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.19.0 +numpy==1.19.1 # homeassistant.components.google oauth2client==4.0.0 From 6e31a2e67d9bc2bb59bd4ef8b1a1ffd4a6e9021a Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Thu, 6 Aug 2020 18:47:39 -0400 Subject: [PATCH 332/362] Expose video doorbell button state to HomeKit (#38617) --- homeassistant/components/homekit/const.py | 1 + homeassistant/components/homekit/type_cameras.py | 11 +++++++++++ tests/components/homekit/test_type_cameras.py | 13 +++++++++++++ 3 files changed, 25 insertions(+) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index e38b86a7032..d8eec057191 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -129,6 +129,7 @@ SERV_OUTLET = "Outlet" SERV_SECURITY_SYSTEM = "SecuritySystem" SERV_SMOKE_SENSOR = "SmokeSensor" SERV_SPEAKER = "Speaker" +SERV_STATELESS_PROGRAMMABLE_SWITCH = "StatelessProgrammableSwitch" SERV_SWITCH = "Switch" SERV_TELEVISION = "Television" SERV_TELEVISION_SPEAKER = "TelevisionSpeaker" diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 93b822f9e7a..91b13a93eca 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -54,6 +54,7 @@ from .const import ( SERV_DOORBELL, SERV_MOTION_SENSOR, SERV_SPEAKER, + SERV_STATELESS_PROGRAMMABLE_SWITCH, ) from .img_util import scale_jpeg_camera_image from .util import pid_is_alive @@ -211,6 +212,7 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_motion_state(state) self._char_doorbell_detected = None + self._char_doorbell_detected_switch = None self.linked_doorbell_sensor = self.config.get(CONF_LINKED_DOORBELL_SENSOR) if self.linked_doorbell_sensor: state = self.hass.states.get(self.linked_doorbell_sensor) @@ -220,6 +222,14 @@ class Camera(HomeAccessory, PyhapCamera): self._char_doorbell_detected = serv_doorbell.configure_char( CHAR_PROGRAMMABLE_SWITCH_EVENT, value=0, ) + serv_stateless_switch = self.add_preload_service( + SERV_STATELESS_PROGRAMMABLE_SWITCH + ) + self._char_doorbell_detected_switch = serv_stateless_switch.configure_char( + CHAR_PROGRAMMABLE_SWITCH_EVENT, + value=0, + valid_values={"SinglePress": DOORBELL_SINGLE_PRESS}, + ) serv_speaker = self.add_preload_service(SERV_SPEAKER) serv_speaker.configure_char(CHAR_MUTE, value=0) @@ -282,6 +292,7 @@ class Camera(HomeAccessory, PyhapCamera): if new_state.state == STATE_ON: self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) + self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS) _LOGGER.debug( "%s: Set linked doorbell %s sensor to %d", self.entity_id, diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9e8faa34d38..118ce2d9934 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -21,6 +21,7 @@ from homeassistant.components.homekit.const import ( DEVICE_CLASS_OCCUPANCY, SERV_DOORBELL, SERV_MOTION_SENSOR, + SERV_STATELESS_PROGRAMMABLE_SWITCH, VIDEO_CODEC_COPY, VIDEO_CODEC_H264_OMX, ) @@ -653,18 +654,28 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): assert char.value == 0 + service2 = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) + assert service2 + char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) + assert char2 + + assert char2.value == 0 + hass.states.async_set( doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} ) await hass.async_block_till_done() assert char.value == 0 + assert char2.value == 0 char.set_value(True) + char2.set_value(True) hass.states.async_set( doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} ) await hass.async_block_till_done() assert char.value == 0 + assert char2.value == 0 # Ensure we do not throw when the linked # doorbell sensor is removed @@ -673,6 +684,7 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await acc.run_handler() await hass.async_block_till_done() assert char.value == 0 + assert char2.value == 0 async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, events): @@ -703,3 +715,4 @@ async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, ev assert acc.category == 17 # Camera assert not acc.get_service(SERV_DOORBELL) + assert not acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) From 30e1ff83b9babcc9f96f8f2890d45674da6189e2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Aug 2020 01:40:28 -0500 Subject: [PATCH 333/362] Ensure doorbird does not block startup (#38619) --- homeassistant/components/doorbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 58311fa65e4..23495a22bf8 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -2,7 +2,7 @@ "domain": "doorbird", "name": "DoorBird", "documentation": "https://www.home-assistant.io/integrations/doorbird", - "requirements": ["doorbirdpy==2.0.8"], + "requirements": ["doorbirdpy==2.1.0"], "dependencies": ["http"], "zeroconf": ["_axis-video._tcp.local."], "codeowners": ["@oblogic7", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index b15a653f05f..34cdaf215db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -491,7 +491,7 @@ distro==1.5.0 dlipower==0.7.165 # homeassistant.components.doorbird -doorbirdpy==2.0.8 +doorbirdpy==2.1.0 # homeassistant.components.dovado dovado==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c38f78a2877..e13dcd5b584 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -252,7 +252,7 @@ directv==0.3.0 distro==1.5.0 # homeassistant.components.doorbird -doorbirdpy==2.0.8 +doorbirdpy==2.1.0 # homeassistant.components.dsmr dsmr_parser==0.18 From b626368b6a65ad9aab4dd4cc0abb554a7db3fd97 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Aug 2020 08:45:05 +0000 Subject: [PATCH 334/362] Bumped version to 0.114.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2b893eb84fe..6158e5c67e2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 114 -PATCH_VERSION = "0b1" +PATCH_VERSION = "0b2" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) From 214bc81d023cce47f02db5d426fa9330bd520a43 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 7 Aug 2020 08:40:50 +0000 Subject: [PATCH 335/362] Fix lint --- homeassistant/components/tensorflow/image_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index d6d20c63f56..420c3403a11 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -7,7 +7,7 @@ import time from PIL import Image, ImageDraw, UnidentifiedImageError import numpy as np -import tensorflow as tf +import tensorflow as tf # pylint: disable=import-error import voluptuous as vol from homeassistant.components.image_processing import ( From a92bc562d326cefc9b403497e1b293a75857cd5f Mon Sep 17 00:00:00 2001 From: Thomas Hollstegge Date: Sun, 9 Aug 2020 04:34:14 +0200 Subject: [PATCH 336/362] Make sure groups are initialized before template sensors (#37766) * Make sure groups are initialized before template sensors This way users may use the `expand` function in templates to expand groups and have HA listen for changes to group members. Fixes #35872 * Patch async_setup_platform instead of async_setup * Cleanup * Use an event to avoid sleep * Update tests/components/template/test_sensor.py Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../components/template/manifest.json | 3 +- tests/components/template/test_sensor.py | 46 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 4ad03db22bb..dd2f8d1e0c6 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -3,5 +3,6 @@ "name": "Template", "documentation": "https://www.home-assistant.io/integrations/template", "codeowners": ["@PhracturedBlue", "@tetienne"], - "quality_scale": "internal" + "quality_scale": "internal", + "after_dependencies": ["group"] } diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 8a3a731f953..3899a7b3afe 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,11 +1,16 @@ """The test for the Template sensor platform.""" +from asyncio import Event +from unittest.mock import patch + +from homeassistant.bootstrap import async_from_config_dict from homeassistant.const import ( + EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.setup import async_setup_component, setup_component +from homeassistant.setup import ATTR_COMPONENT, async_setup_component, setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -438,6 +443,45 @@ class TestTemplateSensor: ) +async def test_creating_sensor_loads_group(hass): + """Test setting up template sensor loads group component first.""" + order = [] + after_dep_event = Event() + + async def async_setup_group(hass, config): + # Make sure group takes longer to load, so that it won't + # be loaded first by chance + await after_dep_event.wait() + + order.append("group") + return True + + async def async_setup_template( + hass, config, async_add_entities, discovery_info=None + ): + order.append("sensor.template") + return True + + async def set_after_dep_event(event): + if event.data[ATTR_COMPONENT] == "sensor": + after_dep_event.set() + + hass.bus.async_listen(EVENT_COMPONENT_LOADED, set_after_dep_event) + + with patch( + "homeassistant.components.group.async_setup", new=async_setup_group, + ), patch( + "homeassistant.components.template.sensor.async_setup_platform", + new=async_setup_template, + ): + await async_from_config_dict( + {"sensor": {"platform": "template", "sensors": {}}, "group": {}}, hass + ) + await hass.async_block_till_done() + + assert order == ["group", "sensor.template"] + + async def test_available_template_with_entities(hass): """Test availability tempalates with values from other entities.""" hass.states.async_set("sensor.availability_sensor", STATE_OFF) From a58a67923b3246c44a07bd6c753826140434ede8 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 8 Aug 2020 13:59:53 +0200 Subject: [PATCH 337/362] Fix xiaomi_aqara discovery (#38622) --- homeassistant/components/xiaomi_aqara/config_flow.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index fb66be76635..c42598c2665 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -75,8 +75,9 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.interface = user_input[CONF_INTERFACE] # allow optional manual setting of host and mac - if self.host is None and self.sid is None: + if self.host is None: self.host = user_input.get(CONF_HOST) + if self.sid is None: mac_address = user_input.get(CONF_MAC) # format sid from mac_address @@ -173,7 +174,9 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): unique_id = mac_address await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + self._abort_if_unique_id_configured( + {CONF_HOST: self.host, CONF_MAC: mac_address} + ) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 self.context.update({"title_placeholders": {"name": self.host}}) From ab9df350fd1c9555e6147807b064c86eec435af2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 8 Aug 2020 20:00:56 +0200 Subject: [PATCH 338/362] Update frontend to 20200807.1 (#38626) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c4d11297e9b..aaab3ac570c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200805.0"], + "requirements": ["home-assistant-frontend==20200807.1"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0339c779291..6b4deee3a3e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 hass-nabucasa==0.35.0 -home-assistant-frontend==20200805.0 +home-assistant-frontend==20200807.1 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index 34cdaf215db..bb00e05c200 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -733,7 +733,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200805.0 +home-assistant-frontend==20200807.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e13dcd5b584..a9838bef8c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200805.0 +home-assistant-frontend==20200807.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From eac5619001b65394253689cd0780a38ca2857551 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Aug 2020 17:37:31 +0200 Subject: [PATCH 339/362] Remove tf-models-official from wheels builder (#38637) --- azure-pipelines-wheels.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index ebe704f12e2..c8943595429 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -89,6 +89,5 @@ jobs: sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} sed -i "s|# bme680|bme680|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} - sed -i "s|# tf-models-official|tf-models-official|g" ${requirement_file} done displayName: 'Prepare requirements files for Home Assistant wheels' From 95ffe122645194f313dda7d2c7f81c0cbe378ba6 Mon Sep 17 00:00:00 2001 From: Ole-Martin Heggen Date: Fri, 7 Aug 2020 22:14:42 +0200 Subject: [PATCH 340/362] Fix url in seventeentrack delivered notification (#38646) --- homeassistant/components/seventeentrack/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 53b16944cb2..42b198d48d9 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -259,7 +259,7 @@ class SeventeenTrackPackageSensor(Entity): self._friendly_name if self._friendly_name else self._tracking_number ) message = NOTIFICATION_DELIVERED_MESSAGE.format( - self._tracking_number, identification + identification, self._tracking_number ) title = NOTIFICATION_DELIVERED_TITLE.format(identification) notification_id = NOTIFICATION_DELIVERED_TITLE.format(self._tracking_number) From 24c3cbfff9c5106b50af39af74db3774c15d17c4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 7 Aug 2020 18:01:55 -0600 Subject: [PATCH 341/362] Bump regenmaschine to 2.1.0 (#38649) --- homeassistant/components/rainmachine/__init__.py | 2 +- homeassistant/components/rainmachine/config_flow.py | 6 +++--- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/rainmachine/test_config_flow.py | 7 +++---- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 2e32d0ed43d..239878d0219 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -133,7 +133,7 @@ async def async_setup_entry(hass, config_entry): _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) - client = Client(websession) + client = Client(session=websession) try: await client.load_local( diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index dc1ee16d05f..d0513ac89fb 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -1,5 +1,5 @@ """Config flow to configure the RainMachine component.""" -from regenmaschine import login +from regenmaschine import Client from regenmaschine.errors import RainMachineError import voluptuous as vol @@ -59,12 +59,12 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(session=websession) try: - await login( + await client.load_local( user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], - websession, port=user_input[CONF_PORT], ssl=user_input.get(CONF_SSL, True), ) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index aed0f030c25..07321801381 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,6 +3,6 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==1.5.1"], + "requirements": ["regenmaschine==2.1.0"], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index bb00e05c200..59788525fbf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1876,7 +1876,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.5.1 +regenmaschine==2.1.0 # homeassistant.components.python_script restrictedpython==5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9838bef8c9..4b123504a40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -842,7 +842,7 @@ pyzerproc==0.2.5 rachiopy==0.1.3 # homeassistant.components.rainmachine -regenmaschine==1.5.1 +regenmaschine==2.1.0 # homeassistant.components.python_script restrictedpython==5.0 diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 04dc67bdbe8..7b27bdf2f39 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -50,8 +50,7 @@ async def test_invalid_password(hass): flow.context = {"source": SOURCE_USER} with patch( - "homeassistant.components.rainmachine.config_flow.login", - side_effect=RainMachineError, + "regenmaschine.client.Client.load_local", side_effect=RainMachineError, ): result = await flow.async_step_user(user_input=conf) assert result["errors"] == {CONF_PASSWORD: "invalid_credentials"} @@ -84,7 +83,7 @@ async def test_step_import(hass): flow.context = {"source": SOURCE_USER} with patch( - "homeassistant.components.rainmachine.config_flow.login", return_value=True, + "regenmaschine.client.Client.load_local", return_value=True, ): result = await flow.async_step_import(import_config=conf) @@ -115,7 +114,7 @@ async def test_step_user(hass): flow.context = {"source": SOURCE_USER} with patch( - "homeassistant.components.rainmachine.config_flow.login", return_value=True, + "regenmaschine.client.Client.load_local", return_value=True, ): result = await flow.async_step_user(user_input=conf) From 43961dc36b5a5877289c60cdadc851b599bd583c Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 7 Aug 2020 23:50:08 -0500 Subject: [PATCH 342/362] Fix AccuWeather async timeout (#38654) --- homeassistant/components/accuweather/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index 3dbb713ab2b..1e1a434a036 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -114,7 +114,7 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self): """Update data via library.""" try: - with timeout(10): + async with timeout(10): current = await self.accuweather.async_get_current_conditions() forecast = ( await self.accuweather.async_get_forecast(metric=self.is_metric) From aa4e879e1a526cf23c3c0108e08308693b8c3a81 Mon Sep 17 00:00:00 2001 From: Alejandro Rivera Date: Sat, 8 Aug 2020 01:56:39 -0700 Subject: [PATCH 343/362] Fix rest_command UnboundLocalError in exception handling (#38656) ``` 2020-08-07 22:38:10 ERROR (MainThread) [homeassistant.components.websocket_api.http.connection.3903193064] local variable 'response' referenced before assignment Traceback (most recent call last): File "/usr/src/homeassistant/homeassistant/components/rest_command/__init__.py", line 115, in async_service_handler async with getattr(websession, method)( File "/usr/local/lib/python3.8/site-packages/aiohttp/client.py", line 1012, in __aenter__ self._resp = await self._coro File "/usr/local/lib/python3.8/site-packages/aiohttp/client.py", line 582, in _request break File "/usr/local/lib/python3.8/site-packages/aiohttp/helpers.py", line 586, in __exit__ raise asyncio.TimeoutError from None asyncio.exceptions.TimeoutError During handling of the above exception, another exception occurred: Traceback (most recent call last): File "/usr/src/homeassistant/homeassistant/components/websocket_api/commands.py", line 125, in handle_call_service await hass.services.async_call( File "/usr/src/homeassistant/homeassistant/core.py", line 1281, in async_call task.result() File "/usr/src/homeassistant/homeassistant/core.py", line 1316, in _execute_service await handler.func(service_call) File "/usr/src/homeassistant/homeassistant/components/rest_command/__init__.py", line 137, in async_service_handler _LOGGER.warning("Timeout call %s", response.url, exc_info=1) UnboundLocalError: local variable 'response' referenced before assignment ``` --- homeassistant/components/rest_command/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index f8b99c48a44..1290912897d 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -134,7 +134,7 @@ async def async_setup(hass, config): ) except asyncio.TimeoutError: - _LOGGER.warning("Timeout call %s", response.url, exc_info=1) + _LOGGER.warning("Timeout call %s", request_url, exc_info=1) except aiohttp.ClientError: _LOGGER.error("Client error %s", request_url, exc_info=1) From f1e3023d44f58e2a37144e2994382593588bd9d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Aug 2020 15:37:05 -0500 Subject: [PATCH 344/362] Ensure shared zeroconf is passed to homekit controller devices (#38678) --- homeassistant/components/homekit_controller/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 47f3cf20571..4a8730b2e9e 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -11,6 +11,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes +from homeassistant.components import zeroconf from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity @@ -212,7 +213,8 @@ async def async_setup(hass, config): map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - hass.data[CONTROLLER] = aiohomekit.Controller() + zeroconf_instance = await zeroconf.async_get_instance(hass) + hass.data[CONTROLLER] = aiohomekit.Controller(zeroconf_instance=zeroconf_instance) hass.data[KNOWN_DEVICES] = {} return True From 65450d85186e54f50a3bcad9704ecf8b0b2ce479 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Aug 2020 06:29:46 -0500 Subject: [PATCH 345/362] Update aiohomekit to handle homekit devices that do not send format (#38679) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9bbaf959012..4d37a38e417 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.45"], + "requirements": ["aiohomekit[IP]==0.2.46"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k"] diff --git a/requirements_all.txt b/requirements_all.txt index 59788525fbf..2fb6a9ffa08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -173,7 +173,7 @@ aioguardian==1.0.1 aioharmony==0.2.6 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.45 +aiohomekit[IP]==0.2.46 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b123504a40..764e573b8b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -98,7 +98,7 @@ aioguardian==1.0.1 aioharmony==0.2.6 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.45 +aiohomekit[IP]==0.2.46 # homeassistant.components.emulated_hue # homeassistant.components.http From 6f5884805e61dce364b0fe9314a17e732a29aced Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 8 Aug 2020 21:00:55 -0600 Subject: [PATCH 346/362] Fix missing data for Guardian "AP enabled" binary sensor (#38681) --- homeassistant/components/guardian/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 495f325eb7f..c63d80163bc 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -90,7 +90,7 @@ class GuardianBinarySensor(GuardianEntity, BinarySensorEntity): def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_AP_INFO: - self._is_on = self._coordinators[API_WIFI_STATUS].data["ap_enabled"] + self._is_on = self._coordinators[API_WIFI_STATUS].data["station_connected"] self._attrs.update( { ATTR_CONNECTED_CLIENTS: self._coordinators[API_WIFI_STATUS].data[ From cb51a00c378d1c5c61e5cda50c2eb914d33c427e Mon Sep 17 00:00:00 2001 From: On Freund Date: Sun, 9 Aug 2020 08:02:21 +0300 Subject: [PATCH 347/362] Bump pyvolumio to 0.1.1 (#38685) --- homeassistant/components/volumio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json index 95d84fd7ee6..c5d14859f05 100644 --- a/homeassistant/components/volumio/manifest.json +++ b/homeassistant/components/volumio/manifest.json @@ -5,5 +5,5 @@ "codeowners": ["@OnFreund"], "config_flow": true, "zeroconf": ["_Volumio._tcp.local."], - "requirements": ["pyvolumio==0.1"] + "requirements": ["pyvolumio==0.1.1"] } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 2fb6a9ffa08..ba9f91df2cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1831,7 +1831,7 @@ pyvizio==0.1.49 pyvlx==0.2.16 # homeassistant.components.volumio -pyvolumio==0.1 +pyvolumio==0.1.1 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 764e573b8b2..73f86625d5b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -830,7 +830,7 @@ pyvesync==1.1.0 pyvizio==0.1.49 # homeassistant.components.volumio -pyvolumio==0.1 +pyvolumio==0.1.1 # homeassistant.components.html5 pywebpush==1.9.2 From c0be9aca48cce485c979fcd7e80f6fb3d272818d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 9 Aug 2020 11:30:45 +0000 Subject: [PATCH 348/362] Bumped version to 0.114.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6158e5c67e2..a79d95c0b5f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 114 -PATCH_VERSION = "0b2" +PATCH_VERSION = "0b3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) From 7f0c97fad857148f8b21bd36e6336fbfce0c9dc7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 9 Aug 2020 14:10:27 +0200 Subject: [PATCH 349/362] Bump updater timeout (#38690) --- homeassistant/components/updater/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 59f858f7cf4..d90efe132f6 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -134,7 +134,7 @@ async def get_newest_version(hass, huuid, include_components): session = async_get_clientsession(hass) - with async_timeout.timeout(15): + with async_timeout.timeout(30): req = await session.post(UPDATER_URL, json=info_object) _LOGGER.info( From e8587408aef0b2d0488f6b85a9fb9af172d36584 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 10 Aug 2020 13:12:23 +0200 Subject: [PATCH 350/362] Update base image 8.2.1 (#38716) --- build.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.json b/build.json index 279a58f4c5a..a1db6ac2a54 100644 --- a/build.json +++ b/build.json @@ -1,11 +1,11 @@ { "image": "homeassistant/{arch}-homeassistant", "build_from": { - "aarch64": "homeassistant/aarch64-homeassistant-base:8.1.0", - "armhf": "homeassistant/armhf-homeassistant-base:8.1.0", - "armv7": "homeassistant/armv7-homeassistant-base:8.1.0", - "amd64": "homeassistant/amd64-homeassistant-base:8.1.0", - "i386": "homeassistant/i386-homeassistant-base:8.1.0" + "aarch64": "homeassistant/aarch64-homeassistant-base:8.2.1", + "armhf": "homeassistant/armhf-homeassistant-base:8.2.1", + "armv7": "homeassistant/armv7-homeassistant-base:8.2.1", + "amd64": "homeassistant/amd64-homeassistant-base:8.2.1", + "i386": "homeassistant/i386-homeassistant-base:8.2.1" }, "labels": { "io.hass.type": "core" From e3335eea007d8a67628d2be1b9a62ed110bda327 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Aug 2020 12:49:12 +0000 Subject: [PATCH 351/362] Bumped version to 0.114.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a79d95c0b5f..6a398512265 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 114 -PATCH_VERSION = "0b3" +PATCH_VERSION = "0b4" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1) From db646141c6d314a9fc583ae3dd38cbfe242364d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 10 Aug 2020 17:54:46 +0200 Subject: [PATCH 352/362] Add scan_tag webhook to mobile app (#38721) --- .../components/mobile_app/webhook.py | 12 +++++++++ tests/components/mobile_app/test_webhook.py | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index ca9c31011ed..d03505b0cb9 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -538,3 +538,15 @@ async def webhook_get_config(hass, config_entry, data): pass return webhook_response(resp, registration=config_entry.data) + + +@WEBHOOK_COMMANDS.register("scan_tag") +@validate_schema({vol.Required("tag_id"): cv.string}) +async def webhook_scan_tag(hass, config_entry, data): + """Handle a fire event webhook.""" + hass.bus.async_fire( + "tag_scanned", + {"tag_id": data["tag_id"], "device_id": config_entry.data[ATTR_DEVICE_ID]}, + context=registration_context(config_entry.data), + ) + return empty_okay_response() diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 195c60d830c..bd38bca535b 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -406,3 +406,28 @@ async def test_webhook_camera_stream_stream_available_but_errors( webhook_json = await resp.json() assert webhook_json["hls_path"] is None assert webhook_json["mjpeg_path"] == "/api/camera_proxy_stream/camera.stream_camera" + + +async def test_webhook_handle_scan_tag(hass, create_registrations, webhook_client): + """Test that we can scan tags.""" + events = [] + + @callback + def store_event(event): + """Helepr to store events.""" + events.append(event) + + hass.bus.async_listen("tag_scanned", store_event) + + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={"type": "scan_tag", "data": {"tag_id": "mock-tag-id"}}, + ) + + assert resp.status == 200 + json = await resp.json() + assert json == {} + + assert len(events) == 1 + assert events[0].data["tag_id"] == "mock-tag-id" + assert events[0].data["device_id"] == "mock-device-id" From 57e99af9ecdcc0782b07ce373846a7d44d4c18c8 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 10 Aug 2020 16:34:52 +0200 Subject: [PATCH 353/362] Add scikit-build to installed env (#38726) --- azure-pipelines-wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index c8943595429..3e7821d77af 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -48,7 +48,7 @@ jobs: parameters: builderVersion: '$(versionWheels)' builderApk: 'build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev' - builderPip: 'Cython;numpy' + builderPip: 'Cython;numpy;scikit-build' skipBinary: 'aiohttp' wheelsRequirement: 'requirements_wheels.txt' wheelsRequirementDiff: 'requirements_diff.txt' From 6717e67352df7417124023401c3c69a7f1c21573 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 10 Aug 2020 15:32:00 -0500 Subject: [PATCH 354/362] Bump pysmartthings 0.7.3 (#38732) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index bf137ae398d..58ea833cb7d 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -3,7 +3,7 @@ "name": "SmartThings", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smartthings", - "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.2"], + "requirements": ["pysmartapp==0.3.2", "pysmartthings==0.7.3"], "dependencies": ["webhook"], "after_dependencies": ["cloud"], "codeowners": ["@andrewsayre"] diff --git a/requirements_all.txt b/requirements_all.txt index ba9f91df2cd..a7a3da1dd0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1626,7 +1626,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.2 +pysmartthings==0.7.3 # homeassistant.components.smarty pysmarty==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73f86625d5b..80ff12c16ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -755,7 +755,7 @@ pysmappee==0.1.5 pysmartapp==0.3.2 # homeassistant.components.smartthings -pysmartthings==0.7.2 +pysmartthings==0.7.3 # homeassistant.components.soma pysoma==0.0.10 From 48617534e956c11c5c6df046ab428c7107896315 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 10 Aug 2020 17:40:07 -0400 Subject: [PATCH 355/362] Make default duration 1/10th of a second for ZHA light calls (#38739) * default duration to 1/10th of a second * update test --- homeassistant/components/zha/light.py | 2 +- tests/components/zha/test_light.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 51b0633ecf2..ff080562190 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -192,7 +192,7 @@ class BaseLight(LogMixin, light.LightEntity): async def async_turn_on(self, **kwargs): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) - duration = transition * 10 if transition else 0 + duration = transition * 10 if transition else 1 brightness = kwargs.get(light.ATTR_BRIGHTNESS) effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 6b94354ed59..bd340445527 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -403,7 +403,7 @@ async def async_test_level_on_off_from_hass( 4, (zigpy.types.uint8_t, zigpy.types.uint16_t), 10, - 0, + 1, expect_reply=True, manufacturer=None, tsn=None, From eada72e2e119e2fd4a272886567388c7437a1078 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Aug 2020 02:45:36 -0500 Subject: [PATCH 356/362] Install a threading.excepthook on python 3.8 and later (#38741) Exceptions in threads were being silently discarded and never logged as the new in python 3.8 threading.excepthook was not being set. --- homeassistant/bootstrap.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4cf95d68f05..a7953cbec6c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -6,6 +6,7 @@ import logging import logging.handlers import os import sys +import threading from time import monotonic from typing import TYPE_CHECKING, Any, Dict, Optional, Set @@ -308,6 +309,12 @@ def async_enable_logging( "Uncaught exception", exc_info=args # type: ignore ) + if sys.version_info[:2] >= (3, 8): + threading.excepthook = lambda args: logging.getLogger(None).exception( + "Uncaught thread exception", + exc_info=(args.exc_type, args.exc_value, args.exc_traceback), + ) + # Log errors to a file if we have write access to file or config dir if log_file is None: err_log_path = hass.config.path(ERROR_LOG_FILENAME) From d1e6fce6528e6cebfe3c22fc683b7ce34f1e5ce4 Mon Sep 17 00:00:00 2001 From: etheralm Date: Tue, 11 Aug 2020 14:19:10 +0200 Subject: [PATCH 357/362] Bump dyson upstream library version (#38756) --- homeassistant/components/dyson/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json index 35a76180e2e..94a29d1615d 100644 --- a/homeassistant/components/dyson/manifest.json +++ b/homeassistant/components/dyson/manifest.json @@ -2,7 +2,7 @@ "domain": "dyson", "name": "Dyson", "documentation": "https://www.home-assistant.io/integrations/dyson", - "requirements": ["libpurecool==0.6.1"], + "requirements": ["libpurecool==0.6.3"], "after_dependencies": ["zeroconf"], "codeowners": ["@etheralm"] } diff --git a/requirements_all.txt b/requirements_all.txt index a7a3da1dd0e..5ad4de4c823 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -824,7 +824,7 @@ konnected==1.1.0 lakeside==0.12 # homeassistant.components.dyson -libpurecool==0.6.1 +libpurecool==0.6.3 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80ff12c16ae..584874736ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -403,7 +403,7 @@ keyrings.alt==3.4.0 konnected==1.1.0 # homeassistant.components.dyson -libpurecool==0.6.1 +libpurecool==0.6.3 # homeassistant.components.mikrotik librouteros==3.0.0 From 7722bb05bcfebafdffbddf5278bda85d2a9a124b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 11 Aug 2020 15:45:07 +0200 Subject: [PATCH 358/362] Bump frontend to 20200811.0 (#38760) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index aaab3ac570c..da11f574ade 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200807.1"], + "requirements": ["home-assistant-frontend==20200811.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b4deee3a3e..e709bf68aa4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==0.5.4 hass-nabucasa==0.35.0 -home-assistant-frontend==20200807.1 +home-assistant-frontend==20200811.0 importlib-metadata==1.6.0;python_version<'3.8' jinja2>=2.11.1 netdisco==2.8.1 diff --git a/requirements_all.txt b/requirements_all.txt index 5ad4de4c823..d58700527f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -733,7 +733,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200807.1 +home-assistant-frontend==20200811.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 584874736ee..f7840e59b4d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ hole==0.5.1 holidays==0.10.3 # homeassistant.components.frontend -home-assistant-frontend==20200807.1 +home-assistant-frontend==20200811.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From e60e82c42428a0a9c3eef6ca1011773d2a6bcc3e Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 11 Aug 2020 11:14:02 -0400 Subject: [PATCH 359/362] Bump ZHA quirks lib to 0.0.43 (#38762) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 974cac32c33..3b123c53598 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -6,7 +6,7 @@ "requirements": [ "bellows==0.18.0", "pyserial==3.4", - "zha-quirks==0.0.42", + "zha-quirks==0.0.43", "zigpy-cc==0.4.4", "zigpy-deconz==0.9.2", "zigpy==0.22.2", diff --git a/requirements_all.txt b/requirements_all.txt index d58700527f9..35e7f615694 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2272,7 +2272,7 @@ zengge==0.2 zeroconf==0.28.0 # homeassistant.components.zha -zha-quirks==0.0.42 +zha-quirks==0.0.43 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7840e59b4d..1b39ec6a51b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1008,7 +1008,7 @@ yeelight==0.5.2 zeroconf==0.28.0 # homeassistant.components.zha -zha-quirks==0.0.42 +zha-quirks==0.0.43 # homeassistant.components.zha zigpy-cc==0.4.4 From c0730a519af67d9ff5ba7adce00eed4539fef265 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 12 Aug 2020 08:00:38 +0200 Subject: [PATCH 360/362] Fix lastest version in updater for Supervisor enabled installs (#38773) * Fix lastest version in update for Supervisor enabled installs * Fix updater tests --- homeassistant/components/hassio/__init__.py | 24 ++++++++++---------- homeassistant/components/hassio/handler.py | 8 +++++++ homeassistant/components/updater/__init__.py | 5 ++-- tests/components/hassio/test_handler.py | 24 ++++++++++++++++++++ tests/components/hassio/test_init.py | 20 +++++++++------- tests/components/updater/test_init.py | 7 +----- 6 files changed, 60 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index f64461f70d3..69c53225d49 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -44,6 +44,7 @@ CONFIG_SCHEMA = vol.Schema( DATA_INFO = "hassio_info" DATA_HOST_INFO = "hassio_host_info" +DATA_CORE_INFO = "hassio_core_info" HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) SERVICE_ADDON_START = "addon_start" @@ -140,18 +141,6 @@ async def async_get_addon_info(hass: HomeAssistantType, addon_id: str) -> dict: return result["data"] -@callback -@bind_hass -def get_homeassistant_version(hass): - """Return latest available Home Assistant version. - - Async friendly. - """ - if DATA_INFO not in hass.data: - return None - return hass.data[DATA_INFO].get("homeassistant") - - @callback @bind_hass def get_info(hass): @@ -172,6 +161,16 @@ def get_host_info(hass): return hass.data.get(DATA_HOST_INFO) +@callback +@bind_hass +def get_core_info(hass): + """Return Home Assistant Core information from Supervisor. + + Async friendly. + """ + return hass.data.get(DATA_CORE_INFO) + + @callback @bind_hass def is_hassio(hass): @@ -301,6 +300,7 @@ async def async_setup(hass, config): try: hass.data[DATA_INFO] = await hassio.get_info() hass.data[DATA_HOST_INFO] = await hassio.get_host_info() + hass.data[DATA_CORE_INFO] = await hassio.get_core_info() except HassioAPIError as err: _LOGGER.warning("Can't read last version: %s", err) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 861056a46e4..e96ed613324 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -82,6 +82,14 @@ class HassIO: """ return self.send_command("/host/info", method="get") + @_api_data + def get_core_info(self): + """Return data for Home Asssistant Core. + + This method returns a coroutine. + """ + return self.send_command("/core/info", method="get") + @_api_data def get_addon_info(self, addon): """Return data for a Add-on. diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index d90efe132f6..f3c9483e4a8 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -76,9 +76,10 @@ async def async_setup(hass, config): if "dev" in current_version: return Updater(False, "", "") - # Load data from supervisor on Hass.io + # Load data from Supervisor if hass.components.hassio.is_hassio(): - newest = hass.components.hassio.get_homeassistant_version() + core_info = hass.components.hassio.get_core_info() + newest = core_info["version_latest"] # Validate version update_available = False diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 67fcfb75d5f..311fc6c7e8c 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -92,6 +92,30 @@ async def test_api_host_info_error(hassio_handler, aioclient_mock): assert aioclient_mock.call_count == 1 +async def test_api_core_info(hassio_handler, aioclient_mock): + """Test setup with API Home Assistant Core info.""" + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) + + data = await hassio_handler.get_core_info() + assert aioclient_mock.call_count == 1 + assert data["version_latest"] == "1.0.0" + + +async def test_api_core_info_error(hassio_handler, aioclient_mock): + """Test setup with API Home Assistant Core info error.""" + aioclient_mock.get( + "http://127.0.0.1/core/info", json={"result": "error", "message": None} + ) + + with pytest.raises(HassioAPIError): + await hassio_handler.get_core_info() + + assert aioclient_mock.call_count == 1 + + async def test_api_homeassistant_stop(hassio_handler, aioclient_mock): """Test setup with API Home Assistant stop.""" aioclient_mock.post("http://127.0.0.1/homeassistant/stop", json={"result": "ok"}) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index d0043747835..34ba638410a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -40,6 +40,10 @@ def mock_all(aioclient_mock): }, }, ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0"}}, + ) aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) @@ -51,8 +55,8 @@ async def test_setup_api_ping(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {}) assert result - assert aioclient_mock.call_count == 6 - assert hass.components.hassio.get_homeassistant_version() == "0.110.0" + assert aioclient_mock.call_count == 7 + assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -90,7 +94,7 @@ async def test_setup_api_push_api_data(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -106,7 +110,7 @@ async def test_setup_api_push_api_data_server_host(hass, aioclient_mock): ) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -118,7 +122,7 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, hass_storag result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -165,7 +169,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, hass_storage result = await async_setup_component(hass, "hassio", {"http": {}, "hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -179,7 +183,7 @@ async def test_setup_core_push_timezone(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" await hass.config.async_update(time_zone="America/New_York") @@ -195,7 +199,7 @@ async def test_setup_hassio_no_additional_data(hass, aioclient_mock): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" diff --git a/tests/components/updater/test_init.py b/tests/components/updater/test_init.py index 203a4df8355..89ebf9e1bbb 100644 --- a/tests/components/updater/test_init.py +++ b/tests/components/updater/test_init.py @@ -154,12 +154,7 @@ async def test_new_version_shows_entity_after_hour_hassio( """Test if binary sensor gets updated if new version is available / Hass.io.""" mock_get_uuid.return_value = MOCK_HUUID mock_component(hass, "hassio") - hass.data["hassio_info"] = {"hassos": None, "homeassistant": "999.0"} - hass.data["hassio_host"] = { - "supervisor": "222", - "chassis": "vm", - "operating_system": "HassOS 4.6", - } + hass.data["hassio_core_info"] = {"version_latest": "999.0"} assert await async_setup_component(hass, updater.DOMAIN, {updater.DOMAIN: {}}) From f33d120ebf5d804f3b33daea255282c9fc244515 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 11 Aug 2020 20:41:49 -0400 Subject: [PATCH 361/362] Bump up ZHA dependencies (#38775) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 3b123c53598..b9d2caf0137 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.18.0", + "bellows==0.18.1", "pyserial==3.4", "zha-quirks==0.0.43", "zigpy-cc==0.4.4", diff --git a/requirements_all.txt b/requirements_all.txt index 35e7f615694..45acef0fd07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -328,7 +328,7 @@ beautifulsoup4==4.9.0 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows==0.18.0 +bellows==0.18.1 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b39ec6a51b..c1b4f560dc9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -175,7 +175,7 @@ azure-eventhub==5.1.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.18.0 +bellows==0.18.1 # homeassistant.components.blebox blebox_uniapi==1.3.2 From e3f10f977bde69535960bd22cf4a498dd3331ab0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 12 Aug 2020 10:58:46 +0200 Subject: [PATCH 362/362] Bumped version to 0.114.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6a398512265..2726181612a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 114 -PATCH_VERSION = "0b4" +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 1)