From f8f05c4d0d491e8d4c9b17a885fd71f1498017dd Mon Sep 17 00:00:00 2001 From: Jonathan Keljo Date: Thu, 2 Apr 2020 07:33:54 -0700 Subject: [PATCH 01/44] Enable sisyphus to recover from bad DNS without restart (#32846) --- homeassistant/components/sisyphus/__init__.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index 5ad59da5dee347..841fbb68178a50 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -99,18 +99,23 @@ def name(self): async def get_table(self): """Return the Table held by this holder, connecting to it if needed.""" + if self._table: + return self._table + if not self._table_task: self._table_task = self._hass.async_create_task(self._connect_table()) return await self._table_task async def _connect_table(self): - - self._table = await Table.connect(self._host, self._session) - if self._name is None: - self._name = self._table.name - _LOGGER.debug("Connected to %s at %s", self._name, self._host) - return self._table + try: + self._table = await Table.connect(self._host, self._session) + if self._name is None: + self._name = self._table.name + _LOGGER.debug("Connected to %s at %s", self._name, self._host) + return self._table + finally: + self._table_task = None async def close(self): """Close the table held by this holder, if any.""" From 23e091696e50e29034f4dde450f6ab20dad0803b Mon Sep 17 00:00:00 2001 From: cgtobi Date: Thu, 2 Apr 2020 17:35:25 +0200 Subject: [PATCH 02/44] Fix netatmo device unavailable and services (#33509) * Handle unavailabe entities * Remove some logging * Set valve to lowest temp when turned off * Remove some logging * Address comments * Report entity as connected if update is successful * Fix stupidness * Fix --- homeassistant/components/netatmo/climate.py | 59 ++++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 1f1b7088b295af..fe6526a16eb0a7 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -56,6 +56,7 @@ STATE_NETATMO_AWAY = PRESET_AWAY STATE_NETATMO_OFF = STATE_OFF STATE_NETATMO_MANUAL = "manual" +STATE_NETATMO_HOME = "home" PRESET_MAP_NETATMO = { PRESET_FROST_GUARD: STATE_NETATMO_HG, @@ -173,8 +174,11 @@ def __init__(self, data, room_id): self._support_flags = SUPPORT_FLAGS 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") + self._module_type = self._data.room_status.get(room_id, {}).get( + "module_type", NA_VALVE + ) if self._module_type == NA_THERM: self._operation_list.append(HVAC_MODE_OFF) @@ -252,25 +256,20 @@ def hvac_action(self) -> Optional[str]: def set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" - mode = None - if hvac_mode == HVAC_MODE_OFF: - mode = STATE_NETATMO_OFF + self.turn_off() elif hvac_mode == HVAC_MODE_AUTO: - mode = PRESET_SCHEDULE + if self.hvac_mode == HVAC_MODE_OFF: + self.turn_on() + self.set_preset_mode(PRESET_SCHEDULE) elif hvac_mode == HVAC_MODE_HEAT: - mode = PRESET_BOOST - - self.set_preset_mode(mode) + self.set_preset_mode(PRESET_BOOST) 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_MANUAL, - DEFAULT_MIN_TEMP, + self._data.home_id, self._room_id, STATE_NETATMO_HOME, ) if ( @@ -283,7 +282,7 @@ def set_preset_mode(self, preset_mode: str) -> None: STATE_NETATMO_MANUAL, DEFAULT_MAX_TEMP, ) - elif preset_mode in [PRESET_BOOST, STATE_NETATMO_MAX, STATE_NETATMO_OFF]: + 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] ) @@ -293,6 +292,7 @@ def set_preset_mode(self, preset_mode: str) -> None: ) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) + self.update_without_throttle = True self.schedule_update_ha_state() @@ -328,6 +328,35 @@ def device_state_attributes(self): 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, + ) + 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() + + 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() + + @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.""" try: @@ -355,12 +384,14 @@ def update(self): self._battery_level = self._data.room_status[self._room_id].get( "battery_level" ) + self._connected = True except KeyError as err: - _LOGGER.error( + _LOGGER.debug( "The thermostat in room %s seems to be out of reach. (%s)", self._room_name, err, ) + self._connected = False self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] From 314bc07cee616c9408bfa01b8db1fd6c8d71f6b1 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 2 Apr 2020 17:53:33 +0200 Subject: [PATCH 03/44] UniFi - Make POE control switches configurable (#32781) * Allow control whether POE switches are to be created or not * Fix options flow and test --- .../components/unifi/.translations/en.json | 3 +- homeassistant/components/unifi/config_flow.py | 6 ++ homeassistant/components/unifi/const.py | 2 + homeassistant/components/unifi/controller.py | 7 ++ homeassistant/components/unifi/strings.json | 3 +- homeassistant/components/unifi/switch.py | 97 +++++++++++-------- tests/components/unifi/test_config_flow.py | 3 + 7 files changed, 76 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json index 8fdde34470b088..0124ca1cc24412 100644 --- a/homeassistant/components/unifi/.translations/en.json +++ b/homeassistant/components/unifi/.translations/en.json @@ -32,7 +32,8 @@ "client_control": { "data": { "block_client": "Network access controlled clients", - "new_client": "Add new client for network access control" + "new_client": "Add new client (MAC) for network access control", + "poe_clients": "Allow POE control of clients" }, "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", "title": "UniFi options 2/3" diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index e0bb1c3bb9fcf3..781fcdeae828e8 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -19,12 +19,14 @@ CONF_BLOCK_CLIENT, CONF_CONTROLLER, CONF_DETECTION_TIME, + CONF_POE_CLIENTS, CONF_SITE_ID, CONF_SSID_FILTER, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, CONTROLLER_ID, + DEFAULT_POE_CLIENTS, DOMAIN, LOGGER, ) @@ -262,6 +264,10 @@ async def async_step_client_control(self, user_input=None): step_id="client_control", data_schema=vol.Schema( { + vol.Optional( + CONF_POE_CLIENTS, + default=self.options.get(CONF_POE_CLIENTS, DEFAULT_POE_CLIENTS), + ): bool, vol.Optional( CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT] ): cv.multi_select(clients_to_block), diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index fd94601db50b2e..4acf75fbdd9f56 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -14,12 +14,14 @@ CONF_ALLOW_BANDWIDTH_SENSORS = "allow_bandwidth_sensors" CONF_BLOCK_CLIENT = "block_client" CONF_DETECTION_TIME = "detection_time" +CONF_POE_CLIENTS = "poe_clients" CONF_TRACK_CLIENTS = "track_clients" CONF_TRACK_DEVICES = "track_devices" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_SSID_FILTER = "ssid_filter" DEFAULT_ALLOW_BANDWIDTH_SENSORS = False +DEFAULT_POE_CLIENTS = True DEFAULT_TRACK_CLIENTS = True DEFAULT_TRACK_DEVICES = True DEFAULT_TRACK_WIRED_CLIENTS = True diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 50b758f01af6e5..03e079c017070c 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -24,6 +24,7 @@ CONF_BLOCK_CLIENT, CONF_CONTROLLER, CONF_DETECTION_TIME, + CONF_POE_CLIENTS, CONF_SITE_ID, CONF_SSID_FILTER, CONF_TRACK_CLIENTS, @@ -32,6 +33,7 @@ CONTROLLER_ID, DEFAULT_ALLOW_BANDWIDTH_SENSORS, DEFAULT_DETECTION_TIME, + DEFAULT_POE_CLIENTS, DEFAULT_TRACK_CLIENTS, DEFAULT_TRACK_DEVICES, DEFAULT_TRACK_WIRED_CLIENTS, @@ -98,6 +100,11 @@ def option_block_clients(self): """Config entry option with list of clients to control network access.""" return self.config_entry.options.get(CONF_BLOCK_CLIENT, []) + @property + def option_poe_clients(self): + """Config entry option to control poe clients.""" + return self.config_entry.options.get(CONF_POE_CLIENTS, DEFAULT_POE_CLIENTS) + @property def option_track_clients(self): """Config entry option to not track clients.""" diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 58728225de7df9..61116adfb60ab5 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -43,7 +43,8 @@ "client_control": { "data": { "block_client": "Network access controlled clients", - "new_client": "Add new client for network access control" + "new_client": "Add new client for network access control", + "poe_clients": "Allow POE control of clients" }, "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", "title": "UniFi options 2/3" diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 0df019de02ccf6..0547e35f064bb0 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -30,6 +30,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): switches_off = [] option_block_clients = controller.option_block_clients + option_poe_clients = controller.option_poe_clients entity_registry = await hass.helpers.entity_registry.async_get_registry() @@ -66,6 +67,7 @@ def update_controller(): def options_updated(): """Manage entities affected by config entry options.""" nonlocal option_block_clients + nonlocal option_poe_clients update = set() remove = set() @@ -82,16 +84,26 @@ def options_updated(): else: remove.add(block_client_id) - for block_client_id in remove: - entity = switches.pop(block_client_id) + if option_poe_clients != controller.option_poe_clients: + option_poe_clients = controller.option_poe_clients - if entity_registry.async_is_registered(entity.entity_id): - entity_registry.async_remove(entity.entity_id) + if option_poe_clients: + update.add("poe_clients_enabled") + else: + for poe_client_id, entity in switches.items(): + if isinstance(entity, UniFiPOEClientSwitch): + remove.add(poe_client_id) - hass.async_create_task(entity.async_remove()) + for client_id in remove: + entity = switches.pop(client_id) - if len(update) != len(option_block_clients): - update_controller() + if entity_registry.async_is_registered(entity.entity_id): + entity_registry.async_remove(entity.entity_id) + + hass.async_create_task(entity.async_remove()) + + if len(update) != len(option_block_clients): + update_controller() controller.listeners.append( async_dispatcher_connect( @@ -109,7 +121,6 @@ def add_entities(controller, async_add_entities, switches, switches_off): new_switches = [] devices = controller.api.devices - # block client for client_id in controller.option_block_clients: client = None @@ -130,49 +141,49 @@ def add_entities(controller, async_add_entities, switches, switches_off): switches[block_client_id] = UniFiBlockClientSwitch(client, controller) new_switches.append(switches[block_client_id]) - # control POE - for client_id in controller.api.clients: + if controller.option_poe_clients: + for client_id in controller.api.clients: - poe_client_id = f"poe-{client_id}" - - if poe_client_id in switches: - continue + poe_client_id = f"poe-{client_id}" - client = controller.api.clients[client_id] - - if poe_client_id in switches_off: - pass - # Network device with active POE - elif ( - client_id in controller.wireless_clients - or client.sw_mac not in devices - or not devices[client.sw_mac].ports[client.sw_port].port_poe - or not devices[client.sw_mac].ports[client.sw_port].poe_enable - or controller.mac == client.mac - ): - continue + if poe_client_id in switches: + continue - # Multiple POE-devices on same port means non UniFi POE driven switch - multi_clients_on_port = False - for client2 in controller.api.clients.values(): + client = controller.api.clients[client_id] if poe_client_id in switches_off: - break - - if ( - client2.is_wired - and client.mac != client2.mac - and client.sw_mac == client2.sw_mac - and client.sw_port == client2.sw_port + pass + # Network device with active POE + elif ( + client_id in controller.wireless_clients + or client.sw_mac not in devices + or not devices[client.sw_mac].ports[client.sw_port].port_poe + or not devices[client.sw_mac].ports[client.sw_port].poe_enable + or controller.mac == client.mac ): - multi_clients_on_port = True - break + continue - if multi_clients_on_port: - continue + # Multiple POE-devices on same port means non UniFi POE driven switch + multi_clients_on_port = False + for client2 in controller.api.clients.values(): + + if poe_client_id in switches_off: + break + + if ( + client2.is_wired + and client.mac != client2.mac + and client.sw_mac == client2.sw_mac + and client.sw_port == client2.sw_port + ): + multi_clients_on_port = True + break + + if multi_clients_on_port: + continue - switches[poe_client_id] = UniFiPOEClientSwitch(client, controller) - new_switches.append(switches[poe_client_id]) + switches[poe_client_id] = UniFiPOEClientSwitch(client, controller) + new_switches.append(switches[poe_client_id]) if new_switches: async_add_entities(new_switches) diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 9a280ffe9e69b8..b89dbfeb700ebd 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -11,6 +11,7 @@ CONF_BLOCK_CLIENT, CONF_CONTROLLER, CONF_DETECTION_TIME, + CONF_POE_CLIENTS, CONF_SITE_ID, CONF_SSID_FILTER, CONF_TRACK_CLIENTS, @@ -287,6 +288,7 @@ async def test_option_flow(hass): user_input={ CONF_BLOCK_CLIENT: clients_to_block, CONF_NEW_CLIENT: "00:00:00:00:00:01", + CONF_POE_CLIENTS: False, }, ) @@ -327,5 +329,6 @@ async def test_option_flow(hass): CONF_DETECTION_TIME: 100, CONF_SSID_FILTER: ["SSID 1"], CONF_BLOCK_CLIENT: ["00:00:00:00:00:01"], + CONF_POE_CLIENTS: False, CONF_ALLOW_BANDWIDTH_SENSORS: True, } From 4ebbabcdd162046e71ea642220e5b7c4bb8e89f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Apr 2020 09:25:33 -0700 Subject: [PATCH 04/44] Unsub dispatcher when removing entity from hass (#33510) * Unsub dispatcher when removing entity from hass * Update homeassistant/components/plaato/sensor.py Co-Authored-By: Martin Hjelmare * Update homeassistant/components/volvooncall/__init__.py Co-Authored-By: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/abode/camera.py | 2 +- homeassistant/components/abode/switch.py | 2 +- homeassistant/components/aftership/sensor.py | 6 +++-- .../alarmdecoder/alarm_control_panel.py | 6 +++-- .../components/alarmdecoder/binary_sensor.py | 24 ++++++++++++------- .../components/alarmdecoder/sensor.py | 6 +++-- .../components/android_ip_webcam/__init__.py | 4 +++- homeassistant/components/aqualogic/sensor.py | 6 +++-- homeassistant/components/aqualogic/switch.py | 12 ++++------ .../components/arcam_fmj/media_player.py | 18 ++++++++++---- .../components/arlo/alarm_control_panel.py | 6 ++++- homeassistant/components/arlo/camera.py | 12 ++++------ homeassistant/components/arlo/sensor.py | 6 ++++- homeassistant/components/axis/axis_base.py | 8 +------ homeassistant/components/axis/camera.py | 2 +- homeassistant/components/comfoconnect/fan.py | 10 ++++---- .../components/comfoconnect/sensor.py | 10 ++++---- .../components/denonavr/media_player.py | 4 +++- .../components/eight_sleep/__init__.py | 12 ++++++++-- homeassistant/components/enocean/__init__.py | 6 +++-- .../envisalink/alarm_control_panel.py | 12 +++++++--- homeassistant/components/fastdotcom/sensor.py | 11 +++++---- homeassistant/components/ffmpeg/__init__.py | 18 ++++++++++---- .../components/geniushub/__init__.py | 2 +- homeassistant/components/hive/__init__.py | 10 ++------ homeassistant/components/hlk_sw16/__init__.py | 10 ++++---- homeassistant/components/homeworks/light.py | 4 +++- .../components/hydrawise/__init__.py | 6 +++-- .../components/iaqualink/__init__.py | 10 ++------ .../components/incomfort/__init__.py | 2 +- .../components/insteon/insteon_entity.py | 8 +++++-- homeassistant/components/iperf3/sensor.py | 11 +++++---- .../components/kaiterra/air_quality.py | 6 +++-- homeassistant/components/kaiterra/sensor.py | 6 +++-- .../components/konnected/binary_sensor.py | 6 +++-- .../components/mediaroom/media_player.py | 6 ++++- .../components/mychevy/binary_sensor.py | 6 +++-- homeassistant/components/mychevy/sensor.py | 12 +++++++--- homeassistant/components/mysensors/device.py | 16 ++++++++----- .../ness_alarm/alarm_control_panel.py | 6 +++-- .../components/ness_alarm/binary_sensor.py | 6 +++-- homeassistant/components/nest/__init__.py | 4 +++- homeassistant/components/nest/climate.py | 4 +++- .../components/netgear_lte/__init__.py | 6 +++-- .../components/nissan_leaf/__init__.py | 6 +++-- homeassistant/components/nzbget/sensor.py | 6 +++-- homeassistant/components/plaato/sensor.py | 6 +++-- .../components/qwikswitch/__init__.py | 6 +++-- homeassistant/components/rachio/switch.py | 6 +++-- .../components/raincloud/__init__.py | 6 +++-- homeassistant/components/repetier/sensor.py | 4 +++- homeassistant/components/rflink/__init__.py | 16 ++++++++----- homeassistant/components/rflink/sensor.py | 16 ++++++++----- homeassistant/components/sabnzbd/sensor.py | 6 ++++- .../satel_integra/alarm_control_panel.py | 6 +++-- .../components/satel_integra/binary_sensor.py | 6 +++-- .../components/spc/alarm_control_panel.py | 8 +++++-- homeassistant/components/spc/binary_sensor.py | 8 +++++-- .../components/speedtestdotnet/sensor.py | 6 +++-- .../components/switcher_kis/switch.py | 6 +++-- .../components/tellstick/__init__.py | 6 +++-- homeassistant/components/upnp/sensor.py | 6 +++-- homeassistant/components/vallox/fan.py | 6 +++-- homeassistant/components/vallox/sensor.py | 6 +++-- .../components/volvooncall/__init__.py | 6 +++-- .../components/waterfurnace/sensor.py | 6 +++-- .../components/websocket_api/sensor.py | 12 ++++++---- .../components/wirelesstag/binary_sensor.py | 10 ++++---- .../components/wirelesstag/sensor.py | 10 ++++---- .../components/yeelight/binary_sensor.py | 16 +++++-------- homeassistant/components/yeelight/light.py | 10 ++++---- 71 files changed, 354 insertions(+), 208 deletions(-) diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index bee7364489007b..e733bbd8abbe01 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -48,7 +48,7 @@ async def async_added_to_hass(self): ) signal = f"abode_camera_capture_{self.entity_id}" - async_dispatcher_connect(self.hass, signal, self.capture) + self.async_on_remove(async_dispatcher_connect(self.hass, signal, self.capture)) def capture(self): """Request a new image capture.""" diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index b57f3fbe1430b9..bbd90442cd9e91 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -53,7 +53,7 @@ async def async_added_to_hass(self): await super().async_added_to_hass() signal = f"abode_trigger_automation_{self.entity_id}" - async_dispatcher_connect(self.hass, signal, self.trigger) + self.async_on_remove(async_dispatcher_connect(self.hass, signal, self.trigger)) def turn_on(self, **kwargs): """Enable the automation.""" diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index eb0236cf3be798..293fe4c647a277 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -145,8 +145,10 @@ def icon(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self._force_update + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self._force_update + ) ) async def _force_update(self): diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 57004191064b4e..5625204c762fdd 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -96,8 +96,10 @@ def __init__(self, auto_bypass, code_arm_required): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_PANEL_MESSAGE, self._message_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback + ) ) def _message_callback(self, message): diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 13a7913e190817..b34c90bc35add4 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -79,20 +79,28 @@ def __init__( async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_ZONE_FAULT, self._fault_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_FAULT, self._fault_callback + ) ) - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_ZONE_RESTORE, self._restore_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_ZONE_RESTORE, self._restore_callback + ) ) - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_RFX_MESSAGE, self._rfx_message_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RFX_MESSAGE, self._rfx_message_callback + ) ) - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_REL_MESSAGE, self._rel_message_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_REL_MESSAGE, self._rel_message_callback + ) ) @property diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index 196e8d704e1586..96e5feb532d313 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -29,8 +29,10 @@ def __init__(self, hass): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_PANEL_MESSAGE, self._message_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_PANEL_MESSAGE, self._message_callback + ) ) def _message_callback(self, message): diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py index 1f9df527c28203..333da7dceea8b1 100644 --- a/homeassistant/components/android_ip_webcam/__init__.py +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -309,7 +309,9 @@ def async_ipcam_update(host): return self.async_schedule_update_ha_state(True) - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update) + ) @property def should_poll(self): diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 002b032fa92b96..ce2ecb89d8f2a7 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -99,8 +99,10 @@ def icon(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback + ) ) @callback diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index e54fcff139d507..c00510b563a2b4 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -64,7 +64,6 @@ def __init__(self, processor, switch_type): "aux_6": States.AUX_6, "aux_7": States.AUX_7, }[switch_type] - self._unsub_disp = None @property def name(self): @@ -101,11 +100,8 @@ def turn_off(self, **kwargs): async def async_added_to_hass(self): """Register callbacks.""" - self._unsub_disp = self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_write_ha_state + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_write_ha_state + ) ) - - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self._unsub_disp() - self._unsub_disp = None diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index a49802ea96fae1..92e07a0547e93b 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -142,14 +142,22 @@ def _stopped(host): if host == self._state.client.host: self.async_schedule_update_ha_state(force_refresh=True) - self.hass.helpers.dispatcher.async_dispatcher_connect(SIGNAL_CLIENT_DATA, _data) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_DATA, _data + ) + ) - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_CLIENT_STARTED, _started + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_STARTED, _started + ) ) - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_CLIENT_STOPPED, _stopped + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_CLIENT_STOPPED, _stopped + ) ) async def async_update(self): diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py index 49a1bced577946..5e5597f50dab5f 100644 --- a/homeassistant/components/arlo/alarm_control_panel.py +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -84,7 +84,11 @@ def icon(self): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback + ) + ) @callback def _update_callback(self): diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index e2bb85c9f8404e..6f7e3796309d21 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -61,7 +61,6 @@ def __init__(self, hass, camera, device_info): self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) self._last_refresh = None self.attrs = {} - self._unsub_disp = None def camera_image(self): """Return a still image response from the camera.""" @@ -69,15 +68,12 @@ def camera_image(self): async def async_added_to_hass(self): """Register callbacks.""" - self._unsub_disp = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ARLO, self.async_write_ha_state + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self.async_write_ha_state + ) ) - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self._unsub_disp() - self._unsub_disp = None - async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" video = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index 03e4437b257e64..10f8d6701089bf 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -92,7 +92,11 @@ def name(self): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback + ) + ) @callback def _update_callback(self): diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py index e61c4cea6b03d2..2e848168b49293 100644 --- a/homeassistant/components/axis/axis_base.py +++ b/homeassistant/components/axis/axis_base.py @@ -13,21 +13,15 @@ class AxisEntityBase(Entity): def __init__(self, device): """Initialize the Axis event.""" self.device = device - self.unsub_dispatcher = [] async def async_added_to_hass(self): """Subscribe device events.""" - self.unsub_dispatcher.append( + self.async_on_remove( async_dispatcher_connect( self.hass, self.device.event_reachable, self.update_callback ) ) - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe device events when removed.""" - for unsub_dispatcher in self.unsub_dispatcher: - unsub_dispatcher() - @property def available(self): """Return True if device is available.""" diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index c914319aa424b8..ca76552a4ccdc5 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -57,7 +57,7 @@ def __init__(self, config, device): async def async_added_to_hass(self): """Subscribe camera events.""" - self.unsub_dispatcher.append( + self.async_on_remove( async_dispatcher_connect( self.hass, self.device.event_new_address, self._new_address ) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 432b25ac602b05..b5eac4f9afea26 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -44,10 +44,12 @@ def __init__(self, name, ccb: ComfoConnectBridge) -> None: async def async_added_to_hass(self): """Register for sensor updates.""" _LOGGER.debug("Registering for fan speed") - async_dispatcher_connect( - self.hass, - SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(SENSOR_FAN_SPEED_MODE), - self._handle_update, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(SENSOR_FAN_SPEED_MODE), + self._handle_update, + ) ) await self.hass.async_add_executor_job( self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 5c8c0d6a75c9d6..cea09e97dbaeb8 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -234,10 +234,12 @@ async def async_added_to_hass(self): _LOGGER.debug( "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id ) - async_dispatcher_connect( - self.hass, - SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), - self._handle_update, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_COMFOCONNECT_UPDATE_RECEIVED.format(self._sensor_id), + self._handle_update, + ) ) await self.hass.async_add_executor_job( self._ccb.comfoconnect.register_sensor, self._sensor_id diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index b14592d1b78dd0..67dc07f68df790 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -197,7 +197,9 @@ def __init__(self, receiver): async def async_added_to_hass(self): """Register signal handler.""" - async_dispatcher_connect(self.hass, DOMAIN, self.signal_handler) + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.signal_handler) + ) def signal_handler(self, data): """Handle domain-specific signal by calling appropriate method.""" diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 595144013b6b16..022878c8276dcd 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -214,7 +214,11 @@ def async_eight_user_update(): """Update callback.""" self.async_schedule_update_ha_state(True) - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_USER, async_eight_user_update) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_USER, async_eight_user_update + ) + ) @property def should_poll(self): @@ -237,7 +241,11 @@ def async_eight_heat_update(): """Update callback.""" self.async_schedule_update_ha_state(True) - async_dispatcher_connect(self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update + ) + ) @property def should_poll(self): diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py index 876c7a1f05baab..90ab408775414a 100644 --- a/homeassistant/components/enocean/__init__.py +++ b/homeassistant/components/enocean/__init__.py @@ -71,8 +71,10 @@ def __init__(self, dev_id, dev_name="EnOcean device"): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_RECEIVE_MESSAGE, self._message_received_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_RECEIVE_MESSAGE, self._message_received_callback + ) ) def _message_received_callback(self, packet): diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index beb1c1cda822ba..62c57daf19d727 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -112,9 +112,15 @@ def __init__( async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect(self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback) - async_dispatcher_connect( - self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback + ) ) @callback diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index a6eaa21ae35ece..fe131e4dab48d5 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -55,15 +55,18 @@ def should_poll(self): async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + ) + state = await self.async_get_last_state() if not state: return self._state = state.state - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) - def update(self): """Get the latest data and update the states.""" data = self.speedtest_client.data diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index ad0c590b87db08..f109103a99c9b3 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -140,12 +140,20 @@ async def async_added_to_hass(self): This method is a coroutine. """ - async_dispatcher_connect( - self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg + ) ) - async_dispatcher_connect(self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg) - async_dispatcher_connect( - self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg + ) ) # register start/stop diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bb25d2d619d175..0b99224bf7f22f 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -204,7 +204,7 @@ def __init__(self) -> None: async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + self.async_on_remove(async_dispatcher_connect(self.hass, DOMAIN, self._refresh)) async def _refresh(self, payload: Optional[dict] = None) -> None: """Process any signals.""" diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 88103ec94c1452..98d625cbb1d264 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -182,17 +182,11 @@ def __init__(self, session, hive_device): self.session = session self.attributes = {} self._unique_id = f"{self.node_id}-{self.device_type}" - self._unsub_disp = None async def async_added_to_hass(self): """When entity is added to Home Assistant.""" - self._unsub_disp = async_dispatcher_connect( - self.hass, DOMAIN, self.async_write_ha_state + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) if self.device_type in SERVICES: self.session.entity_lookup[self.entity_id] = self.node_id - - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self._unsub_disp() - self._unsub_disp = None diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index 52a82184dccf3b..3319ce6bee7bf4 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -164,8 +164,10 @@ async def async_added_to_hass(self): self.handle_event_callback, self._device_port ) self._is_on = await self._client.status(self._device_port) - async_dispatcher_connect( - self.hass, - f"hlk_sw16_device_available_{self._device_id}", - self._availability_callback, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"hlk_sw16_device_available_{self._device_id}", + self._availability_callback, + ) ) diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index 4cfb2b0a26d811..db72c87a4a33fa 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -42,7 +42,9 @@ async def async_added_to_hass(self): """Call when entity is added to hass.""" signal = f"homeworks_entity_{self._addr}" _LOGGER.debug("connecting %s", signal) - async_dispatcher_connect(self.hass, signal, self._update_callback) + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._update_callback) + ) self._controller.request_dimmer_level(self._addr) @property diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 65b7b1f6f6e71d..28b577354d29ea 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -127,8 +127,10 @@ def name(self): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback + ) ) @callback diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 214bdf302ea277..97ddd95f50c22a 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -202,19 +202,13 @@ class AqualinkEntity(Entity): def __init__(self, dev: AqualinkDevice): """Initialize the entity.""" self.dev = dev - self._unsub_disp = None async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" - self._unsub_disp = async_dispatcher_connect( - self.hass, DOMAIN, self.async_write_ha_state + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self._unsub_disp() - self._unsub_disp = None - @property def should_poll(self) -> bool: """Return False as entities shouldn't be polled. diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index bb11565006148c..cec550d24d95e2 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -83,7 +83,7 @@ class IncomfortChild(IncomfortEntity): async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" - async_dispatcher_connect(self.hass, DOMAIN, self._refresh) + self.async_on_remove(async_dispatcher_connect(self.hass, DOMAIN, self._refresh)) @callback def _refresh(self) -> None: diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/insteon_entity.py index e411cd82045d11..19f3344fb81ea0 100644 --- a/homeassistant/components/insteon/insteon_entity.py +++ b/homeassistant/components/insteon/insteon_entity.py @@ -93,9 +93,13 @@ async def async_added_to_hass(self): self._insteon_device_state.register_updates(self.async_entity_update) self.hass.data[DOMAIN][INSTEON_ENTITIES].add(self.entity_id) load_signal = f"{self.entity_id}_{SIGNAL_LOAD_ALDB}" - async_dispatcher_connect(self.hass, load_signal, self._load_aldb) + self.async_on_remove( + async_dispatcher_connect(self.hass, load_signal, self._load_aldb) + ) print_signal = f"{self.entity_id}_{SIGNAL_PRINT_ALDB}" - async_dispatcher_connect(self.hass, print_signal, self._print_aldb) + self.async_on_remove( + async_dispatcher_connect(self.hass, print_signal, self._print_aldb) + ) def _load_aldb(self, reload=False): """Load the device All-Link Database.""" diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index 70a15a0dac51ea..749a3e83217a65 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -73,15 +73,18 @@ def should_poll(self): async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + ) + state = await self.async_get_last_state() if not state: return self._state = state.state - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) - def update(self): """Get the latest data and update the states.""" data = self._iperf3_data.data.get(self._sensor_type) diff --git a/homeassistant/components/kaiterra/air_quality.py b/homeassistant/components/kaiterra/air_quality.py index 1de1a4bd6c5b05..ae5df387884fb5 100644 --- a/homeassistant/components/kaiterra/air_quality.py +++ b/homeassistant/components/kaiterra/air_quality.py @@ -113,6 +113,8 @@ def device_state_attributes(self): async def async_added_to_hass(self): """Register callback.""" - async_dispatcher_connect( - self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + ) ) diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index e86d6f7d836443..d9500c7a00064d 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -88,6 +88,8 @@ def unit_of_measurement(self): async def async_added_to_hass(self): """Register callback.""" - async_dispatcher_connect( - self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + ) ) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index f2f79f5ed7d6ba..5cd270d500833e 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -79,8 +79,10 @@ def device_info(self): async def async_added_to_hass(self): """Store entity_id and register state change callback.""" self._data[ATTR_ENTITY_ID] = self.entity_id - async_dispatcher_connect( - self.hass, f"konnected.{self.entity_id}.update", self.async_set_state + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"konnected.{self.entity_id}.update", self.async_set_state + ) ) @callback diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 8db9cb6fa37c21..dd67cc287832e8 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -178,7 +178,11 @@ async def async_notify_received(notify): self._available = True self.async_write_ha_state() - async_dispatcher_connect(self.hass, SIGNAL_STB_NOTIFY, async_notify_received) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_STB_NOTIFY, async_notify_received + ) + ) async def async_play_media(self, media_type, media_id, **kwargs): """Play media.""" diff --git a/homeassistant/components/mychevy/binary_sensor.py b/homeassistant/components/mychevy/binary_sensor.py index 822a7988d0d252..702f3146f8e407 100644 --- a/homeassistant/components/mychevy/binary_sensor.py +++ b/homeassistant/components/mychevy/binary_sensor.py @@ -64,8 +64,10 @@ def _car(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback + ) ) @callback diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py index 9f8ea5607d9dcd..96e0eef68ad21b 100644 --- a/homeassistant/components/mychevy/sensor.py +++ b/homeassistant/components/mychevy/sensor.py @@ -58,11 +58,17 @@ def __init__(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.success + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.success + ) ) - self.hass.helpers.dispatcher.async_dispatcher_connect(ERROR_TOPIC, self.error) + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + ERROR_TOPIC, self.error + ) + ) @callback def success(self): diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index e5853fce5ca7f1..9c1c4b54367646 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -137,11 +137,15 @@ async def async_added_to_hass(self): """Register update callback.""" gateway_id = id(self.gateway) dev_id = gateway_id, self.node_id, self.child_id, self.value_type - async_dispatcher_connect( - self.hass, CHILD_CALLBACK.format(*dev_id), self.async_update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, CHILD_CALLBACK.format(*dev_id), self.async_update_callback + ) ) - async_dispatcher_connect( - self.hass, - NODE_CALLBACK.format(gateway_id, self.node_id), - self.async_update_callback, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + NODE_CALLBACK.format(gateway_id, self.node_id), + self.async_update_callback, + ) ) diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 8b7867fdc062de..8181e54640d3bf 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -45,8 +45,10 @@ def __init__(self, client, name): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ARMING_STATE_CHANGED, self._handle_arming_state_change + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_ARMING_STATE_CHANGED, self._handle_arming_state_change + ) ) @property diff --git a/homeassistant/components/ness_alarm/binary_sensor.py b/homeassistant/components/ness_alarm/binary_sensor.py index 69acc97130d2e5..c719febdb58f07 100644 --- a/homeassistant/components/ness_alarm/binary_sensor.py +++ b/homeassistant/components/ness_alarm/binary_sensor.py @@ -50,8 +50,10 @@ def __init__(self, zone_id, name, zone_type): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_ZONE_CHANGED, self._handle_zone_change + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_ZONE_CHANGED, self._handle_zone_change + ) ) @property diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 73a28aa121f289..b486e907ee3b16 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -437,4 +437,6 @@ async def async_update_state(): """Update sensor state.""" await self.async_update_ha_state(True) - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + ) diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index f75e3a692f3112..92442479091d81 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -151,7 +151,9 @@ async def async_update_state(): """Update device state.""" await self.async_update_ha_state(True) - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + ) @property def supported_features(self): diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index ac36cc1eb44dee..aedfe9018f7c44 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -352,8 +352,10 @@ def _init_unique_id(self): async def async_added_to_hass(self): """Register callback.""" - async_dispatcher_connect( - self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state + ) ) async def async_update(self): diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 57b9bdb61fa6a0..f5f23f2f114043 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -467,8 +467,10 @@ def device_state_attributes(self): async def async_added_to_hass(self): """Register callbacks.""" self.log_registration() - async_dispatcher_connect( - self.car.hass, SIGNAL_UPDATE_LEAF, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.car.hass, SIGNAL_UPDATE_LEAF, self._update_callback + ) ) @callback diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 89d2c1c01da4e0..a0f1dc57c94bda 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -89,8 +89,10 @@ def available(self): async def async_added_to_hass(self): """Handle entity which will be added.""" - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) ) @callback diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 34a2a1a42b669e..07b0453fca69fb 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -157,6 +157,8 @@ def should_poll(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - f"{PLAATO_DOMAIN}_{self.unique_id}", self.async_schedule_update_ha_state + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + f"{PLAATO_DOMAIN}_{self.unique_id}", self.async_write_ha_state + ) ) diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index c2d938f6ed77f2..6ad030078b1177 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -91,8 +91,10 @@ def update_packet(self, packet): async def async_added_to_hass(self): """Listen for updates from QSUSb via dispatcher.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - self.qsid, self.update_packet + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + self.qsid, self.update_packet + ) ) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index a4ba1a41fee0b0..7be0c64ee1bac4 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -167,8 +167,10 @@ def turn_off(self, **kwargs) -> None: async def async_added_to_hass(self): """Subscribe to updates.""" - async_dispatcher_connect( - self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update + ) ) diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index 4f9ae7fb73330b..971b8174993a41 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -152,8 +152,10 @@ def name(self): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RAINCLOUD, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_RAINCLOUD, self._update_callback + ) ) def _update_callback(self): diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 5936b5c33436c4..e342b2d341ed97 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -102,7 +102,9 @@ def update_callback(self): async def async_added_to_hass(self): """Connect update callbacks.""" - async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self.update_callback) + self.async_on_remove( + async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self.update_callback) + ) def _get_data(self): """Return new data from the api cache.""" diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 9ba008a56ef5d1..b33f2623b9d6df 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -404,13 +404,17 @@ async def async_added_to_hass(self): self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( self.entity_id ) - async_dispatcher_connect( - self.hass, SIGNAL_AVAILABILITY, self._availability_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_AVAILABILITY, self._availability_callback + ) ) - async_dispatcher_connect( - self.hass, - SIGNAL_HANDLE_EVENT.format(self.entity_id), - self.handle_event_callback, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback, + ) ) # Process the initial event now that the entity is created diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index bc736a1ede6195..9394c568a73bca 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -139,13 +139,17 @@ async def async_added_to_hass(self): self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_SENSOR][_id].append( self.entity_id ) - async_dispatcher_connect( - self.hass, SIGNAL_AVAILABILITY, self._availability_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_AVAILABILITY, self._availability_callback + ) ) - async_dispatcher_connect( - self.hass, - SIGNAL_HANDLE_EVENT.format(self.entity_id), - self.handle_event_callback, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback, + ) ) # Process the initial event now that the entity is created diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index 21ac9eefdb29a5..b232e2e63c58fb 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -37,7 +37,11 @@ def __init__(self, sensor_type, sabnzbd_api_data, client_name): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" - async_dispatcher_connect(self.hass, SIGNAL_SABNZBD_UPDATED, self.update_state) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_SABNZBD_UPDATED, self.update_state + ) + ) @property def name(self): diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 6034c24e31a745..8a240794580ce8 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -67,8 +67,10 @@ async def async_added_to_hass(self): """Update alarm status and register callbacks for future updates.""" _LOGGER.debug("Starts listening for panel messages") self._update_alarm_status() - async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status + ) ) @callback diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 5b268266dda1af..4a9be339a1cdd1 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -75,8 +75,10 @@ async def async_added_to_hass(self): self._state = 1 else: self._state = 0 - async_dispatcher_connect( - self.hass, self._react_to_signal, self._devices_updated + self.async_on_remove( + async_dispatcher_connect( + self.hass, self._react_to_signal, self._devices_updated + ) ) @property diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index ca5d77b2a828a5..982c0fe2bab52d 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -57,8 +57,12 @@ def __init__(self, area, api): async def async_added_to_hass(self): """Call for adding new entities.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ALARM.format(self._area.id), self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE_ALARM.format(self._area.id), + self._update_callback, + ) ) @callback diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index 34689c4dccfd42..3149ae560638d1 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -46,8 +46,12 @@ def __init__(self, zone): async def async_added_to_hass(self): """Call for adding new entities.""" - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_SENSOR.format(self._zone.id), self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_UPDATE_SENSOR.format(self._zone.id), + self._update_callback, + ) ) @callback diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index c1f51ab269ac7d..41db6c26930f90 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -91,8 +91,10 @@ async def async_added_to_hass(self): return self._state = state.state - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) ) def update(self): diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 4ace2c6eea1fcc..ea32183b511bf6 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -109,8 +109,10 @@ def available(self) -> bool: async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - async_dispatcher_connect( - self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data + ) ) async def async_update_data(self, device_data: "SwitcherV2Device") -> None: diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index e7f341c90b26ed..db37f4669d3bc8 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -182,8 +182,10 @@ def __init__(self, tellcore_device, signal_repetitions): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_TELLCORE_CALLBACK, self.update_from_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_TELLCORE_CALLBACK, self.update_from_callback + ) ) @property diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 9632997ac1b135..88d6681a8049ac 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -82,8 +82,10 @@ def __init__(self, device): async def async_added_to_hass(self): """Subscribe to sensors events.""" - async_dispatcher_connect( - self.hass, SIGNAL_REMOVE_SENSOR, self._upnp_remove_sensor + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_REMOVE_SENSOR, self._upnp_remove_sensor + ) ) @callback diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 5277a3309762f8..c79ee15db598f1 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -93,8 +93,10 @@ def device_state_attributes(self): async def async_added_to_hass(self): """Call to update.""" - async_dispatcher_connect( - self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback + ) ) @callback diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 5bf9b8061ad1cf..b3a7e8758a0296 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -149,8 +149,10 @@ def state(self): async def async_added_to_hass(self): """Call to update.""" - async_dispatcher_connect( - self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_VALLOX_STATE_UPDATE, self._update_callback + ) ) @callback diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index c621a12943b8cb..c408080524ebed 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -230,8 +230,10 @@ def __init__(self, data, vin, component, attribute): async def async_added_to_hass(self): """Register update dispatcher.""" - async_dispatcher_connect( - self.hass, SIGNAL_STATE_UPDATED, self.async_schedule_update_ha_state + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_STATE_UPDATED, self.async_write_ha_state + ) ) @property diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 14f3549b2a37a2..b2b8aaa6f3580d 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -105,8 +105,10 @@ def should_poll(self): async def async_added_to_hass(self): """Register callbacks.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - UPDATE_TOPIC, self.async_update_callback + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + UPDATE_TOPIC, self.async_update_callback + ) ) @callback diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index a74381b8a856a6..6be07dfb1f42a1 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -28,11 +28,15 @@ def __init__(self): async def async_added_to_hass(self): """Added to hass.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_WEBSOCKET_CONNECTED, self._update_count + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_WEBSOCKET_CONNECTED, self._update_count + ) ) - self.hass.helpers.dispatcher.async_dispatcher_connect( - SIGNAL_WEBSOCKET_DISCONNECTED, self._update_count + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_WEBSOCKET_DISCONNECTED, self._update_count + ) ) self._update_count() diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 07acf6057a1059..eae8c17edcd32e 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -102,10 +102,12 @@ async def async_added_to_hass(self): tag_id = self.tag_id event_type = self.device_class mac = self.tag_manager_mac - async_dispatcher_connect( - self.hass, - SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), - self._on_binary_event_callback, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), + self._on_binary_event_callback, + ) ) @property diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 7a41d237781674..14f63084709847 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -64,10 +64,12 @@ def __init__(self, api, tag, sensor_type, config): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, - SIGNAL_TAG_UPDATE.format(self.tag_id, self.tag_manager_mac), - self._update_tag_info_callback, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TAG_UPDATE.format(self.tag_id, self.tag_manager_mac), + self._update_tag_info_callback, + ) ) @property diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index 3c06a75fb71a98..f5f3e03b7650ee 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -27,21 +27,17 @@ class YeelightNightlightModeSensor(BinarySensorDevice): def __init__(self, device): """Initialize nightlight mode sensor.""" self._device = device - self._unsub_disp = None async def async_added_to_hass(self): """Handle entity which will be added.""" - self._unsub_disp = async_dispatcher_connect( - self.hass, - DATA_UPDATED.format(self._device.ipaddr), - self.async_write_ha_state, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + DATA_UPDATED.format(self._device.ipaddr), + self.async_write_ha_state, + ) ) - async def async_will_remove_from_hass(self): - """When entity will be removed from hass.""" - self._unsub_disp() - self._unsub_disp = None - @property def should_poll(self): """No polling needed.""" diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 59863464d21eb5..2f69b98bcbcf2e 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -456,10 +456,12 @@ def _schedule_immediate_update(self): async def async_added_to_hass(self): """Handle entity which will be added.""" - async_dispatcher_connect( - self.hass, - DATA_UPDATED.format(self._device.ipaddr), - self._schedule_immediate_update, + self.async_on_remove( + async_dispatcher_connect( + self.hass, + DATA_UPDATED.format(self._device.ipaddr), + self._schedule_immediate_update, + ) ) @property From 590e7140217b13b6356e57a0012b1cdeccc8e075 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2020 11:46:10 -0500 Subject: [PATCH 05/44] Ensure harmony hub is ready before importing (#33537) If the harmony hub was not ready for connection or was busy when importing from yaml, the import validation would fail would not be retried. To mitigate this scenario we now do the validation in async_setup_platform which allows us to raise PlatformNotReady so we can retry later. --- .../components/harmony/config_flow.py | 58 +++++++------------ homeassistant/components/harmony/remote.py | 27 ++++++++- homeassistant/components/harmony/util.py | 44 +++++++++++++- tests/components/harmony/test_config_flow.py | 17 +++--- 4 files changed, 95 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index ff7b47d60107e4..9d9c9dfb8e97ac 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -2,11 +2,9 @@ import logging from urllib.parse import urlparse -import aioharmony.exceptions as harmony_exceptions -from aioharmony.harmonyapi import HarmonyAPI import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, exceptions from homeassistant.components import ssdp from homeassistant.components.remote import ( ATTR_ACTIVITY, @@ -17,7 +15,11 @@ from homeassistant.core import callback from .const import DOMAIN, UNIQUE_ID -from .util import find_unique_id_for_remote +from .util import ( + find_best_name_for_remote, + find_unique_id_for_remote, + get_harmony_client_if_available, +) _LOGGER = logging.getLogger(__name__) @@ -26,43 +28,19 @@ ) -async def get_harmony_client_if_available(hass: core.HomeAssistant, ip_address): - """Connect to a harmony hub and fetch info.""" - harmony = HarmonyAPI(ip_address=ip_address) - - try: - if not await harmony.connect(): - await harmony.close() - return None - except harmony_exceptions.TimeOut: - return None - - await harmony.close() - - return harmony - - -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(data): """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. """ - harmony = await get_harmony_client_if_available(hass, data[CONF_HOST]) + harmony = await get_harmony_client_if_available(data[CONF_HOST]) if not harmony: raise CannotConnect - unique_id = find_unique_id_for_remote(harmony) - - # As a last resort we get the name from the harmony client - # in the event a name was not provided. harmony.name is - # usually the ip address but it can be an empty string. - if CONF_NAME not in data or data[CONF_NAME] is None or data[CONF_NAME] == "": - data[CONF_NAME] = harmony.name - return { - CONF_NAME: data[CONF_NAME], + CONF_NAME: find_best_name_for_remote(data, harmony), CONF_HOST: data[CONF_HOST], - UNIQUE_ID: unique_id, + UNIQUE_ID: find_unique_id_for_remote(harmony), } @@ -82,7 +60,7 @@ async def async_step_user(self, user_input=None): if user_input is not None: try: - validated = await validate_input(self.hass, user_input) + validated = await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except @@ -116,9 +94,7 @@ async def async_step_ssdp(self, discovery_info): CONF_NAME: friendly_name, } - harmony = await get_harmony_client_if_available( - self.hass, self.harmony_config[CONF_HOST] - ) + harmony = await get_harmony_client_if_available(parsed_url.hostname) if harmony: unique_id = find_unique_id_for_remote(harmony) @@ -150,9 +126,15 @@ async def async_step_link(self, user_input=None): }, ) - async def async_step_import(self, user_input): + async def async_step_import(self, validated_input): """Handle import.""" - return await self.async_step_user(user_input) + await self.async_set_unique_id(validated_input[UNIQUE_ID]) + self._abort_if_unique_id_configured() + # Everything was validated in remote async_setup_platform + # all we do now is create. + return await self._async_create_entry_from_valid_input( + validated_input, validated_input + ) @staticmethod @callback diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 024af9b15804bd..5af8d1eb65a31a 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -24,6 +24,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -33,6 +34,13 @@ HARMONY_OPTIONS_UPDATE, SERVICE_CHANGE_CHANNEL, SERVICE_SYNC, + UNIQUE_ID, +) +from .util import ( + find_best_name_for_remote, + find_matching_config_entries_for_host, + find_unique_id_for_remote, + get_harmony_client_if_available, ) _LOGGER = logging.getLogger(__name__) @@ -51,6 +59,7 @@ extra=vol.ALLOW_EXTRA, ) + HARMONY_SYNC_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema( @@ -68,9 +77,25 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= # Now handled by ssdp in the config flow return + if find_matching_config_entries_for_host(hass, config[CONF_HOST]): + return + + # We do the validation to verify we can connect + # so we can raise PlatformNotReady to force + # a retry so we can avoid a scenario where the config + # entry cannot be created via import because hub + # is not yet ready. + harmony = await get_harmony_client_if_available(config[CONF_HOST]) + if not harmony: + raise PlatformNotReady + + validated_config = config.copy() + validated_config[UNIQUE_ID] = find_unique_id_for_remote(harmony) + validated_config[CONF_NAME] = find_best_name_for_remote(config, harmony) + hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config + DOMAIN, context={"source": SOURCE_IMPORT}, data=validated_config ) ) diff --git a/homeassistant/components/harmony/util.py b/homeassistant/components/harmony/util.py index 5f7e46510f9f95..69ed44cb7dac10 100644 --- a/homeassistant/components/harmony/util.py +++ b/homeassistant/components/harmony/util.py @@ -1,8 +1,13 @@ """The Logitech Harmony Hub integration utils.""" -from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient +import aioharmony.exceptions as harmony_exceptions +from aioharmony.harmonyapi import HarmonyAPI +from homeassistant.const import CONF_HOST, CONF_NAME -def find_unique_id_for_remote(harmony: HarmonyClient): +from .const import DOMAIN + + +def find_unique_id_for_remote(harmony: HarmonyAPI): """Find the unique id for both websocket and xmpp clients.""" websocket_unique_id = harmony.hub_config.info.get("activeRemoteId") if websocket_unique_id is not None: @@ -10,3 +15,38 @@ def find_unique_id_for_remote(harmony: HarmonyClient): # fallback to the xmpp unique id if websocket is not available return harmony.config["global"]["timeStampHash"].split(";")[-1] + + +def find_best_name_for_remote(data: dict, harmony: HarmonyAPI): + """Find the best name from config or fallback to the remote.""" + # As a last resort we get the name from the harmony client + # in the event a name was not provided. harmony.name is + # usually the ip address but it can be an empty string. + if CONF_NAME not in data or data[CONF_NAME] is None or data[CONF_NAME] == "": + return harmony.name + + return data[CONF_NAME] + + +async def get_harmony_client_if_available(ip_address: str): + """Connect to a harmony hub and fetch info.""" + harmony = HarmonyAPI(ip_address=ip_address) + + try: + if not await harmony.connect(): + await harmony.close() + return None + except harmony_exceptions.TimeOut: + return None + + await harmony.close() + + return harmony + + +def find_matching_config_entries_for_host(hass, host): + """Search existing config entries for one matching the host.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == host: + return entry + return None diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py index 18c0825b6a1dcb..30421756d22514 100644 --- a/tests/components/harmony/test_config_flow.py +++ b/tests/components/harmony/test_config_flow.py @@ -25,8 +25,7 @@ async def test_user_form(hass): harmonyapi = _get_mock_harmonyapi(connect=True) with patch( - "homeassistant.components.harmony.config_flow.HarmonyAPI", - return_value=harmonyapi, + "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi, ), patch( "homeassistant.components.harmony.async_setup", return_value=True ) as mock_setup, patch( @@ -53,8 +52,7 @@ async def test_form_import(hass): harmonyapi = _get_mock_harmonyapi(connect=True) with patch( - "homeassistant.components.harmony.config_flow.HarmonyAPI", - return_value=harmonyapi, + "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi, ), patch( "homeassistant.components.harmony.async_setup", return_value=True ) as mock_setup, patch( @@ -68,9 +66,11 @@ async def test_form_import(hass): "name": "friend", "activity": "Watch TV", "delay_secs": 0.9, + "unique_id": "555234534543", }, ) + assert result["result"].unique_id == "555234534543" assert result["type"] == "create_entry" assert result["title"] == "friend" assert result["data"] == { @@ -94,8 +94,7 @@ async def test_form_ssdp(hass): harmonyapi = _get_mock_harmonyapi(connect=True) with patch( - "homeassistant.components.harmony.config_flow.HarmonyAPI", - return_value=harmonyapi, + "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -114,8 +113,7 @@ async def test_form_ssdp(hass): } with patch( - "homeassistant.components.harmony.config_flow.HarmonyAPI", - return_value=harmonyapi, + "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi, ), patch( "homeassistant.components.harmony.async_setup", return_value=True ) as mock_setup, patch( @@ -141,8 +139,7 @@ async def test_form_cannot_connect(hass): ) with patch( - "homeassistant.components.harmony.config_flow.HarmonyAPI", - side_effect=CannotConnect, + "homeassistant.components.harmony.util.HarmonyAPI", side_effect=CannotConnect, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From 457d439e24e64a73c610d723662e5aefc6bc5e03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Apr 2020 18:52:05 +0200 Subject: [PATCH 06/44] Fix MQTT cleanup regression from #32184. (#33532) --- homeassistant/components/mqtt/__init__.py | 21 +++++++++++++-------- tests/ignore_uncaught_exceptions.py | 4 ---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index bc59be0d1f3e8d..734f67906ceb10 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1162,7 +1162,7 @@ def available(self) -> bool: async def cleanup_device_registry(hass, device_id): - """Remove device registry entry if there are no entities or triggers.""" + """Remove device registry entry if there are no remaining entities or triggers.""" # Local import to avoid circular dependencies from . import device_trigger @@ -1196,8 +1196,12 @@ async def async_added_to_hass(self) -> None: self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None ) - async def async_remove_from_registry(self) -> None: - """Remove entity from entity registry.""" + async def _async_remove_state_and_registry_entry(self) -> None: + """Remove entity's state and entity registry entry. + + Remove entity from entity registry if it is registered, this also removes the state. + If the entity is not in the entity registry, just remove the state. + """ entity_registry = ( await self.hass.helpers.entity_registry.async_get_registry() ) @@ -1205,6 +1209,8 @@ async def async_remove_from_registry(self) -> None: entity_entry = entity_registry.async_get(self.entity_id) entity_registry.async_remove(self.entity_id) await cleanup_device_registry(self.hass, entity_entry.device_id) + else: + await self.async_remove() @callback async def discovery_callback(payload): @@ -1216,9 +1222,8 @@ async def discovery_callback(payload): if not payload: # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) - self._cleanup_on_remove() - await async_remove_from_registry(self) - await self.async_remove() + 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) @@ -1246,9 +1251,9 @@ async def async_removed_from_registry(self) -> None: async def async_will_remove_from_hass(self) -> None: """Stop listening to signal and cleanup discovery data..""" - self._cleanup_on_remove() + self._cleanup_discovery_on_remove() - def _cleanup_on_remove(self) -> None: + def _cleanup_discovery_on_remove(self) -> None: """Stop listening to signal and cleanup discovery data.""" if self._discovery_data and not self._removed_from_hass: debug_info.remove_entity_data(self.hass, self.entity_id) diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index 428de1a683c13b..df623a2fc20f27 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -68,10 +68,6 @@ "tests.components.mqtt.test_init", "test_setup_with_tls_config_of_v1_under_python36_only_uses_v1", ), - ("tests.components.mqtt.test_light", "test_entity_device_info_remove"), - ("tests.components.mqtt.test_light_json", "test_entity_device_info_remove"), - ("tests.components.mqtt.test_light_template", "test_entity_device_info_remove"), - ("tests.components.mqtt.test_switch", "test_entity_device_info_remove"), ("tests.components.qwikswitch.test_init", "test_binary_sensor_device"), ("tests.components.qwikswitch.test_init", "test_sensor_device"), ("tests.components.rflink.test_init", "test_send_command_invalid_arguments"), From 1d2713b0ea1c2d93a88010695a2e9b9bd8d5eda6 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 2 Apr 2020 18:52:46 +0200 Subject: [PATCH 07/44] Clarify light reproduce state deprecation warning (#33531) --- homeassistant/components/light/reproduce_state.py | 10 +++++++--- tests/components/light/test_reproduce_state.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 59a4b0306d08ef..9a6b22b51a2db5 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -64,7 +64,10 @@ ATTR_TRANSITION, ] -DEPRECATION_WARNING = "The use of other attributes than device state attributes is deprecated and will be removed in a future release. Read the logs for further details: https://www.home-assistant.io/integrations/scene/" +DEPRECATION_WARNING = ( + "The use of other attributes than device state attributes is deprecated and will be removed in a future release. " + "Invalid attributes are %s. Read the logs for further details: https://www.home-assistant.io/integrations/scene/" +) async def _async_reproduce_state( @@ -84,8 +87,9 @@ async def _async_reproduce_state( return # Warn if deprecated attributes are used - if any(attr in DEPRECATED_GROUP for attr in state.attributes): - _LOGGER.warning(DEPRECATION_WARNING) + deprecated_attrs = [attr for attr in state.attributes if attr in DEPRECATED_GROUP] + if deprecated_attrs: + _LOGGER.warning(DEPRECATION_WARNING, deprecated_attrs) # Return if we are already at the right state. if cur_state.state == state.state and all( diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index 250a0fe26a8251..1c40f352ff0180 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -166,4 +166,4 @@ async def test_deprecation_warning(hass, caplog): [State("light.entity_off", "on", {"brightness_pct": 80})], blocking=True ) assert len(turn_on_calls) == 1 - assert DEPRECATION_WARNING in caplog.text + assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text From b10319f69ea59024ed0d6cdbfd21e2306e422992 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Apr 2020 09:55:34 -0700 Subject: [PATCH 08/44] Convert TTS tests to async (#33517) * Convert TTS tests to async * Address comments --- homeassistant/components/tts/__init__.py | 76 +- tests/common.py | 16 +- tests/components/tts/test_init.py | 969 +++++++++++------------ 3 files changed, 515 insertions(+), 546 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 3a456dec531a38..d9d513198ce4ca 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -133,7 +133,7 @@ async def async_setup_platform(p_type, p_config=None, discovery_info=None): hass, p_config, discovery_info ) else: - provider = await hass.async_add_job( + provider = await hass.async_add_executor_job( platform.get_engine, hass, p_config, discovery_info ) @@ -226,41 +226,17 @@ async def async_init_cache(self, use_cache, cache_dir, time_memory, base_url): self.time_memory = time_memory self.base_url = base_url - def init_tts_cache_dir(cache_dir): - """Init cache folder.""" - if not os.path.isabs(cache_dir): - cache_dir = self.hass.config.path(cache_dir) - if not os.path.isdir(cache_dir): - _LOGGER.info("Create cache dir %s.", cache_dir) - os.mkdir(cache_dir) - return cache_dir - try: - self.cache_dir = await self.hass.async_add_job( - init_tts_cache_dir, cache_dir + self.cache_dir = await self.hass.async_add_executor_job( + _init_tts_cache_dir, self.hass, cache_dir ) except OSError as err: raise HomeAssistantError(f"Can't init cache dir {err}") - def get_cache_files(): - """Return a dict of given engine files.""" - cache = {} - - folder_data = os.listdir(self.cache_dir) - for file_data in folder_data: - record = _RE_VOICE_FILE.match(file_data) - if record: - key = KEY_PATTERN.format( - record.group(1), - record.group(2), - record.group(3), - record.group(4), - ) - cache[key.lower()] = file_data.lower() - return cache - try: - cache_files = await self.hass.async_add_job(get_cache_files) + cache_files = await self.hass.async_add_executor_job( + _get_cache_files, self.cache_dir + ) except OSError as err: raise HomeAssistantError(f"Can't read cache dir {err}") @@ -273,13 +249,13 @@ async def async_clear_cache(self): def remove_files(): """Remove files from filesystem.""" - for _, filename in self.file_cache.items(): + for filename in self.file_cache.values(): try: os.remove(os.path.join(self.cache_dir, filename)) except OSError as err: _LOGGER.warning("Can't remove cache file '%s': %s", filename, err) - await self.hass.async_add_job(remove_files) + await self.hass.async_add_executor_job(remove_files) self.file_cache = {} @callback @@ -312,6 +288,7 @@ async def async_get_url( merged_options.update(options) options = merged_options options = options or provider.default_options + if options is not None: invalid_opts = [ opt_name @@ -378,10 +355,10 @@ def save_speech(): speech.write(data) try: - await self.hass.async_add_job(save_speech) + await self.hass.async_add_executor_job(save_speech) self.file_cache[key] = filename - except OSError: - _LOGGER.error("Can't write %s", filename) + except OSError as err: + _LOGGER.error("Can't write %s: %s", filename, err) async def async_file_to_mem(self, key): """Load voice from file cache into memory. @@ -400,7 +377,7 @@ def load_speech(): return speech.read() try: - data = await self.hass.async_add_job(load_speech) + data = await self.hass.async_add_executor_job(load_speech) except OSError: del self.file_cache[key] raise HomeAssistantError(f"Can't read {voice_file}") @@ -506,11 +483,36 @@ async def async_get_tts_audio(self, message, language, options=None): Return a tuple of file extension and data as bytes. """ - return await self.hass.async_add_job( + return await self.hass.async_add_executor_job( ft.partial(self.get_tts_audio, message, language, options=options) ) +def _init_tts_cache_dir(hass, cache_dir): + """Init cache folder.""" + if not os.path.isabs(cache_dir): + cache_dir = hass.config.path(cache_dir) + if not os.path.isdir(cache_dir): + _LOGGER.info("Create cache dir %s", cache_dir) + os.mkdir(cache_dir) + return cache_dir + + +def _get_cache_files(cache_dir): + """Return a dict of given engine files.""" + cache = {} + + folder_data = os.listdir(cache_dir) + for file_data in folder_data: + record = _RE_VOICE_FILE.match(file_data) + if record: + key = KEY_PATTERN.format( + record.group(1), record.group(2), record.group(3), record.group(4), + ) + cache[key.lower()] = file_data.lower() + return cache + + class TextToSpeechUrlView(HomeAssistantView): """TTS view to get a url to a generated speech file.""" diff --git a/tests/common.py b/tests/common.py index 8fdcc9b8f860ce..9790a8a7131ac1 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,6 +14,8 @@ from unittest.mock import MagicMock, Mock, patch import uuid +from aiohttp.test_utils import unused_port as get_test_instance_port # noqa + from homeassistant import auth, config_entries, core as ha, loader from homeassistant.auth import ( auth_store, @@ -37,7 +39,6 @@ EVENT_PLATFORM_DISCOVERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, - SERVER_PORT, STATE_OFF, STATE_ON, ) @@ -59,7 +60,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.yaml.loader as yaml_loader -_TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" @@ -217,18 +217,6 @@ def clear_instance(event): return hass -def get_test_instance_port(): - """Return unused port for running test instance. - - The socket that holds the default port does not get released when we stop - HA in a different test case. Until I have figured out what is going on, - let's run each test on a different port. - """ - global _TEST_INSTANCE_PORT - _TEST_INSTANCE_PORT += 1 - return _TEST_INSTANCE_PORT - - def async_mock_service(hass, domain, service, schema=None): """Set up a fake service & return a calls log list to this service.""" calls = [] diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 62c4bc3a065511..ab5d562ffc802c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1,14 +1,12 @@ """The tests for the TTS component.""" import ctypes import os -import shutil from unittest.mock import PropertyMock, patch import pytest -import requests +import yarl from homeassistant.components.demo.tts import DemoProvider -import homeassistant.components.http as http from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, @@ -17,260 +15,267 @@ SERVICE_PLAY_MEDIA, ) import homeassistant.components.tts as tts -from homeassistant.setup import async_setup_component, setup_component - -from tests.common import ( - assert_setup_component, - get_test_home_assistant, - get_test_instance_port, - mock_service, - mock_storage, -) +from homeassistant.components.tts import _get_cache_files +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_mock_service -@pytest.fixture(autouse=True) -def mutagen_mock(): - """Mock writing tags.""" - with patch( - "homeassistant.components.tts.SpeechManager.write_tags", - side_effect=lambda *args: args[1], - ): - yield +def relative_url(url): + """Convert an absolute url to a relative one.""" + return str(yarl.URL(url).relative()) -class TestTTS: - """Test the Google speech component.""" - def setup_method(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.demo_provider = DemoProvider("en") - self.default_tts_cache = self.hass.config.path(tts.DEFAULT_CACHE_DIR) - self.mock_storage = mock_storage() - self.mock_storage.__enter__() +@pytest.fixture +def demo_provider(): + """Demo TTS provider.""" + return DemoProvider("en") - setup_component( - self.hass, - http.DOMAIN, - {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}}, - ) - def teardown_method(self): - """Stop everything that was started.""" - self.hass.stop() - self.mock_storage.__exit__(None, None, None) +@pytest.fixture(autouse=True) +def mock_get_cache_files(): + """Mock the list TTS cache function.""" + with patch( + "homeassistant.components.tts._get_cache_files", return_value={} + ) as mock_cache_files: + yield mock_cache_files - if os.path.isdir(self.default_tts_cache): - shutil.rmtree(self.default_tts_cache) - def test_setup_component_demo(self): - """Set up the demo platform with defaults.""" - config = {tts.DOMAIN: {"platform": "demo"}} +@pytest.fixture(autouse=True) +def mock_init_cache_dir(): + """Mock the TTS cache dir in memory.""" + with patch( + "homeassistant.components.tts._init_tts_cache_dir", + side_effect=lambda hass, cache_dir: hass.config.path(cache_dir), + ) as mock_cache_dir: + yield mock_cache_dir - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - assert self.hass.services.has_service(tts.DOMAIN, "demo_say") - assert self.hass.services.has_service(tts.DOMAIN, "clear_cache") +@pytest.fixture +def empty_cache_dir(tmp_path, mock_init_cache_dir, mock_get_cache_files): + """Mock the TTS cache dir with empty dir.""" + mock_init_cache_dir.side_effect = None + mock_init_cache_dir.return_value = str(tmp_path) - @patch("os.mkdir", side_effect=OSError(2, "No access")) - def test_setup_component_demo_no_access_cache_folder(self, mock_mkdir): - """Set up the demo platform with defaults.""" - config = {tts.DOMAIN: {"platform": "demo"}} + # Restore original get cache files behavior, we're working with a real dir. + mock_get_cache_files.side_effect = _get_cache_files - assert not setup_component(self.hass, tts.DOMAIN, config) + return tmp_path - assert not self.hass.services.has_service(tts.DOMAIN, "demo_say") - assert not self.hass.services.has_service(tts.DOMAIN, "clear_cache") - def test_setup_component_and_test_service(self): - """Set up the demo platform and call service.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +@pytest.fixture(autouse=True) +def mutagen_mock(): + """Mock writing tags.""" + with patch( + "homeassistant.components.tts.SpeechManager.write_tags", + side_effect=lambda *args: args[1], + ): + yield - config = {tts.DOMAIN: {"platform": "demo"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) +async def test_setup_component_demo(hass): + """Set up the demo platform with defaults.""" + config = {tts.DOMAIN: {"platform": "demo"}} - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( - self.hass.config.api.base_url - ) - assert os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) - ) + assert hass.services.has_service(tts.DOMAIN, "demo_say") + assert hass.services.has_service(tts.DOMAIN, "clear_cache") - def test_setup_component_and_test_service_with_config_language(self): - """Set up the demo platform and call service.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} +async def test_setup_component_demo_no_access_cache_folder(hass, mock_init_cache_dir): + """Set up the demo platform with defaults.""" + config = {tts.DOMAIN: {"platform": "demo"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + mock_init_cache_dir.side_effect = OSError(2, "No access") + assert not await async_setup_component(hass, tts.DOMAIN, config) - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() + assert not hass.services.has_service(tts.DOMAIN, "demo_say") + assert not hass.services.has_service(tts.DOMAIN, "clear_cache") - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format( - self.hass.config.api.base_url - ) - assert os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", - ) - ) - def test_setup_component_and_test_service_with_wrong_conf_language(self): - """Set up the demo platform and call service with wrong config.""" - config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}} +async def test_setup_component_and_test_service(hass, empty_cache_dir): + """Set up the demo platform and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - with assert_setup_component(0, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + config = {tts.DOMAIN: {"platform": "demo"}} - def test_setup_component_and_test_service_with_service_language(self): - """Set up the demo platform and call service.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) - config = {tts.DOMAIN: {"platform": "demo"}} + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ + ATTR_MEDIA_CONTENT_ID + ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( + hass.config.api.base_url + ) + assert ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ).is_file() + + +async def test_setup_component_and_test_service_with_config_language( + hass, empty_cache_dir +): + """Set up the demo platform and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ + ATTR_MEDIA_CONTENT_ID + ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format( + hass.config.api.base_url + ) + assert ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + ).is_file() - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - }, - ) - self.hass.block_till_done() +async def test_setup_component_and_test_service_with_wrong_conf_language(hass): + """Set up the demo platform and call service with wrong config.""" + config = {tts.DOMAIN: {"platform": "demo", "language": "ru"}} - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format( - self.hass.config.api.base_url - ) - assert os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", - ) - ) + with assert_setup_component(0, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - def test_setup_component_test_service_with_wrong_service_language(self): - """Set up the demo platform and call service.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} +async def test_setup_component_and_test_service_with_service_language( + hass, empty_cache_dir +): + """Set up the demo platform and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + config = {tts.DOMAIN: {"platform": "demo"}} - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "lang", - }, - ) - self.hass.block_till_done() + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de", + }, + blocking=True, + ) + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ + ATTR_MEDIA_CONTENT_ID + ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3".format( + hass.config.api.base_url + ) + assert ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3" + ).is_file() - assert len(calls) == 0 - assert not os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3", - ) - ) - def test_setup_component_and_test_service_with_service_options(self): - """Set up the demo platform and call service with options.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +async def test_setup_component_test_service_with_wrong_service_language( + hass, empty_cache_dir +): + """Set up the demo platform and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "demo"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "lang", + }, + blocking=True, + ) + assert len(calls) == 0 + assert not ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_demo.mp3" + ).is_file() - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - tts.ATTR_OPTIONS: {"voice": "alex"}, - }, - ) - self.hass.block_till_done() - opt_hash = ctypes.c_size_t(hash(frozenset({"voice": "alex"}))).value +async def test_setup_component_and_test_service_with_service_options( + hass, empty_cache_dir +): + """Set up the demo platform and call service with options.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format( - self.hass.config.api.base_url, opt_hash - ) - assert os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format( - opt_hash - ), - ) - ) + config = {tts.DOMAIN: {"platform": "demo"}} - @patch( - "homeassistant.components.demo.tts.DemoProvider.default_options", - new_callable=PropertyMock(return_value={"voice": "alex"}), + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: {"voice": "alex"}, + }, + blocking=True, ) - def test_setup_component_and_test_with_service_options_def(self, def_mock): - """Set up the demo platform and call service with default options.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + opt_hash = ctypes.c_size_t(hash(frozenset({"voice": "alex"}))).value + + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert calls[0].data[ + ATTR_MEDIA_CONTENT_ID + ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format( + hass.config.api.base_url, opt_hash + ) + assert ( + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + ).is_file() + - config = {tts.DOMAIN: {"platform": "demo"}} +async def test_setup_component_and_test_with_service_options_def(hass, empty_cache_dir): + """Set up the demo platform and call service with default options.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + config = {tts.DOMAIN: {"platform": "demo"}} + + with assert_setup_component(1, tts.DOMAIN), patch( + "homeassistant.components.demo.tts.DemoProvider.default_options", + new_callable=PropertyMock(return_value={"voice": "alex"}), + ): + assert await async_setup_component(hass, tts.DOMAIN, config) - self.hass.services.call( + await hass.services.async_call( tts.DOMAIN, "demo_say", { @@ -278,9 +283,8 @@ def test_setup_component_and_test_with_service_options_def(self, def_mock): tts.ATTR_MESSAGE: "There is someone at the door.", tts.ATTR_LANGUAGE: "de", }, + blocking=True, ) - self.hass.block_till_done() - opt_hash = ctypes.c_size_t(hash(frozenset({"voice": "alex"}))).value assert len(calls) == 1 @@ -288,362 +292,341 @@ def test_setup_component_and_test_with_service_options_def(self, def_mock): assert calls[0].data[ ATTR_MEDIA_CONTENT_ID ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{}_demo.mp3".format( - self.hass.config.api.base_url, opt_hash + hass.config.api.base_url, opt_hash ) assert os.path.isfile( os.path.join( - self.default_tts_cache, + empty_cache_dir, "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format( opt_hash ), ) ) - def test_setup_component_and_test_service_with_service_options_wrong(self): - """Set up the demo platform and call service with wrong options.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} +async def test_setup_component_and_test_service_with_service_options_wrong( + hass, empty_cache_dir +): + """Set up the demo platform and call service with wrong options.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + config = {tts.DOMAIN: {"platform": "demo"}} - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_LANGUAGE: "de", - tts.ATTR_OPTIONS: {"speed": 1}, - }, - ) - self.hass.block_till_done() + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_LANGUAGE: "de", + tts.ATTR_OPTIONS: {"speed": 1}, + }, + blocking=True, + ) + opt_hash = ctypes.c_size_t(hash(frozenset({"speed": 1}))).value - opt_hash = ctypes.c_size_t(hash(frozenset({"speed": 1}))).value + assert len(calls) == 0 + assert not ( + empty_cache_dir + / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{opt_hash}_demo.mp3" + ).is_file() - assert len(calls) == 0 - assert not os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_{0}_demo.mp3".format( - opt_hash - ), - ) - ) - def test_setup_component_and_test_service_with_base_url_set(self): - """Set up the demo platform with ``base_url`` set and call service.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +async def test_setup_component_and_test_service_with_base_url_set(hass): + """Set up the demo platform with ``base_url`` set and call service.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}} + config = {tts.DOMAIN: {"platform": "demo", "base_url": "http://fnord"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC + assert ( + calls[0].data[ATTR_MEDIA_CONTENT_ID] == "http://fnord" + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + "_en_-_demo.mp3" + ) - assert len(calls) == 1 - assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC - assert ( - calls[0].data[ATTR_MEDIA_CONTENT_ID] == "http://fnord" - "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" - "_en_-_demo.mp3" - ) - def test_setup_component_and_test_service_clear_cache(self): - """Set up the demo platform and call service clear cache.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +async def test_setup_component_and_test_service_clear_cache(hass, empty_cache_dir): + """Set up the demo platform and call service clear cache.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo"}} + config = {tts.DOMAIN: {"platform": "demo"}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + # To make sure the file is persisted + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ).is_file() + + await hass.services.async_call( + tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {}, blocking=True + ) - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() + assert not ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ).is_file() - assert len(calls) == 1 - assert os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) - ) - self.hass.services.call(tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {}) - self.hass.block_till_done() +async def test_setup_component_and_test_service_with_receive_voice( + hass, demo_provider, hass_client +): + """Set up the demo platform and call service and receive voice.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - assert not os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) - ) + config = {tts.DOMAIN: {"platform": "demo"}} - def test_setup_component_and_test_service_with_receive_voice(self): - """Set up the demo platform and call service and receive voice.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - config = {tts.DOMAIN: {"platform": "demo"}} + client = await hass_client() - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + + req = await client.get(relative_url(calls[0].data[ATTR_MEDIA_CONTENT_ID])) + _, demo_data = demo_provider.get_tts_audio("bla", "en") + demo_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", + demo_data, + demo_provider, + "AI person is in front of your door.", + "en", + None, + ) + assert req.status == 200 + assert await req.read() == demo_data - self.hass.start() - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() +async def test_setup_component_and_test_service_with_receive_voice_german( + hass, demo_provider, hass_client +): + """Set up the demo platform and call service and receive voice.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - assert len(calls) == 1 - req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID]) - _, demo_data = self.demo_provider.get_tts_audio("bla", "en") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - demo_data, - self.demo_provider, - "AI person is in front of your door.", - "en", - None, - ) - assert req.status_code == 200 - assert req.content == demo_data + config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} - def test_setup_component_and_test_service_with_receive_voice_german(self): - """Set up the demo platform and call service and receive voice.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - config = {tts.DOMAIN: {"platform": "demo", "language": "de"}} + client = await hass_client() - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + req = await client.get(relative_url(calls[0].data[ATTR_MEDIA_CONTENT_ID])) + _, demo_data = demo_provider.get_tts_audio("bla", "de") + demo_data = tts.SpeechManager.write_tags( + "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", + demo_data, + demo_provider, + "There is someone at the door.", + "de", + None, + ) + assert req.status == 200 + assert await req.read() == demo_data - self.hass.start() - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() +async def test_setup_component_and_web_view_wrong_file(hass, hass_client): + """Set up the demo platform and receive wrong file from web.""" + config = {tts.DOMAIN: {"platform": "demo"}} - assert len(calls) == 1 - req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID]) - _, demo_data = self.demo_provider.get_tts_audio("bla", "de") - demo_data = tts.SpeechManager.write_tags( - "42f18378fd4393d18c8dd11d03fa9563c1e54491_de_-_demo.mp3", - demo_data, - self.demo_provider, - "There is someone at the door.", - "de", - None, - ) - assert req.status_code == 200 - assert req.content == demo_data + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - def test_setup_component_and_web_view_wrong_file(self): - """Set up the demo platform and receive wrong file from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + client = await hass_client() - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" - self.hass.start() + req = await client.get(url) + assert req.status == 404 - url = ( - "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" - ).format(self.hass.config.api.base_url) - req = requests.get(url) - assert req.status_code == 404 +async def test_setup_component_and_web_view_wrong_filename(hass, hass_client): + """Set up the demo platform and receive wrong filename from web.""" + config = {tts.DOMAIN: {"platform": "demo"}} - def test_setup_component_and_web_view_wrong_filename(self): - """Set up the demo platform and receive wrong filename from web.""" - config = {tts.DOMAIN: {"platform": "demo"}} + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + client = await hass_client() - self.hass.start() + url = "/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3" - url = ( - "{}/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd_en_-_demo.mp3" - ).format(self.hass.config.api.base_url) + req = await client.get(url) + assert req.status == 404 - req = requests.get(url) - assert req.status_code == 404 - def test_setup_component_test_without_cache(self): - """Set up demo platform without cache.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +async def test_setup_component_test_without_cache(hass, empty_cache_dir): + """Set up demo platform without cache.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - config = {tts.DOMAIN: {"platform": "demo", "cache": False}} + config = {tts.DOMAIN: {"platform": "demo", "cache": False}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 + assert not ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ).is_file() + + +async def test_setup_component_test_with_cache_call_service_without_cache( + hass, empty_cache_dir +): + """Set up demo platform with cache and call service without cache.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "demo_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + tts.ATTR_CACHE: False, + }, + blocking=True, + ) + assert len(calls) == 1 + assert not ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ).is_file() - assert len(calls) == 1 - assert not os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) - ) - def test_setup_component_test_with_cache_call_service_without_cache(self): - """Set up demo platform with cache and call service without cache.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) +async def test_setup_component_test_with_cache_dir( + hass, empty_cache_dir, demo_provider +): + """Set up demo platform with cache and call service without cache.""" + calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + _, demo_data = demo_provider.get_tts_audio("bla", "en") + cache_file = ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ) + + with open(cache_file, "wb") as voice_file: + voice_file.write(demo_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "demo", "cache": True}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - self.hass.services.call( + with patch( + "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", + return_value=(None, None), + ): + await hass.services.async_call( tts.DOMAIN, "demo_say", { "entity_id": "media_player.something", tts.ATTR_MESSAGE: "There is someone at the door.", - tts.ATTR_CACHE: False, }, + blocking=True, ) - self.hass.block_till_done() - - assert len(calls) == 1 - assert not os.path.isfile( - os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) - ) - - def test_setup_component_test_with_cache_dir(self): - """Set up demo platform with cache and call service without cache.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - _, demo_data = self.demo_provider.get_tts_audio("bla", "en") - cache_file = os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) + assert len(calls) == 1 + assert calls[0].data[ + ATTR_MEDIA_CONTENT_ID + ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( + hass.config.api.base_url + ) - os.mkdir(self.default_tts_cache) - with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) - - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - with patch( - "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", - return_value=(None, None), - ): - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() - assert len(calls) == 1 - assert calls[0].data[ - ATTR_MEDIA_CONTENT_ID - ] == "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3".format( - self.hass.config.api.base_url - ) +async def test_setup_component_test_with_error_on_get_tts(hass): + """Set up demo platform with wrong get_tts_audio.""" + config = {tts.DOMAIN: {"platform": "demo"}} - @patch( + with assert_setup_component(1, tts.DOMAIN), patch( "homeassistant.components.demo.tts.DemoProvider.get_tts_audio", return_value=(None, None), - ) - def test_setup_component_test_with_error_on_get_tts(self, tts_mock): - """Set up demo platform with wrong get_tts_audio.""" - calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) - - config = {tts.DOMAIN: {"platform": "demo"}} - - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) - - self.hass.services.call( - tts.DOMAIN, - "demo_say", - { - "entity_id": "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - ) - self.hass.block_till_done() + ): + assert await async_setup_component(hass, tts.DOMAIN, config) - assert len(calls) == 0 - def test_setup_component_load_cache_retrieve_without_mem_cache(self): - """Set up component and load cache and get without mem cache.""" - _, demo_data = self.demo_provider.get_tts_audio("bla", "en") - cache_file = os.path.join( - self.default_tts_cache, - "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3", - ) +async def test_setup_component_load_cache_retrieve_without_mem_cache( + hass, demo_provider, empty_cache_dir, hass_client +): + """Set up component and load cache and get without mem cache.""" + _, demo_data = demo_provider.get_tts_audio("bla", "en") + cache_file = ( + empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" + ) - os.mkdir(self.default_tts_cache) - with open(cache_file, "wb") as voice_file: - voice_file.write(demo_data) + with open(cache_file, "wb") as voice_file: + voice_file.write(demo_data) - config = {tts.DOMAIN: {"platform": "demo", "cache": True}} + config = {tts.DOMAIN: {"platform": "demo", "cache": True}} - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + with assert_setup_component(1, tts.DOMAIN): + assert await async_setup_component(hass, tts.DOMAIN, config) - self.hass.start() + client = await hass_client() - url = ( - "{}/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" - ).format(self.hass.config.api.base_url) + url = "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_demo.mp3" - req = requests.get(url) - assert req.status_code == 200 - assert req.content == demo_data + req = await client.get(url) + assert req.status == 200 + assert await req.read() == demo_data async def test_setup_component_and_web_get_url(hass, hass_client): @@ -666,10 +649,6 @@ async def test_setup_component_and_web_get_url(hass, hass_client): ) ) - tts_cache = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(tts_cache): - shutil.rmtree(tts_cache) - async def test_setup_component_and_web_get_url_bad_config(hass, hass_client): """Set up the demo platform and receive wrong file from web.""" From 30fd9950e225e99eee0677d28f19c86bbfd988ec Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 2 Apr 2020 12:18:53 -0500 Subject: [PATCH 09/44] Add remote platform to directv (#32790) * add remote platform to directv. * Update __init__.py * Update .coveragerc * Rename remote py to remote.py * Update remote.py * squash. * Update remote.py * squash. * Update remote.py --- homeassistant/components/directv/__init__.py | 2 +- .../components/directv/manifest.json | 2 +- homeassistant/components/directv/remote.py | 106 ++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/directv/test_remote.py | 130 ++++++++++++++++++ 6 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/directv/remote.py create mode 100644 tests/components/directv/test_remote.py diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index 0be5957a29a26e..677487945be875 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -32,7 +32,7 @@ extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["media_player"] +PLATFORMS = ["media_player", "remote"] SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 4a712ba053ec8b..8474849bdaa576 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -2,7 +2,7 @@ "domain": "directv", "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", - "requirements": ["directv==0.2.0"], + "requirements": ["directv==0.3.0"], "dependencies": [], "codeowners": ["@ctalkington"], "quality_scale": "gold", diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py new file mode 100644 index 00000000000000..8bc7c2208338b4 --- /dev/null +++ b/homeassistant/components/directv/remote.py @@ -0,0 +1,106 @@ +"""Support for the DIRECTV remote.""" +from datetime import timedelta +import logging +from typing import Any, Callable, Iterable, List + +from directv import DIRECTV, DIRECTVError + +from homeassistant.components.remote import RemoteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import DIRECTVEntity +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=2) + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List, bool], None], +) -> bool: + """Load DirecTV remote based on a config entry.""" + dtv = hass.data[DOMAIN][entry.entry_id] + entities = [] + + for location in dtv.device.locations: + entities.append( + DIRECTVRemote( + dtv=dtv, name=str.title(location.name), address=location.address, + ) + ) + + async_add_entities(entities, True) + + +class DIRECTVRemote(DIRECTVEntity, RemoteDevice): + """Device that sends commands to a DirecTV receiver.""" + + def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None: + """Initialize DirecTV remote.""" + super().__init__( + dtv=dtv, name=name, address=address, + ) + + self._available = False + self._is_on = True + + @property + def available(self): + """Return if able to retrieve information from device or not.""" + return self._available + + @property + def unique_id(self): + """Return a unique ID.""" + if self._address == "0": + return self.dtv.device.info.receiver_id + + return self._address + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._is_on + + async def async_update(self) -> None: + """Update device state.""" + status = await self.dtv.status(self._address) + + if status in ("active", "standby"): + self._available = True + self._is_on = status == "active" + else: + self._available = False + self._is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.dtv.remote("poweron", self._address) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.dtv.remote("poweroff", self._address) + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device. + + Supported keys: power, poweron, poweroff, format, + pause, rew, replay, stop, advance, ffwd, record, + play, guide, active, list, exit, back, menu, info, + up, down, left, right, select, red, green, yellow, + blue, chanup, chandown, prev, 0, 1, 2, 3, 4, 5, + 6, 7, 8, 9, dash, enter + """ + for single_command in command: + try: + await self.dtv.remote(single_command, self._address) + except DIRECTVError: + _LOGGER.exception( + "Sending command %s to device %s failed", + single_command, + self._device_id, + ) diff --git a/requirements_all.txt b/requirements_all.txt index d3d1f669df9502..e5916a146fe9f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -447,7 +447,7 @@ deluge-client==1.7.1 denonavr==0.8.1 # homeassistant.components.directv -directv==0.2.0 +directv==0.3.0 # homeassistant.components.discogs discogs_client==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24986a574a17e7..d1bfa69bbd6a18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -178,7 +178,7 @@ defusedxml==0.6.0 denonavr==0.8.1 # homeassistant.components.directv -directv==0.2.0 +directv==0.3.0 # homeassistant.components.updater distro==1.4.0 diff --git a/tests/components/directv/test_remote.py b/tests/components/directv/test_remote.py new file mode 100644 index 00000000000000..1e598b358928b4 --- /dev/null +++ b/tests/components/directv/test_remote.py @@ -0,0 +1,130 @@ +"""The tests for the DirecTV remote platform.""" +from typing import Any, List + +from asynctest import patch + +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.components.directv import setup_integration +from tests.test_util.aiohttp import AiohttpClientMocker + +ATTR_UNIQUE_ID = "unique_id" +CLIENT_ENTITY_ID = f"{REMOTE_DOMAIN}.client" +MAIN_ENTITY_ID = f"{REMOTE_DOMAIN}.host" +UNAVAILABLE_ENTITY_ID = f"{REMOTE_DOMAIN}.unavailable_client" + +# pylint: disable=redefined-outer-name + + +async def async_send_command( + hass: HomeAssistantType, + command: List[str], + entity_id: Any = ENTITY_MATCH_ALL, + device: str = None, + num_repeats: str = None, + delay_secs: str = None, +) -> None: + """Send a command to a device.""" + data = {ATTR_COMMAND: command} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + if device: + data[ATTR_DEVICE] = device + + if num_repeats: + data[ATTR_NUM_REPEATS] = num_repeats + + if delay_secs: + data[ATTR_DELAY_SECS] = delay_secs + + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_SEND_COMMAND, data) + + +async def async_turn_on( + hass: HomeAssistantType, entity_id: Any = ENTITY_MATCH_ALL +) -> None: + """Turn on device.""" + data = {} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_TURN_ON, data) + + +async def async_turn_off( + hass: HomeAssistantType, entity_id: Any = ENTITY_MATCH_ALL +) -> None: + """Turn off remote.""" + data = {} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(REMOTE_DOMAIN, SERVICE_TURN_OFF, data) + + +async def test_setup( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with basic config.""" + await setup_integration(hass, aioclient_mock) + assert hass.states.get(MAIN_ENTITY_ID) + assert hass.states.get(CLIENT_ENTITY_ID) + assert hass.states.get(UNAVAILABLE_ENTITY_ID) + + +async def test_unique_id( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test unique id.""" + await setup_integration(hass, aioclient_mock) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + main = entity_registry.async_get(MAIN_ENTITY_ID) + assert main.unique_id == "028877455858" + + client = entity_registry.async_get(CLIENT_ENTITY_ID) + assert client.unique_id == "2CA17D1CD30X" + + unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID) + assert unavailable_client.unique_id == "9XXXXXXXXXX9" + + +async def test_main_services( + hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the different services.""" + await setup_integration(hass, aioclient_mock) + + with patch("directv.DIRECTV.remote") as remote_mock: + await async_turn_off(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + remote_mock.assert_called_once_with("poweroff", "0") + + with patch("directv.DIRECTV.remote") as remote_mock: + await async_turn_on(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + remote_mock.assert_called_once_with("poweron", "0") + + with patch("directv.DIRECTV.remote") as remote_mock: + await async_send_command(hass, ["dash"], MAIN_ENTITY_ID) + await hass.async_block_till_done() + remote_mock.assert_called_once_with("dash", "0") From 9fd019244121434fe43ee4b4fe1e7712791ac7b0 Mon Sep 17 00:00:00 2001 From: AJ Schmidt Date: Thu, 2 Apr 2020 13:22:54 -0400 Subject: [PATCH 10/44] Remove extraneous parameter from AlarmDecoder services (#33516) --- homeassistant/components/alarmdecoder/services.yaml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/alarmdecoder/services.yaml b/homeassistant/components/alarmdecoder/services.yaml index 12268d48bb7121..1193f90ff8e608 100644 --- a/homeassistant/components/alarmdecoder/services.yaml +++ b/homeassistant/components/alarmdecoder/services.yaml @@ -1,9 +1,6 @@ alarm_keypress: description: Send custom keypresses to the alarm. fields: - entity_id: - description: Name of the alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' keypress: description: 'String to send to the alarm panel.' example: '*71' @@ -11,9 +8,6 @@ alarm_keypress: alarm_toggle_chime: description: Send the alarm the toggle chime command. fields: - entity_id: - description: Name of the alarm control panel to trigger. - example: 'alarm_control_panel.downstairs' code: description: A required code to toggle the alarm control panel chime with. example: 1234 From 8b0a0ee521e97df1637b860b464263109fa9cff4 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 2 Apr 2020 13:25:28 -0400 Subject: [PATCH 11/44] Don't write storage to disk while stopping (#33456) * Don't write storage to disk while stopping * rework change * lint * remove delay save and schedule final write at stop * update tests * fix test component using private methods * cleanup * always listen * use stop in restore state again * whitelist JSON exceptions for later * review comment * make zwave tests use mock storage --- homeassistant/core.py | 6 +-- homeassistant/helpers/restore_state.py | 9 +---- homeassistant/helpers/storage.py | 47 ++++++++++++++-------- tests/common.py | 4 +- tests/components/zwave/test_init.py | 7 ++++ tests/conftest.py | 14 ++++++- tests/helpers/test_storage.py | 55 ++++++++++++++++++++++++-- tests/ignore_uncaught_exceptions.py | 39 ++++++++++++++++++ 8 files changed, 147 insertions(+), 34 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 9265c57bbf349e..d9155ece2d3e66 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -152,7 +152,7 @@ class CoreState(enum.Enum): starting = "STARTING" running = "RUNNING" stopping = "STOPPING" - writing_data = "WRITING_DATA" + final_write = "FINAL_WRITE" def __str__(self) -> str: """Return the event.""" @@ -414,7 +414,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: # regardless of the state of the loop. if self.state == CoreState.not_running: # just ignore return - if self.state == CoreState.stopping or self.state == CoreState.writing_data: + if self.state == CoreState.stopping or self.state == CoreState.final_write: _LOGGER.info("async_stop called twice: ignored") return if self.state == CoreState.starting: @@ -428,7 +428,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: await self.async_block_till_done() # stage 2 - self.state = CoreState.writing_data + self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) await self.async_block_till_done() diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 0757770d2f7885..d57d3ad99207a3 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -4,10 +4,7 @@ import logging from typing import Any, Awaitable, Dict, List, Optional, Set, cast -from homeassistant.const import ( - EVENT_HOMEASSISTANT_FINAL_WRITE, - EVENT_HOMEASSISTANT_START, -) +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( CoreState, HomeAssistant, @@ -187,9 +184,7 @@ def _async_dump_states(*_: Any) -> None: async_track_time_interval(self.hass, _async_dump_states, STATE_DUMP_INTERVAL) # Dump states when stopping hass - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_FINAL_WRITE, _async_dump_states - ) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_dump_states) @callback def async_restore_entity_added(self, entity_id: str) -> None: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 5885aa01e6fbf9..00df728fb36d08 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -6,7 +6,7 @@ from typing import Any, Callable, Dict, List, Optional, Type, Union from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.loader import bind_hass from homeassistant.util import json as json_util @@ -72,7 +72,7 @@ def __init__( self._private = private self._data: Optional[Dict[str, Any]] = None self._unsub_delay_listener: Optional[CALLBACK_TYPE] = None - self._unsub_stop_listener: Optional[CALLBACK_TYPE] = None + self._unsub_final_write_listener: Optional[CALLBACK_TYPE] = None self._write_lock = asyncio.Lock() self._load_task: Optional[asyncio.Future] = None self._encoder = encoder @@ -132,7 +132,12 @@ async def async_save(self, data: Union[Dict, List]) -> None: self._data = {"version": self.version, "key": self.key, "data": data} self._async_cleanup_delay_listener() - self._async_cleanup_stop_listener() + self._async_cleanup_final_write_listener() + + if self.hass.state == CoreState.stopping: + self._async_ensure_final_write_listener() + return + await self._async_handle_write_data() @callback @@ -141,27 +146,31 @@ def async_delay_save(self, data_func: Callable[[], Dict], delay: float = 0) -> N self._data = {"version": self.version, "key": self.key, "data_func": data_func} self._async_cleanup_delay_listener() + self._async_cleanup_final_write_listener() + + if self.hass.state == CoreState.stopping: + self._async_ensure_final_write_listener() + return self._unsub_delay_listener = async_call_later( self.hass, delay, self._async_callback_delayed_write ) - - self._async_ensure_stop_listener() + self._async_ensure_final_write_listener() @callback - def _async_ensure_stop_listener(self): + def _async_ensure_final_write_listener(self): """Ensure that we write if we quit before delay has passed.""" - if self._unsub_stop_listener is None: - self._unsub_stop_listener = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_stop_write + if self._unsub_final_write_listener is None: + self._unsub_final_write_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_final_write ) @callback - def _async_cleanup_stop_listener(self): + def _async_cleanup_final_write_listener(self): """Clean up a stop listener.""" - if self._unsub_stop_listener is not None: - self._unsub_stop_listener() - self._unsub_stop_listener = None + if self._unsub_final_write_listener is not None: + self._unsub_final_write_listener() + self._unsub_final_write_listener = None @callback def _async_cleanup_delay_listener(self): @@ -172,13 +181,17 @@ def _async_cleanup_delay_listener(self): async def _async_callback_delayed_write(self, _now): """Handle a delayed write callback.""" + # catch the case where a call is scheduled and then we stop Home Assistant + if self.hass.state == CoreState.stopping: + self._async_ensure_final_write_listener() + return self._unsub_delay_listener = None - self._async_cleanup_stop_listener() + self._async_cleanup_final_write_listener() await self._async_handle_write_data() - async def _async_callback_stop_write(self, _event): - """Handle a write because Home Assistant is stopping.""" - self._unsub_stop_listener = None + async def _async_callback_final_write(self, _event): + """Handle a write because Home Assistant is in final write state.""" + self._unsub_final_write_listener = None self._async_cleanup_delay_listener() await self._async_handle_write_data() diff --git a/tests/common.py b/tests/common.py index 9790a8a7131ac1..f39d458bbe0dfd 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1005,7 +1005,7 @@ async def flush_store(store): if store._data is None: return - store._async_cleanup_stop_listener() + store._async_cleanup_final_write_listener() store._async_cleanup_delay_listener() await store._async_handle_write_data() @@ -1018,7 +1018,7 @@ async def get_system_health_info(hass, domain): def mock_integration(hass, module): """Mock an integration.""" integration = loader.Integration( - hass, f"homeassistant.components.{module.DOMAIN}", None, module.mock_manifest(), + hass, f"homeassistant.components.{module.DOMAIN}", None, module.mock_manifest() ) _LOGGER.info("Adding mock integration: %s", module.DOMAIN) diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index 4d358bde770c22..6d19022f9592bd 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -28,6 +28,7 @@ get_test_home_assistant, mock_coro, mock_registry, + mock_storage, ) from tests.mock.zwave import MockEntityValues, MockNetwork, MockNode, MockValue @@ -827,6 +828,8 @@ def set_mock_openzwave(self, mock_openzwave): def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() self.hass.start() self.registry = mock_registry(self.hass) @@ -862,6 +865,7 @@ def setUp(self): def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) @patch.object(zwave, "import_module") @patch.object(zwave, "discovery") @@ -1194,6 +1198,8 @@ def set_mock_openzwave(self, mock_openzwave): def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() self.hass.start() # Initialize zwave @@ -1209,6 +1215,7 @@ def tearDown(self): # pylint: disable=invalid-name self.hass.services.call("zwave", "stop_network", {}) self.hass.block_till_done() self.hass.stop() + self.mock_storage.__exit__(None, None, None) def test_add_node(self): """Test zwave add_node service.""" diff --git a/tests/conftest.py b/tests/conftest.py index 0963d1514904b0..f93d5190350637 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,10 @@ from homeassistant.setup import async_setup_component from homeassistant.util import location -from tests.ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS +from tests.ignore_uncaught_exceptions import ( + IGNORE_UNCAUGHT_EXCEPTIONS, + IGNORE_UNCAUGHT_JSON_EXCEPTIONS, +) pytest.register_assert_rewrite("tests.common") @@ -104,6 +107,13 @@ def exc_handle(loop, context): continue if isinstance(ex, ServiceNotFound): continue + if ( + isinstance(ex, TypeError) + and "is not JSON serializable" in str(ex) + and (request.module.__name__, request.function.__name__) + in IGNORE_UNCAUGHT_JSON_EXCEPTIONS + ): + continue raise ex @@ -211,7 +221,7 @@ def hass_client(hass, aiohttp_client, hass_access_token): async def auth_client(): """Return an authenticated client.""" return await aiohttp_client( - hass.http.app, headers={"Authorization": f"Bearer {hass_access_token}"}, + hass.http.app, headers={"Authorization": f"Bearer {hass_access_token}"} ) return auth_client diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index dcadd4d4369f34..61648c85ada9e4 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -6,7 +6,11 @@ import pytest -from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE +from homeassistant.const import ( + EVENT_HOMEASSISTANT_FINAL_WRITE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState from homeassistant.helpers import storage from homeassistant.util import dt @@ -79,10 +83,18 @@ async def test_saving_with_delay(hass, store, hass_storage): } -async def test_saving_on_stop(hass, hass_storage): +async def test_saving_on_final_write(hass, hass_storage): """Test delayed saves trigger when we quit Home Assistant.""" store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) - store.async_delay_save(lambda: MOCK_DATA, 1) + store.async_delay_save(lambda: MOCK_DATA, 5) + assert store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() assert store.key not in hass_storage hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) @@ -94,6 +106,43 @@ async def test_saving_on_stop(hass, hass_storage): } +async def test_not_delayed_saving_while_stopping(hass, hass_storage): + """Test delayed saves don't write after the stop event has fired.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + hass.state = CoreState.stopping + + store.async_delay_save(lambda: MOCK_DATA, 1) + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done() + assert store.key not in hass_storage + + +async def test_not_delayed_saving_after_stopping(hass, hass_storage): + """Test delayed saves don't write after stop if issued before stopping Home Assistant.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + store.async_delay_save(lambda: MOCK_DATA, 10) + assert store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + assert store.key not in hass_storage + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=15)) + await hass.async_block_till_done() + assert store.key not in hass_storage + + +async def test_not_saving_while_stopping(hass, hass_storage): + """Test saves don't write when stopping Home Assistant.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + hass.state = CoreState.stopping + await store.async_save(MOCK_DATA) + assert store.key not in hass_storage + + async def test_loading_while_delay(hass, store, hass_storage): """Test we load new data even if not written yet.""" await store.async_save({"delay": "no"}) diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index df623a2fc20f27..126cb37f14fdeb 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -89,3 +89,42 @@ ("tests.components.yr.test_sensor", "test_forecast_setup"), ("tests.components.zwave.test_init", "test_power_schemes"), ] + +IGNORE_UNCAUGHT_JSON_EXCEPTIONS = [ + ("tests.components.spotify.test_config_flow", "test_full_flow"), + ("tests.components.smartthings.test_init", "test_config_entry_loads_platforms"), + ( + "tests.components.smartthings.test_init", + "test_scenes_unauthorized_loads_platforms", + ), + ( + "tests.components.smartthings.test_init", + "test_config_entry_loads_unconnected_cloud", + ), + ("tests.components.samsungtv.test_config_flow", "test_ssdp"), + ("tests.components.samsungtv.test_config_flow", "test_user_websocket"), + ("tests.components.samsungtv.test_config_flow", "test_user_already_configured"), + ("tests.components.samsungtv.test_config_flow", "test_autodetect_websocket"), + ("tests.components.samsungtv.test_config_flow", "test_autodetect_websocket_ssl"), + ("tests.components.samsungtv.test_config_flow", "test_ssdp_already_configured"), + ("tests.components.samsungtv.test_config_flow", "test_ssdp_noprefix"), + ("tests.components.samsungtv.test_config_flow", "test_user_legacy"), + ("tests.components.samsungtv.test_config_flow", "test_autodetect_legacy"), + ( + "tests.components.samsungtv.test_media_player", + "test_select_source_invalid_source", + ), + ( + "tests.components.samsungtv.test_media_player", + "test_play_media_channel_as_string", + ), + ( + "tests.components.samsungtv.test_media_player", + "test_play_media_channel_as_non_positive", + ), + ("tests.components.samsungtv.test_media_player", "test_turn_off_websocket"), + ("tests.components.samsungtv.test_media_player", "test_play_media_invalid_type"), + ("tests.components.harmony.test_config_flow", "test_form_import"), + ("tests.components.harmony.test_config_flow", "test_form_ssdp"), + ("tests.components.harmony.test_config_flow", "test_user_form"), +] From 1d89d22a381e10944ed3420b435beeae32f4fa5e Mon Sep 17 00:00:00 2001 From: mvn23 Date: Thu, 2 Apr 2020 19:29:41 +0200 Subject: [PATCH 12/44] Update pyotgw to 0.6b1 (#33529) --- homeassistant/components/opentherm_gw/config_flow.py | 3 ++- homeassistant/components/opentherm_gw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/opentherm_gw/test_config_flow.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index b52641105e4445..dc1b943686f252 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -2,6 +2,7 @@ import asyncio import pyotgw +from pyotgw import vars as gw_vars from serial import SerialException import voluptuous as vol @@ -53,7 +54,7 @@ async def test_connection(): otgw = pyotgw.pyotgw() status = await otgw.connect(self.hass.loop, device) await otgw.disconnect() - return status.get(pyotgw.OTGW_ABOUT) + return status.get(gw_vars.OTGW_ABOUT) try: res = await asyncio.wait_for(test_connection(), timeout=10) diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 81a41b8fb47b04..d0cbb4351c73b2 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -2,7 +2,7 @@ "domain": "opentherm_gw", "name": "OpenTherm Gateway", "documentation": "https://www.home-assistant.io/integrations/opentherm_gw", - "requirements": ["pyotgw==0.5b1"], + "requirements": ["pyotgw==0.6b1"], "dependencies": [], "codeowners": ["@mvn23"], "config_flow": true diff --git a/requirements_all.txt b/requirements_all.txt index e5916a146fe9f1..5201014713155d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1465,7 +1465,7 @@ pyoppleio==1.0.5 pyota==2.0.5 # homeassistant.components.opentherm_gw -pyotgw==0.5b1 +pyotgw==0.6b1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1bfa69bbd6a18..b1977b187aa39f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -570,7 +570,7 @@ pyopenuv==1.0.9 pyopnsense==0.2.0 # homeassistant.components.opentherm_gw -pyotgw==0.5b1 +pyotgw==0.6b1 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 0adcdb188d08b9..f57ad20f5d5d18 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import patch -from pyotgw import OTGW_ABOUT +from pyotgw.vars import OTGW_ABOUT from serial import SerialException from homeassistant import config_entries, data_entry_flow, setup From 39408ab2408207ced5b13d181e59e0b316f3f1c5 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Thu, 2 Apr 2020 21:26:36 +0300 Subject: [PATCH 13/44] Add cover platform to Dynalite (#32594) * lib version * unit-test refactoring * added type hints * added cover * added test to see that consts have the same value as library consts * Update tests/components/dynalite/test_init.py Co-Authored-By: Martin Hjelmare * removed trigger template * Update homeassistant/components/dynalite/__init__.py Co-Authored-By: Martin Hjelmare * Update homeassistant/components/dynalite/const.py Co-Authored-By: Martin Hjelmare * removed CONF_TRIGGER from const corrected type hints - not clear why mypy didn't catch it * conversion of the config to library CONFs * moved to use the value since it should come from the library * taking CONF_HOST from homeassistant.const instead of module const * use dict.get removed leftover log * force device_class to be from homeassistant consts * move dict.get to inline * removed CONF from values changed "channelcover" to "channel_cover" * moved some CONF values out of const.py and taking them from homeassistant.const * verifying that device class is a valid HA device class * moved shutter to home assistant const Co-authored-by: Martin Hjelmare --- homeassistant/components/dynalite/__init__.py | 110 ++++++++++++++---- homeassistant/components/dynalite/bridge.py | 33 ++++-- .../components/dynalite/config_flow.py | 6 +- homeassistant/components/dynalite/const.py | 41 +++++-- .../components/dynalite/convert_config.py | 78 +++++++++++++ homeassistant/components/dynalite/cover.py | 98 ++++++++++++++++ .../components/dynalite/dynalitebase.py | 31 +++-- homeassistant/components/dynalite/light.py | 18 ++- .../components/dynalite/manifest.json | 2 +- homeassistant/components/dynalite/switch.py | 14 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dynalite/common.py | 1 + tests/components/dynalite/test_bridge.py | 8 ++ tests/components/dynalite/test_config_flow.py | 66 ++++++----- tests/components/dynalite/test_cover.py | 100 ++++++++++++++++ tests/components/dynalite/test_init.py | 83 +++++++++++-- 17 files changed, 587 insertions(+), 106 deletions(-) create mode 100644 homeassistant/components/dynalite/convert_config.py create mode 100644 homeassistant/components/dynalite/cover.py create mode 100644 tests/components/dynalite/test_cover.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 973d09a384f137..2f2ed8f2fa2293 100755 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,43 +1,55 @@ """Support for the Dynalite networks.""" import asyncio +from typing import Any, Dict, Union import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv # Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, CONF_ACTIVE, - CONF_ACTIVE_INIT, - CONF_ACTIVE_OFF, - CONF_ACTIVE_ON, CONF_AREA, CONF_AUTO_DISCOVER, CONF_BRIDGES, CONF_CHANNEL, - CONF_CHANNEL_TYPE, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, CONF_DEFAULT, + CONF_DEVICE_CLASS, + CONF_DURATION, CONF_FADE, - CONF_NAME, CONF_NO_DEFAULT, - CONF_POLLTIMER, - CONF_PORT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, DEFAULT_CHANNEL_TYPE, DEFAULT_NAME, DEFAULT_PORT, + DEFAULT_TEMPLATES, DOMAIN, ENTITY_PLATFORMS, LOGGER, ) -def num_string(value): +def num_string(value: Union[int, str]) -> str: """Test if value is a string of digits, aka an integer.""" new_value = str(value) if new_value.isdigit(): @@ -49,7 +61,7 @@ def num_string(value): { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_CHANNEL_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( "light", "switch" ), } @@ -63,15 +75,66 @@ def num_string(value): PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) -AREA_DATA_SCHEMA = vol.Schema( +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_NO_DEFAULT): vol.Coerce(bool), - vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - }, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: Dict[str, Any]) -> Dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for template in DEFAULT_TEMPLATES: + for conf in DEFAULT_TEMPLATES[template]: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + + +AREA_DATA_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) ) AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) @@ -85,13 +148,14 @@ def num_string(value): vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), - vol.Optional(CONF_POLLTIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, vol.Optional(CONF_ACTIVE, default=False): vol.Any( - CONF_ACTIVE_ON, CONF_ACTIVE_OFF, CONF_ACTIVE_INIT, cv.boolean + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean ), vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, } ) @@ -105,7 +169,7 @@ def num_string(value): ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: Dict[str, Any]) -> bool: """Set up the Dynalite platform.""" conf = config.get(DOMAIN) @@ -137,7 +201,7 @@ async def async_setup(hass, config): return True -async def async_entry_changed(hass, entry): +async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload entry since the data has changed.""" LOGGER.debug("Reconfiguring entry %s", entry.data) bridge = hass.data[DOMAIN][entry.entry_id] @@ -145,7 +209,7 @@ async def async_entry_changed(hass, entry): LOGGER.debug("Reconfiguring entry finished %s", entry.data) -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) bridge = DynaliteBridge(hass, entry.data) @@ -163,7 +227,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" LOGGER.debug("Unloading entry %s", entry.data) hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index fa0a91bfab1ca5..09cf8e25a10575 100755 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,17 +1,26 @@ """Code to handle a Dynalite bridge.""" +from typing import TYPE_CHECKING, Any, Callable, Dict, List + from dynalite_devices_lib.dynalite_devices import DynaliteDevices -from homeassistant.core import callback +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_ALL, CONF_HOST, ENTITY_PLATFORMS, LOGGER +from .const import CONF_ALL, ENTITY_PLATFORMS, LOGGER +from .convert_config import convert_config + +if TYPE_CHECKING: # pragma: no cover + from dynalite_devices_lib.dynalite_devices import ( # pylint: disable=ungrouped-imports + DynaliteBaseDevice, + ) class DynaliteBridge: """Manages a single Dynalite bridge.""" - def __init__(self, hass, config): + def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None: """Initialize the system based on host parameter.""" self.hass = hass self.area = {} @@ -23,20 +32,20 @@ def __init__(self, hass, config): new_device_func=self.add_devices_when_registered, update_device_func=self.update_device, ) - self.dynalite_devices.configure(config) + self.dynalite_devices.configure(convert_config(config)) - async def async_setup(self): + async def async_setup(self) -> bool: """Set up a Dynalite bridge.""" # Configure the dynalite devices LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config): + def reload_config(self, config: Dict[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) - self.dynalite_devices.configure(config) + self.dynalite_devices.configure(convert_config(config)) - def update_signal(self, device=None): + def update_signal(self, device: "DynaliteBaseDevice" = None) -> str: """Create signal to use to trigger entity update.""" if device: signal = f"dynalite-update-{self.host}-{device.unique_id}" @@ -45,12 +54,12 @@ def update_signal(self, device=None): return signal @callback - def update_device(self, device): + def update_device(self, device: "DynaliteBaseDevice") -> None: """Call when a device or all devices should be updated.""" if device == CONF_ALL: # This is used to signal connection or disconnection, so all devices may become available or not. log_string = ( - "Connected" if self.dynalite_devices.available else "Disconnected" + "Connected" if self.dynalite_devices.connected else "Disconnected" ) LOGGER.info("%s to dynalite host", log_string) async_dispatcher_send(self.hass, self.update_signal()) @@ -58,13 +67,13 @@ def update_device(self, device): async_dispatcher_send(self.hass, self.update_signal(device)) @callback - def register_add_devices(self, platform, async_add_devices): + def register_add_devices(self, platform: str, async_add_devices: Callable) -> None: """Add an async_add_entities for a category.""" self.async_add_devices[platform] = async_add_devices if platform in self.waiting_devices: self.async_add_devices[platform](self.waiting_devices[platform]) - def add_devices_when_registered(self, devices): + def add_devices_when_registered(self, devices: List["DynaliteBaseDevice"]) -> None: """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" for platform in ENTITY_PLATFORMS: platform_devices = [ diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index ca95c0754a6f70..4c5b2ceb7d88a4 100755 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure Dynalite hub.""" +from typing import Any, Dict + from homeassistant import config_entries from homeassistant.const import CONF_HOST @@ -12,11 +14,11 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - def __init__(self): + def __init__(self) -> None: """Initialize the Dynalite flow.""" self.host = None - async def async_step_import(self, import_info): + async def async_step_import(self, import_info: Dict[str, Any]) -> Any: """Import a new bridge as a config entry.""" LOGGER.debug("Starting async_step_import - %s", import_info) host = import_info[CONF_HOST] diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index 267b5727b83dec..ade167e1b3e7a7 100755 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -1,31 +1,54 @@ """Constants for the Dynalite component.""" import logging +from homeassistant.components.cover import DEVICE_CLASS_SHUTTER +from homeassistant.const import CONF_ROOM + LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -ENTITY_PLATFORMS = ["light", "switch"] +ENTITY_PLATFORMS = ["light", "switch", "cover"] CONF_ACTIVE = "active" -CONF_ACTIVE_INIT = "init" -CONF_ACTIVE_OFF = "off" -CONF_ACTIVE_ON = "on" +ACTIVE_INIT = "init" +ACTIVE_OFF = "off" +ACTIVE_ON = "on" CONF_ALL = "ALL" CONF_AREA = "area" CONF_AUTO_DISCOVER = "autodiscover" CONF_BRIDGES = "bridges" CONF_CHANNEL = "channel" -CONF_CHANNEL_TYPE = "type" +CONF_CHANNEL_COVER = "channel_cover" +CONF_CLOSE_PRESET = "close" CONF_DEFAULT = "default" +CONF_DEVICE_CLASS = "class" +CONF_DURATION = "duration" CONF_FADE = "fade" -CONF_HOST = "host" -CONF_NAME = "name" CONF_NO_DEFAULT = "nodefault" -CONF_POLLTIMER = "polltimer" -CONF_PORT = "port" +CONF_OPEN_PRESET = "open" +CONF_POLL_TIMER = "polltimer" CONF_PRESET = "preset" +CONF_ROOM_OFF = "room_off" +CONF_ROOM_ON = "room_on" +CONF_STOP_PRESET = "stop" +CONF_TEMPLATE = "template" +CONF_TILT_TIME = "tilt" +CONF_TIME_COVER = "time_cover" DEFAULT_CHANNEL_TYPE = "light" +DEFAULT_COVER_CLASS = DEVICE_CLASS_SHUTTER DEFAULT_NAME = "dynalite" DEFAULT_PORT = 12345 +DEFAULT_TEMPLATES = { + CONF_ROOM: [CONF_ROOM_ON, CONF_ROOM_OFF], + CONF_TIME_COVER: [ + CONF_CHANNEL_COVER, + CONF_DEVICE_CLASS, + CONF_OPEN_PRESET, + CONF_CLOSE_PRESET, + CONF_STOP_PRESET, + CONF_DURATION, + CONF_TILT_TIME, + ], +} diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py new file mode 100644 index 00000000000000..03ece744d413be --- /dev/null +++ b/homeassistant/components/dynalite/convert_config.py @@ -0,0 +1,78 @@ +"""Convert the HA config to the dynalite config.""" + +from typing import Any, Dict + +from dynalite_devices_lib import const as dyn_const + +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM, CONF_TYPE + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEFAULT, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + CONF_TIME_COVER, +) + +CONF_MAP = { + CONF_ACTIVE: dyn_const.CONF_ACTIVE, + ACTIVE_INIT: dyn_const.CONF_ACTIVE_INIT, + ACTIVE_OFF: dyn_const.CONF_ACTIVE_OFF, + ACTIVE_ON: dyn_const.CONF_ACTIVE_ON, + CONF_AREA: dyn_const.CONF_AREA, + CONF_AUTO_DISCOVER: dyn_const.CONF_AUTO_DISCOVER, + CONF_CHANNEL: dyn_const.CONF_CHANNEL, + CONF_CHANNEL_COVER: dyn_const.CONF_CHANNEL_COVER, + CONF_TYPE: dyn_const.CONF_CHANNEL_TYPE, + CONF_CLOSE_PRESET: dyn_const.CONF_CLOSE_PRESET, + CONF_DEFAULT: dyn_const.CONF_DEFAULT, + CONF_DEVICE_CLASS: dyn_const.CONF_DEVICE_CLASS, + CONF_DURATION: dyn_const.CONF_DURATION, + CONF_FADE: dyn_const.CONF_FADE, + CONF_HOST: dyn_const.CONF_HOST, + CONF_NAME: dyn_const.CONF_NAME, + CONF_NO_DEFAULT: dyn_const.CONF_NO_DEFAULT, + CONF_OPEN_PRESET: dyn_const.CONF_OPEN_PRESET, + CONF_POLL_TIMER: dyn_const.CONF_POLL_TIMER, + CONF_PORT: dyn_const.CONF_PORT, + CONF_PRESET: dyn_const.CONF_PRESET, + CONF_ROOM: dyn_const.CONF_ROOM, + CONF_ROOM_OFF: dyn_const.CONF_ROOM_OFF, + CONF_ROOM_ON: dyn_const.CONF_ROOM_ON, + CONF_STOP_PRESET: dyn_const.CONF_STOP_PRESET, + CONF_TEMPLATE: dyn_const.CONF_TEMPLATE, + CONF_TILT_TIME: dyn_const.CONF_TILT_TIME, + CONF_TIME_COVER: dyn_const.CONF_TIME_COVER, +} + + +def convert_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Convert a config dict by replacing component consts with library consts.""" + result = {} + for (key, value) in config.items(): + if isinstance(value, dict): + new_value = convert_config(value) + elif isinstance(value, str): + new_value = CONF_MAP.get(value, value) + else: + new_value = value + result[CONF_MAP.get(key, key)] = new_value + return result diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py new file mode 100644 index 00000000000000..dcf16ede58c34a --- /dev/null +++ b/homeassistant/components/dynalite/cover.py @@ -0,0 +1,98 @@ +"""Support for the Dynalite channels as covers.""" +from typing import Callable + +from homeassistant.components.cover import DEVICE_CLASSES, CoverDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback + +from .const import DEFAULT_COVER_CLASS +from .dynalitebase import DynaliteBase, async_setup_entry_base + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: + """Record the async_add_entities function to add them later when received from Dynalite.""" + + @callback + def cover_from_device(device, bridge): + if device.has_tilt: + return DynaliteCoverWithTilt(device, bridge) + return DynaliteCover(device, bridge) + + async_setup_entry_base( + hass, config_entry, async_add_entities, "cover", cover_from_device + ) + + +class DynaliteCover(DynaliteBase, CoverDevice): + """Representation of a Dynalite Channel as a Home Assistant Cover.""" + + @property + def device_class(self) -> str: + """Return the class of the device.""" + dev_cls = self._device.device_class + if dev_cls in DEVICE_CLASSES: + return dev_cls + return DEFAULT_COVER_CLASS + + @property + def current_cover_position(self) -> int: + """Return the position of the cover from 0 to 100.""" + return self._device.current_cover_position + + @property + def is_opening(self) -> bool: + """Return true if cover is opening.""" + return self._device.is_opening + + @property + def is_closing(self) -> bool: + """Return true if cover is closing.""" + return self._device.is_closing + + @property + def is_closed(self) -> bool: + """Return true if cover is closed.""" + return self._device.is_closed + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self._device.async_open_cover(**kwargs) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self._device.async_close_cover(**kwargs) + + async def async_set_cover_position(self, **kwargs) -> None: + """Set the cover position.""" + await self._device.async_set_cover_position(**kwargs) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self._device.async_stop_cover(**kwargs) + + +class DynaliteCoverWithTilt(DynaliteCover): + """Representation of a Dynalite Channel as a Home Assistant Cover that uses up and down for tilt.""" + + @property + def current_cover_tilt_position(self) -> int: + """Return the current tilt position.""" + return self._device.current_cover_tilt_position + + async def async_open_cover_tilt(self, **kwargs) -> None: + """Open cover tilt.""" + await self._device.async_open_cover_tilt(**kwargs) + + async def async_close_cover_tilt(self, **kwargs) -> None: + """Close cover tilt.""" + await self._device.async_close_cover_tilt(**kwargs) + + async def async_set_cover_tilt_position(self, **kwargs) -> None: + """Set the cover tilt position.""" + await self._device.async_set_cover_tilt_position(**kwargs) + + async def async_stop_cover_tilt(self, **kwargs) -> None: + """Stop the cover tilt.""" + await self._device.async_stop_cover_tilt(**kwargs) diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 8bb1ab2dc42ab7..31879c5c118f6b 100755 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -1,5 +1,9 @@ """Support for the Dynalite devices as entities.""" -from homeassistant.core import callback +from typing import Any, Callable, Dict + +from homeassistant.components.dynalite.bridge import DynaliteBridge +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -7,8 +11,12 @@ def async_setup_entry_base( - hass, config_entry, async_add_entities, platform, entity_from_device -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable, + platform: str, + entity_from_device: Callable, +) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) bridge = hass.data[DOMAIN][config_entry.entry_id] @@ -18,8 +26,7 @@ def async_add_entities_platform(devices): # assumes it is called with a single platform added_entities = [] for device in devices: - if device.category == platform: - added_entities.append(entity_from_device(device, bridge)) + added_entities.append(entity_from_device(device, bridge)) if added_entities: async_add_entities(added_entities) @@ -29,29 +36,29 @@ def async_add_entities_platform(devices): class DynaliteBase(Entity): """Base class for the Dynalite entities.""" - def __init__(self, device, bridge): + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: """Initialize the base class.""" self._device = device self._bridge = bridge self._unsub_dispatchers = [] @property - def name(self): + def name(self) -> str: """Return the name of the entity.""" return self._device.name @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of the entity.""" return self._device.unique_id @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" return self._device.available @property - def device_info(self): + def device_info(self) -> Dict[str, Any]: """Device info for this entity.""" return { "identifiers": {(DOMAIN, self._device.unique_id)}, @@ -59,7 +66,7 @@ def device_info(self): "manufacturer": "Dynalite", } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Added to hass so need to register to dispatch.""" # register for device specific update self._unsub_dispatchers.append( @@ -78,7 +85,7 @@ async def async_added_to_hass(self): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unregister signal dispatch listeners when being removed.""" for unsub in self._unsub_dispatchers: unsub() diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index a5b7139803c174..283b1ee2286191 100755 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -1,10 +1,16 @@ """Support for Dynalite channels as lights.""" +from typing import Callable + from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .dynalitebase import DynaliteBase, async_setup_entry_base -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" async_setup_entry_base( @@ -16,24 +22,24 @@ class DynaliteLight(DynaliteBase, Light): """Representation of a Dynalite Channel as a Home Assistant Light.""" @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return self._device.brightness @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" await self._device.async_turn_on(**kwargs) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the light off.""" await self._device.async_turn_off(**kwargs) @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_BRIGHTNESS diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index d6351db17b2439..a6ae0e96c459b6 100755 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "dependencies": [], "codeowners": ["@ziv1234"], - "requirements": ["dynalite_devices==0.1.32"] + "requirements": ["dynalite_devices==0.1.39"] } diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index 84be74cee36e6c..45e24d8193ab1f 100755 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -1,10 +1,16 @@ """Support for the Dynalite channels and presets as switches.""" +from typing import Callable + from homeassistant.components.switch import SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from .dynalitebase import DynaliteBase, async_setup_entry_base -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: Callable +) -> None: """Record the async_add_entities function to add them later when received from Dynalite.""" async_setup_entry_base( @@ -16,14 +22,14 @@ class DynaliteSwitch(DynaliteBase, SwitchDevice): """Representation of a Dynalite Channel as a Home Assistant Switch.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if switch is on.""" return self._device.is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs) -> None: """Turn the switch on.""" await self._device.async_turn_on() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs) -> None: """Turn the switch off.""" await self._device.async_turn_off() diff --git a/requirements_all.txt b/requirements_all.txt index 5201014713155d..66254138e51ab3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -474,7 +474,7 @@ dsmr_parser==0.18 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.32 +dynalite_devices==0.1.39 # homeassistant.components.rainforest_eagle eagle200_reader==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1977b187aa39f..73cea897aa4f90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -190,7 +190,7 @@ doorbirdpy==2.0.8 dsmr_parser==0.18 # homeassistant.components.dynalite -dynalite_devices==0.1.32 +dynalite_devices==0.1.39 # homeassistant.components.ee_brightbox eebrightbox==0.0.4 diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 56554efaa071fa..b90e61204441ea 100755 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -44,6 +44,7 @@ async def create_entity_from_device(hass, device): new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"] new_device_func([device]) await hass.async_block_till_done() + return mock_dyn_dev.mock_calls[1][2]["update_device_func"] async def run_service_tests(hass, device, platform, services): diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index 97759e96b699cf..938bc09f59a528 100755 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -61,8 +61,15 @@ async def test_add_devices_then_register(hass): device2.name = "NAME2" device2.unique_id = "unique2" new_device_func([device1, device2]) + device3 = Mock() + device3.category = "switch" + device3.name = "NAME3" + device3.unique_id = "unique3" + new_device_func([device3]) await hass.async_block_till_done() assert hass.states.get("light.name") + assert hass.states.get("switch.name2") + assert hass.states.get("switch.name3") async def test_register_then_add_devices(hass): @@ -89,3 +96,4 @@ async def test_register_then_add_devices(hass): new_device_func([device1, device2]) await hass.async_block_till_done() assert hass.states.get("light.name") + assert hass.states.get("switch.name2") diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 96e361e260f8ca..1a1cdc16f49a18 100755 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -1,6 +1,7 @@ """Test Dynalite config flow.""" from asynctest import CoroutineMock, patch +import pytest from homeassistant import config_entries from homeassistant.components import dynalite @@ -8,12 +9,20 @@ from tests.common import MockConfigEntry -async def run_flow(hass, connection): +@pytest.mark.parametrize( + "first_con, second_con,exp_type, exp_result, exp_reason", + [ + (True, True, "create_entry", "loaded", ""), + (False, False, "abort", "", "no_connection"), + (True, False, "create_entry", "setup_retry", ""), + ], +) +async def test_flow(hass, first_con, second_con, exp_type, exp_result, exp_reason): """Run a flow with or without errors and return result.""" host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - side_effect=connection, + side_effect=[first_con, second_con], ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, @@ -21,35 +30,18 @@ async def run_flow(hass, connection): data={dynalite.CONF_HOST: host}, ) await hass.async_block_till_done() - return result - - -async def test_flow_works(hass): - """Test a successful config flow.""" - result = await run_flow(hass, [True, True]) - assert result["type"] == "create_entry" - assert result["result"].state == "loaded" - - -async def test_flow_setup_fails(hass): - """Test a flow where async_setup fails.""" - result = await run_flow(hass, [False]) - assert result["type"] == "abort" - assert result["reason"] == "no_connection" - - -async def test_flow_setup_fails_in_setup_entry(hass): - """Test a flow where the initial check works but inside setup_entry, the bridge setup fails.""" - result = await run_flow(hass, [True, False]) - assert result["type"] == "create_entry" - assert result["result"].state == "setup_retry" + assert result["type"] == exp_type + if exp_result: + assert result["result"].state == exp_result + if exp_reason: + assert result["reason"] == exp_reason async def test_existing(hass): """Test when the entry exists with the same config.""" host = "1.2.3.4" MockConfigEntry( - domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host} + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} ).add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -81,7 +73,7 @@ async def test_existing_update(hass): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() mock_dyn_dev().configure.assert_called_once() - assert mock_dyn_dev().configure.mock_calls[0][1][0][dynalite.CONF_PORT] == port1 + assert mock_dyn_dev().configure.mock_calls[0][1][0]["port"] == port1 result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, @@ -89,6 +81,26 @@ async def test_existing_update(hass): ) await hass.async_block_till_done() assert mock_dyn_dev().configure.call_count == 2 - assert mock_dyn_dev().configure.mock_calls[1][1][0][dynalite.CONF_PORT] == port2 + assert mock_dyn_dev().configure.mock_calls[1][1][0]["port"] == port2 assert result["type"] == "abort" assert result["reason"] == "already_configured" + + +async def test_two_entries(hass): + """Test when two different entries exist with different hosts.""" + host1 = "1.2.3.4" + host2 = "5.6.7.8" + MockConfigEntry( + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host1} + ).add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={dynalite.CONF_HOST: host2}, + ) + assert result["type"] == "create_entry" + assert result["result"].state == "loaded" diff --git a/tests/components/dynalite/test_cover.py b/tests/components/dynalite/test_cover.py new file mode 100644 index 00000000000000..cef4081c60714b --- /dev/null +++ b/tests/components/dynalite/test_cover.py @@ -0,0 +1,100 @@ +"""Test Dynalite cover.""" +from dynalite_devices_lib.cover import DynaliteTimeCoverWithTiltDevice +import pytest + +from .common import ( + ATTR_ARGS, + ATTR_METHOD, + ATTR_SERVICE, + create_entity_from_device, + create_mock_device, + run_service_tests, +) + + +@pytest.fixture +def mock_device(): + """Mock a Dynalite device.""" + mock_dev = create_mock_device("cover", DynaliteTimeCoverWithTiltDevice) + mock_dev.device_class = "blind" + return mock_dev + + +async def test_cover_setup(hass, mock_device): + """Test a successful setup.""" + await create_entity_from_device(hass, mock_device) + entity_state = hass.states.get("cover.name") + assert entity_state.attributes["friendly_name"] == mock_device.name + assert ( + entity_state.attributes["current_position"] + == mock_device.current_cover_position + ) + assert ( + entity_state.attributes["current_tilt_position"] + == mock_device.current_cover_tilt_position + ) + assert entity_state.attributes["device_class"] == mock_device.device_class + await run_service_tests( + hass, + mock_device, + "cover", + [ + {ATTR_SERVICE: "open_cover", ATTR_METHOD: "async_open_cover"}, + {ATTR_SERVICE: "close_cover", ATTR_METHOD: "async_close_cover"}, + {ATTR_SERVICE: "stop_cover", ATTR_METHOD: "async_stop_cover"}, + { + ATTR_SERVICE: "set_cover_position", + ATTR_METHOD: "async_set_cover_position", + ATTR_ARGS: {"position": 50}, + }, + {ATTR_SERVICE: "open_cover_tilt", ATTR_METHOD: "async_open_cover_tilt"}, + {ATTR_SERVICE: "close_cover_tilt", ATTR_METHOD: "async_close_cover_tilt"}, + {ATTR_SERVICE: "stop_cover_tilt", ATTR_METHOD: "async_stop_cover_tilt"}, + { + ATTR_SERVICE: "set_cover_tilt_position", + ATTR_METHOD: "async_set_cover_tilt_position", + ATTR_ARGS: {"tilt_position": 50}, + }, + ], + ) + + +async def test_cover_without_tilt(hass, mock_device): + """Test a cover with no tilt.""" + mock_device.has_tilt = False + await create_entity_from_device(hass, mock_device) + await hass.services.async_call( + "cover", "open_cover_tilt", {"entity_id": "cover.name"}, blocking=True + ) + await hass.async_block_till_done() + mock_device.async_open_cover_tilt.assert_not_called() + + +async def check_cover_position( + hass, update_func, device, closing, opening, closed, expected +): + """Check that a given position behaves correctly.""" + device.is_closing = closing + device.is_opening = opening + device.is_closed = closed + update_func(device) + await hass.async_block_till_done() + entity_state = hass.states.get("cover.name") + assert entity_state.state == expected + + +async def test_cover_positions(hass, mock_device): + """Test that the state updates in the various positions.""" + update_func = await create_entity_from_device(hass, mock_device) + await check_cover_position( + hass, update_func, mock_device, True, False, False, "closing" + ) + await check_cover_position( + hass, update_func, mock_device, False, True, False, "opening" + ) + await check_cover_position( + hass, update_func, mock_device, False, False, True, "closed" + ) + await check_cover_position( + hass, update_func, mock_device, False, False, False, "open" + ) diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index b74fcd64da029a..8e2290a9c407af 100755 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -3,7 +3,8 @@ from asynctest import call, patch -from homeassistant.components import dynalite +import homeassistant.components.dynalite.const as dynalite +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -17,8 +18,7 @@ async def test_empty_config(hass): async def test_async_setup(hass): - """Test a successful setup.""" - host = "1.2.3.4" + """Test a successful setup with all of the different options.""" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", return_value=True, @@ -30,8 +30,46 @@ async def test_async_setup(hass): dynalite.DOMAIN: { dynalite.CONF_BRIDGES: [ { - dynalite.CONF_HOST: host, - dynalite.CONF_AREA: {"1": {dynalite.CONF_NAME: "Name"}}, + CONF_HOST: "1.2.3.4", + CONF_PORT: 1234, + dynalite.CONF_AUTO_DISCOVER: True, + dynalite.CONF_POLL_TIMER: 5.5, + dynalite.CONF_AREA: { + "1": { + CONF_NAME: "Name1", + dynalite.CONF_CHANNEL: {"4": {}}, + dynalite.CONF_NO_DEFAULT: True, + }, + "2": {CONF_NAME: "Name2"}, + "3": { + CONF_NAME: "Name3", + dynalite.CONF_TEMPLATE: CONF_ROOM, + }, + "4": { + CONF_NAME: "Name4", + dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, + }, + }, + dynalite.CONF_DEFAULT: {dynalite.CONF_FADE: 2.3}, + dynalite.CONF_ACTIVE: dynalite.ACTIVE_INIT, + dynalite.CONF_PRESET: { + "5": {CONF_NAME: "pres5", dynalite.CONF_FADE: 4.5} + }, + dynalite.CONF_TEMPLATE: { + CONF_ROOM: { + dynalite.CONF_ROOM_ON: 6, + dynalite.CONF_ROOM_OFF: 7, + }, + dynalite.CONF_TIME_COVER: { + dynalite.CONF_OPEN_PRESET: 8, + dynalite.CONF_CLOSE_PRESET: 9, + dynalite.CONF_STOP_PRESET: 10, + dynalite.CONF_CHANNEL_COVER: 3, + dynalite.CONF_DURATION: 2.2, + dynalite.CONF_TILT_TIME: 3.3, + dynalite.CONF_DEVICE_CLASS: "awning", + }, + }, } ] } @@ -41,6 +79,35 @@ async def test_async_setup(hass): assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1 +async def test_async_setup_bad_config1(hass): + """Test a successful with bad config on templates.""" + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert not await async_setup_component( + hass, + dynalite.DOMAIN, + { + dynalite.DOMAIN: { + dynalite.CONF_BRIDGES: [ + { + CONF_HOST: "1.2.3.4", + dynalite.CONF_AREA: { + "1": { + dynalite.CONF_TEMPLATE: dynalite.CONF_TIME_COVER, + CONF_NAME: "Name", + dynalite.CONF_ROOM_ON: 7, + } + }, + } + ] + } + }, + ) + await hass.async_block_till_done() + + async def test_async_setup_bad_config2(hass): """Test a successful with bad config on numbers.""" host = "1.2.3.4" @@ -55,8 +122,8 @@ async def test_async_setup_bad_config2(hass): dynalite.DOMAIN: { dynalite.CONF_BRIDGES: [ { - dynalite.CONF_HOST: host, - dynalite.CONF_AREA: {"WRONG": {dynalite.CONF_NAME: "Name"}}, + CONF_HOST: host, + dynalite.CONF_AREA: {"WRONG": {CONF_NAME: "Name"}}, } ] } @@ -69,7 +136,7 @@ async def test_async_setup_bad_config2(hass): async def test_unload_entry(hass): """Test being able to unload an entry.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={CONF_HOST: host}) entry.add_to_hass(hass) with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", From 4caf65dc971f1ba8aa5ca6f92ec7844e96e07b6a Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Thu, 2 Apr 2020 14:43:40 -0400 Subject: [PATCH 14/44] Add Rachio Flex Schedules (#33533) * Add Rachio Flex Schedules * Remove Duration Property * Missed duration call * Black formatting --- homeassistant/components/rachio/const.py | 1 + homeassistant/components/rachio/device.py | 8 +++++- homeassistant/components/rachio/switch.py | 30 ++++++++++------------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 2c439407c71d92..587cd85a2a5001 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -35,6 +35,7 @@ KEY_ZONE_NUMBER = "zoneNumber" KEY_ZONES = "zones" KEY_SCHEDULES = "scheduleRules" +KEY_FLEX_SCHEDULES = "flexScheduleRules" KEY_SCHEDULE_ID = "scheduleId" KEY_CUSTOM_SHADE = "customShade" KEY_CUSTOM_CROP = "customCrop" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index fbf49ffc67f123..7ff47f7a221544 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -9,6 +9,7 @@ KEY_DEVICES, KEY_ENABLED, KEY_EXTERNAL_ID, + KEY_FLEX_SCHEDULES, KEY_ID, KEY_MAC_ADDRESS, KEY_MODEL, @@ -92,6 +93,7 @@ def __init__(self, hass, rachio, data, webhooks): self.model = data[KEY_MODEL] self._zones = data[KEY_ZONES] self._schedules = data[KEY_SCHEDULES] + self._flex_schedules = data[KEY_FLEX_SCHEDULES] self._init_data = data self._webhooks = webhooks _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id) @@ -177,9 +179,13 @@ def get_zone(self, zone_id) -> Optional[dict]: return None def list_schedules(self) -> list: - """Return a list of schedules.""" + """Return a list of fixed schedules.""" return self._schedules + def list_flex_schedules(self) -> list: + """Return a list of flex schedules.""" + return self._flex_schedules + def stop_watering(self) -> None: """Stop watering all zones connected to this controller.""" self.rachio.device.stopWater(self.controller_id) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 7be0c64ee1bac4..86b6097ad13f5a 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -4,6 +4,7 @@ import logging from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ( @@ -68,13 +69,13 @@ def _create_entities(hass, config_entry): entities.append(RachioStandbySwitch(controller)) zones = controller.list_zones() schedules = controller.list_schedules() + flex_schedules = controller.list_flex_schedules() current_schedule = controller.current_schedule for zone in zones: - _LOGGER.debug("Rachio setting up zone: %s", zone) entities.append(RachioZone(person, controller, zone, current_schedule)) - for sched in schedules: - _LOGGER.debug("Added schedule: %s", sched) + for sched in schedules + flex_schedules: entities.append(RachioSchedule(person, controller, sched, current_schedule)) + _LOGGER.debug("Added %s", entities) return entities @@ -180,7 +181,6 @@ class RachioZone(RachioSwitch): def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Zone.""" self._id = data[KEY_ID] - _LOGGER.debug("zone_data: %s", data) self._zone_name = data[KEY_NAME] self._zone_number = data[KEY_ZONE_NUMBER] self._zone_enabled = data[KEY_ENABLED] @@ -297,21 +297,16 @@ class RachioSchedule(RachioSwitch): def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Schedule.""" - self._id = data[KEY_ID] + self._schedule_id = data[KEY_ID] self._schedule_name = data[KEY_NAME] self._duration = data[KEY_DURATION] self._schedule_enabled = data[KEY_ENABLED] self._summary = data[KEY_SUMMARY] self._current_schedule = current_schedule super().__init__(controller, poll=False) - self._state = self.schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) + self._state = self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) self._undo_dispatcher = None - @property - def schedule_id(self) -> str: - """How the Rachio API refers to the schedule.""" - return self._id - @property def name(self) -> str: """Return the friendly name of the schedule.""" @@ -320,7 +315,7 @@ def name(self) -> str: @property def unique_id(self) -> str: """Return a unique id by combining controller id and schedule.""" - return f"{self._controller.controller_id}-schedule-{self.schedule_id}" + return f"{self._controller.controller_id}-schedule-{self._schedule_id}" @property def icon(self) -> str: @@ -333,7 +328,7 @@ def device_state_attributes(self) -> dict: return { ATTR_SCHEDULE_SUMMARY: self._summary, ATTR_SCHEDULE_ENABLED: self.schedule_is_enabled, - ATTR_SCHEDULE_DURATION: self._duration / 60, + ATTR_SCHEDULE_DURATION: f"{round(self._duration / 60)} minutes", } @property @@ -344,7 +339,7 @@ def schedule_is_enabled(self) -> bool: def turn_on(self, **kwargs) -> None: """Start this schedule.""" - self._controller.rachio.schedulerule.start(self.schedule_id) + self._controller.rachio.schedulerule.start(self._schedule_id) _LOGGER.debug( "Schedule %s started on %s", self.name, self._controller.name, ) @@ -356,13 +351,14 @@ def turn_off(self, **kwargs) -> None: def _poll_update(self, data=None) -> bool: """Poll the API to check whether the schedule is running.""" self._current_schedule = self._controller.current_schedule - return self.schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) + return self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) - def _handle_update(self, *args, **kwargs) -> None: + @callback + async def _handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook schedule data.""" # Schedule ID not passed when running individual zones, so we catch that error try: - if args[0][KEY_SCHEDULE_ID] == self.schedule_id: + if args[0][KEY_SCHEDULE_ID] == self._schedule_id: if args[0][KEY_SUBTYPE] in [SUBTYPE_SCHEDULE_STARTED]: self._state = True elif args[0][KEY_SUBTYPE] in [ From 4b2c45e668aaec938650438c5b7a9746d718b90b Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Thu, 2 Apr 2020 22:56:41 +0300 Subject: [PATCH 15/44] Add melcloud AtaDevice vane control (#32672) * Add melcloud AtaDevice vane control * Return empty dict when no vane states available Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Use constants for services and conf * Split state attribute assignment and fix suggested changes * Log valid positions when called with an invalid position * Improve service description Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- homeassistant/components/melcloud/climate.py | 67 ++++++++++++++++++- homeassistant/components/melcloud/const.py | 9 +++ .../components/melcloud/services.yaml | 23 +++++++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/melcloud/services.yaml diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index c661b1a59ade6c..df3d9e893923cc 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -11,6 +11,7 @@ PROPERTY_ZONE_2_OPERATION_MODE, Zone, ) +import voluptuous as vol from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( @@ -27,11 +28,23 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.temperature import convert as convert_temperature from . import MelCloudDevice -from .const import ATTR_STATUS, DOMAIN, TEMP_UNIT_LOOKUP +from .const import ( + ATTR_STATUS, + ATTR_VANE_HORIZONTAL, + ATTR_VANE_HORIZONTAL_POSITIONS, + ATTR_VANE_VERTICAL, + ATTR_VANE_VERTICAL_POSITIONS, + CONF_POSITION, + DOMAIN, + SERVICE_SET_VANE_HORIZONTAL, + SERVICE_SET_VANE_VERTICAL, + TEMP_UNIT_LOOKUP, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -73,6 +86,18 @@ async def async_setup_entry( True, ) + platform = entity_platform.current_platform.get() + platform.async_register_entity_service( + SERVICE_SET_VANE_HORIZONTAL, + {vol.Required(CONF_POSITION): cv.string}, + "async_set_vane_horizontal", + ) + platform.async_register_entity_service( + SERVICE_SET_VANE_VERTICAL, + {vol.Required(CONF_POSITION): cv.string}, + "async_set_vane_vertical", + ) + class MelCloudClimate(ClimateDevice): """Base climate device.""" @@ -116,6 +141,30 @@ def name(self): """Return the display name of this entity.""" return self._name + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the optional state attributes with device specific additions.""" + attr = {} + + vane_horizontal = self._device.vane_horizontal + if vane_horizontal: + attr.update( + { + ATTR_VANE_HORIZONTAL: vane_horizontal, + ATTR_VANE_HORIZONTAL_POSITIONS: self._device.vane_horizontal_positions, + } + ) + + vane_vertical = self._device.vane_vertical + if vane_horizontal: + attr.update( + { + ATTR_VANE_VERTICAL: vane_vertical, + ATTR_VANE_VERTICAL_POSITIONS: self._device.vane_vertical_positions, + } + ) + return attr + @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" @@ -181,6 +230,22 @@ def fan_modes(self) -> Optional[List[str]]: """Return the list of available fan modes.""" return self._device.fan_speeds + async def async_set_vane_horizontal(self, position: str) -> None: + """Set horizontal vane position.""" + if position not in self._device.vane_horizontal_positions: + raise ValueError( + f"Invalid horizontal vane position {position}. Valid positions: [{self._device.vane_horizontal_positions}]." + ) + await self._device.set({ata.PROPERTY_VANE_HORIZONTAL: position}) + + async def async_set_vane_vertical(self, position: str) -> None: + """Set vertical vane position.""" + if position not in self._device.vane_vertical_positions: + raise ValueError( + f"Invalid vertical vane position {position}. Valid positions: [{self._device.vane_vertical_positions}]." + ) + await self._device.set({ata.PROPERTY_VANE_VERTICAL: position}) + @property def supported_features(self) -> int: """Return the list of supported features.""" diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py index c6ce4391294a43..d58f483d441eee 100644 --- a/homeassistant/components/melcloud/const.py +++ b/homeassistant/components/melcloud/const.py @@ -5,7 +5,16 @@ DOMAIN = "melcloud" +CONF_POSITION = "position" + ATTR_STATUS = "status" +ATTR_VANE_HORIZONTAL = "vane_horizontal" +ATTR_VANE_HORIZONTAL_POSITIONS = "vane_horizontal_positions" +ATTR_VANE_VERTICAL = "vane_vertical" +ATTR_VANE_VERTICAL_POSITIONS = "vane_vertical_positions" + +SERVICE_SET_VANE_HORIZONTAL = "set_vane_horizontal" +SERVICE_SET_VANE_VERTICAL = "set_vane_vertical" TEMP_UNIT_LOOKUP = { UNIT_TEMP_CELSIUS: TEMP_CELSIUS, diff --git a/homeassistant/components/melcloud/services.yaml b/homeassistant/components/melcloud/services.yaml new file mode 100644 index 00000000000000..40faa097d9b6d4 --- /dev/null +++ b/homeassistant/components/melcloud/services.yaml @@ -0,0 +1,23 @@ +set_vane_horizontal: + description: Sets horizontal vane position. + fields: + entity_id: + description: Name of the target entity + example: "climate.ac_1" + position: + description: > + Horizontal vane position. Possible options can be found in the + vane_horizontal_positions state attribute. + example: "auto" + +set_vane_vertical: + description: Sets vertical vane position. + fields: + entity_id: + description: Name of the target entity + example: "climate.ac_1" + position: + description: > + Vertical vane position. Possible options can be found in the + vane_vertical_positions state attribute. + example: "auto" From 6afe6acb6c5092353016cb75570034f10154cd44 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Apr 2020 13:02:59 -0700 Subject: [PATCH 16/44] Mark new gate device class as 2FA (#33541) --- homeassistant/components/google_assistant/trait.py | 6 +++++- tests/components/google_assistant/test_trait.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 1c47be45651c7c..2bc5f5040d413e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1247,7 +1247,11 @@ class OpenCloseTrait(_Trait): """ # Cover device classes that require 2FA - COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE) + COVER_2FA = ( + cover.DEVICE_CLASS_DOOR, + cover.DEVICE_CLASS_GARAGE, + cover.DEVICE_CLASS_GATE, + ) name = TRAIT_OPENCLOSE commands = [COMMAND_OPENCLOSE] diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index ec1848bf1ed5d8..d0ed9a9d33c241 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1532,7 +1532,8 @@ async def test_openclose_cover_no_position(hass): @pytest.mark.parametrize( - "device_class", (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE) + "device_class", + (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE, cover.DEVICE_CLASS_GATE), ) async def test_openclose_cover_secure(hass, device_class): """Test OpenClose trait support for cover domain.""" From b719a77503f0cb6d3828083cecd4f6526913bf7e Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 2 Apr 2020 15:46:31 -0500 Subject: [PATCH 17/44] Bump pyipp to 0.8.2 (#33544) --- homeassistant/components/ipp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index beb6679e308d2c..2eae581bdc70eb 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -2,7 +2,7 @@ "domain": "ipp", "name": "Internet Printing Protocol (IPP)", "documentation": "https://www.home-assistant.io/integrations/ipp", - "requirements": ["pyipp==0.8.1"], + "requirements": ["pyipp==0.8.2"], "dependencies": [], "codeowners": ["@ctalkington"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 66254138e51ab3..87228d52078a41 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1336,7 +1336,7 @@ pyintesishome==1.7.1 pyipma==2.0.5 # homeassistant.components.ipp -pyipp==0.8.1 +pyipp==0.8.2 # homeassistant.components.iqvia pyiqvia==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73cea897aa4f90..8e408e1d24a169 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -519,7 +519,7 @@ pyicloud==0.9.6.1 pyipma==2.0.5 # homeassistant.components.ipp -pyipp==0.8.1 +pyipp==0.8.2 # homeassistant.components.iqvia pyiqvia==0.2.1 From 36a606f81d7ade3d8c0a5485bc861f6a0f627250 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2020 16:09:35 -0500 Subject: [PATCH 18/44] Bump HAP-python to 2.8.0 (#33539) --- homeassistant/components/homekit/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/manifest.json b/homeassistant/components/homekit/manifest.json index bbbc6561a878ca..eb8d16d0c0ada2 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -2,7 +2,7 @@ "domain": "homekit", "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", - "requirements": ["HAP-python==2.7.0"], + "requirements": ["HAP-python==2.8.0"], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 87228d52078a41..95bdb6f7bc131c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -35,7 +35,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==2.7.0 +HAP-python==2.8.0 # homeassistant.components.mastodon Mastodon.py==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e408e1d24a169..c7278c57b38bd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ -r requirements_test.txt # homeassistant.components.homekit -HAP-python==2.7.0 +HAP-python==2.8.0 # homeassistant.components.mobile_app # homeassistant.components.owntracks From 201e9588548f0e5bf7bac8a35b5ac7e49e1d24d4 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 2 Apr 2020 18:13:44 -0400 Subject: [PATCH 19/44] Use mock storage for MQTT tests (#33553) * mock storage for MQTT * more mqtt storage mocks --- tests/components/mqtt/test_init.py | 7 +++++++ tests/components/mqtt/test_server.py | 5 ++++- tests/components/mqtt_eventstream/test_init.py | 4 ++++ tests/components/mqtt_statestream/test_init.py | 4 ++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 7aa185c2c391e9..29cece92e640db 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -33,6 +33,7 @@ mock_device_registry, mock_mqtt_component, mock_registry, + mock_storage, threadsafe_coroutine_factory, ) @@ -83,12 +84,15 @@ class TestMQTTComponent(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() mock_mqtt_component(self.hass) self.calls = [] def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) @callback def record_calls(self, *args): @@ -298,12 +302,15 @@ class TestMQTTCallbacks(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() mock_mqtt_client(self.hass) self.calls = [] def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) @callback def record_calls(self, *args): diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 95f31b6782637a..a9b5656a0b3a9e 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_PASSWORD from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, mock_coro +from tests.common import get_test_home_assistant, mock_coro, mock_storage class TestMQTT: @@ -16,10 +16,13 @@ class TestMQTT: def setup_method(self, method): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) @patch("passlib.apps.custom_app_context", Mock(return_value="")) @patch("tempfile.NamedTemporaryFile", Mock(return_value=MagicMock())) diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index f4062458d91e99..36698db87e187d 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -15,6 +15,7 @@ get_test_home_assistant, mock_mqtt_component, mock_state_change_event, + mock_storage, ) @@ -24,11 +25,14 @@ class TestMqttEventStream: def setup_method(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() self.mock_mqtt = mock_mqtt_component(self.hass) def teardown_method(self): """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) def add_eventstream(self, sub_topic=None, pub_topic=None, ignore_event=None): """Add a mqtt_eventstream component.""" diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py index ffab0e0846f083..af9a721f1a4fbf 100644 --- a/tests/components/mqtt_statestream/test_init.py +++ b/tests/components/mqtt_statestream/test_init.py @@ -9,6 +9,7 @@ get_test_home_assistant, mock_mqtt_component, mock_state_change_event, + mock_storage, ) @@ -18,11 +19,14 @@ class TestMqttStateStream: def setup_method(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.mock_storage = mock_storage() + self.mock_storage.__enter__() self.mock_mqtt = mock_mqtt_component(self.hass) def teardown_method(self): """Stop everything that was started.""" self.hass.stop() + self.mock_storage.__exit__(None, None, None) def add_statestream( self, From 8fbdc703e015e4c9e582d498e154b675c7aab938 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Fri, 3 Apr 2020 01:18:30 +0300 Subject: [PATCH 20/44] Fix uncaught exceptions for mqtt (#33547) now all mqtt tests pass --- tests/components/mqtt/test_init.py | 1 + tests/ignore_uncaught_exceptions.py | 20 -------------------- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 29cece92e640db..60ca91b00520ba 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -55,6 +55,7 @@ def mock_MQTT(): """Make sure connection is established.""" with mock.patch("homeassistant.components.mqtt.MQTT") as mock_MQTT: mock_MQTT.return_value.async_connect.return_value = mock_coro(True) + mock_MQTT.return_value.async_disconnect.return_value = mock_coro(True) yield mock_MQTT diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index 126cb37f14fdeb..ad8ec114eafe98 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -48,26 +48,6 @@ ("tests.components.ios.test_init", "test_creating_entry_sets_up_sensor"), ("tests.components.ios.test_init", "test_not_configuring_ios_not_creates_entry"), ("tests.components.local_file.test_camera", "test_file_not_readable"), - ( - "tests.components.mqtt.test_init", - "test_setup_uses_certificate_on_certificate_set_to_auto", - ), - ( - "tests.components.mqtt.test_init", - "test_setup_does_not_use_certificate_on_mqtts_port", - ), - ( - "tests.components.mqtt.test_init", - "test_setup_without_tls_config_uses_tlsv1_under_python36", - ), - ( - "tests.components.mqtt.test_init", - "test_setup_with_tls_config_uses_tls_version1_2", - ), - ( - "tests.components.mqtt.test_init", - "test_setup_with_tls_config_of_v1_under_python36_only_uses_v1", - ), ("tests.components.qwikswitch.test_init", "test_binary_sensor_device"), ("tests.components.qwikswitch.test_init", "test_sensor_device"), ("tests.components.rflink.test_init", "test_send_command_invalid_arguments"), From 3db9d6a6aa01ccd32231d0a4bff8f1c7c730b9e7 Mon Sep 17 00:00:00 2001 From: Alistair Galbraith Date: Thu, 2 Apr 2020 15:41:19 -0700 Subject: [PATCH 21/44] Fix template light returning NULL in color or temperature (#33498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Support for returning NULL in color or temperature. Fixes #33469 * Added further support for ‘None’ returns in level template * Removed assumption that template render may not be a string * Streamlined code per cloud pylint * Updates per code review suggestions * Added improved error handling and logging for brightness * Additional exception handling for temperature --- homeassistant/components/template/light.py | 39 +++++++++++++++++----- tests/components/template/test_light.py | 19 +++++++++-- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 52560ea27329c3..c6d6656f1343e8 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -378,6 +378,9 @@ def update_brightness(self): return try: brightness = self._level_template.async_render() + if brightness in ("None", ""): + self._brightness = None + return if 0 <= int(brightness) <= 255: self._brightness = int(brightness) else: @@ -385,9 +388,15 @@ def update_brightness(self): "Received invalid brightness : %s. Expected: 0-255", brightness ) self._brightness = None - except TemplateError as ex: - _LOGGER.error(ex) - self._state = None + except ValueError: + _LOGGER.error( + "Template must supply an integer brightness from 0-255, or 'None'", + exc_info=True, + ) + self._brightness = None + except TemplateError: + _LOGGER.error("Invalid template", exc_info=True) + self._brightness = None @callback def update_state(self): @@ -415,7 +424,11 @@ def update_temperature(self): if self._temperature_template is None: return try: - temperature = int(self._temperature_template.async_render()) + render = self._temperature_template.async_render() + if render in ("None", ""): + self._temperature = None + return + temperature = int(render) if self.min_mireds <= temperature <= self.max_mireds: self._temperature = temperature else: @@ -425,6 +438,12 @@ def update_temperature(self): self.max_mireds, ) self._temperature = None + except ValueError: + _LOGGER.error( + "Template must supply an integer temperature within the range for this light, or 'None'", + exc_info=True, + ) + self._temperature = None except TemplateError: _LOGGER.error("Cannot evaluate temperature template", exc_info=True) self._temperature = None @@ -435,10 +454,11 @@ def update_color(self): if self._color_template is None: return - self._color = None - try: render = self._color_template.async_render() + if render in ("None", ""): + self._color = None + return h_str, s_str = map( float, render.replace("(", "").replace(")", "").split(",", 1) ) @@ -455,7 +475,10 @@ def update_color(self): h_str, s_str, ) + self._color = None else: _LOGGER.error("Received invalid hs_color : (%s)", render) - except TemplateError as ex: - _LOGGER.error(ex) + self._color = None + except TemplateError: + _LOGGER.error("Cannot evaluate hs_color template", exc_info=True) + self._color = None diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index dccca97a1cc9d1..d9adf6015c90bf 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -543,7 +543,13 @@ def test_level_action_no_template(self): @pytest.mark.parametrize( "expected_level,template", - [(255, "{{255}}"), (None, "{{256}}"), (None, "{{x - 12}}")], + [ + (255, "{{255}}"), + (None, "{{256}}"), + (None, "{{x - 12}}"), + (None, "{{ none }}"), + (None, ""), + ], ) def test_level_template(self, expected_level, template): """Test the template for the level.""" @@ -588,7 +594,14 @@ def test_level_template(self, expected_level, template): @pytest.mark.parametrize( "expected_temp,template", - [(500, "{{500}}"), (None, "{{501}}"), (None, "{{x - 12}}")], + [ + (500, "{{500}}"), + (None, "{{501}}"), + (None, "{{x - 12}}"), + (None, "None"), + (None, "{{ none }}"), + (None, ""), + ], ) def test_temperature_template(self, expected_temp, template): """Test the template for the temperature.""" @@ -894,6 +907,8 @@ def test_color_action_no_template(self): (None, "{{(361, 100)}}"), (None, "{{(360, 101)}}"), (None, "{{x - 12}}"), + (None, ""), + (None, "{{ none }}"), ], ) def test_color_template(self, expected_hs, template): From f2dad7905d796192c8bd5b08c6bacfc151c70dc4 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Fri, 3 Apr 2020 01:55:04 +0300 Subject: [PATCH 22/44] fixed uncaught exceptions for tradfri (#33550) was caused by device_info being mocks, so write to storage failed --- tests/components/tradfri/test_light.py | 6 +++++- tests/ignore_uncaught_exceptions.py | 5 ----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py index 365f84cf7a32f2..e4bdd140faa92c 100644 --- a/tests/components/tradfri/test_light.py +++ b/tests/components/tradfri/test_light.py @@ -155,11 +155,15 @@ def mock_light(test_features={}, test_state={}, n=0): """Mock a tradfri light.""" mock_light_data = Mock(**test_state) + dev_info_mock = MagicMock() + dev_info_mock.manufacturer = "manufacturer" + dev_info_mock.model_number = "model" + dev_info_mock.firmware_version = "1.2.3" mock_light = Mock( id=f"mock-light-id-{n}", reachable=True, observe=Mock(), - device_info=MagicMock(), + device_info=dev_info_mock, ) mock_light.name = f"tradfri_light_{n}" diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index ad8ec114eafe98..6f7910f9120438 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -56,11 +56,6 @@ "tests.components.tplink.test_init", "test_configuring_devices_from_multiple_sources", ), - ("tests.components.tradfri.test_light", "test_light"), - ("tests.components.tradfri.test_light", "test_light_observed"), - ("tests.components.tradfri.test_light", "test_light_available"), - ("tests.components.tradfri.test_light", "test_turn_on"), - ("tests.components.tradfri.test_light", "test_turn_off"), ("tests.components.unifi_direct.test_device_tracker", "test_get_scanner"), ("tests.components.upnp.test_init", "test_async_setup_entry_default"), ("tests.components.upnp.test_init", "test_async_setup_entry_port_mapping"), From a30e217bb513f7fe80f11baa40d42c0b68e1bc4d Mon Sep 17 00:00:00 2001 From: Richard Powell Date: Thu, 2 Apr 2020 16:07:21 -0700 Subject: [PATCH 23/44] Add support to the Econet integration for new attributes: lower_temp, upper_temp, ambient_temp & is_enabled (#33363) --- homeassistant/components/econet/water_heater.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 26ee7cb8bd422f..97be043402f9c3 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -40,6 +40,11 @@ ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" +ATTR_LOWER_TEMP = "lower_temp" +ATTR_UPPER_TEMP = "upper_temp" +ATTR_AMBIENT_TEMP = "ambient_temp" +ATTR_IS_ENABLED = "is_enabled" + SUPPORT_FLAGS_HEATER = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE ADD_VACATION_SCHEMA = vol.Schema( @@ -168,6 +173,11 @@ def device_state_attributes(self): data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage data[ATTR_IN_USE] = self.water_heater.in_use + data[ATTR_LOWER_TEMP] = round(self.water_heater.lower_temp, 2) + data[ATTR_UPPER_TEMP] = round(self.water_heater.upper_temp, 2) + data[ATTR_AMBIENT_TEMP] = round(self.water_heater.ambient_temp, 2) + data[ATTR_IS_ENABLED] = self.water_heater.is_enabled + return data @property From 9165ea5fbd5034cb79a0c6be85664fe1c62b780a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2020 18:23:45 -0500 Subject: [PATCH 24/44] Revert "Add support to the Econet integration for new attributes: lower_temp, upper_temp, ambient_temp & is_enabled (#33363)" (#33555) This reverts commit a30e217bb513f7fe80f11baa40d42c0b68e1bc4d. --- homeassistant/components/econet/water_heater.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 97be043402f9c3..26ee7cb8bd422f 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -40,11 +40,6 @@ ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" -ATTR_LOWER_TEMP = "lower_temp" -ATTR_UPPER_TEMP = "upper_temp" -ATTR_AMBIENT_TEMP = "ambient_temp" -ATTR_IS_ENABLED = "is_enabled" - SUPPORT_FLAGS_HEATER = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE ADD_VACATION_SCHEMA = vol.Schema( @@ -173,11 +168,6 @@ def device_state_attributes(self): data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage data[ATTR_IN_USE] = self.water_heater.in_use - data[ATTR_LOWER_TEMP] = round(self.water_heater.lower_temp, 2) - data[ATTR_UPPER_TEMP] = round(self.water_heater.upper_temp, 2) - data[ATTR_AMBIENT_TEMP] = round(self.water_heater.ambient_temp, 2) - data[ATTR_IS_ENABLED] = self.water_heater.is_enabled - return data @property From 3df46cd70acfe474c4147f9d5a72d3a0911ac97d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 3 Apr 2020 01:46:18 +0200 Subject: [PATCH 25/44] Remove MQTT state vacuum value_template support. (#33536) * Fix MQTT state vacuum value_template support. * Remove support for state_template --- .../components/mqtt/vacuum/schema_state.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index cbaf8d43a77e3f..254d841aebcda8 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -36,12 +36,7 @@ SUPPORT_STOP, StateVacuumDevice, ) -from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, - CONF_DEVICE, - CONF_NAME, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_DEVICE, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -104,7 +99,6 @@ CONF_PAYLOAD_LOCATE = "payload_locate" CONF_PAYLOAD_START = "payload_start" CONF_PAYLOAD_PAUSE = "payload_pause" -CONF_STATE_TEMPLATE = "state_template" CONF_SET_FAN_SPEED_TOPIC = "set_fan_speed_topic" CONF_FAN_SPEED_LIST = "fan_speed_list" CONF_SEND_COMMAND_TOPIC = "send_command_topic" @@ -141,7 +135,6 @@ vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_STATE_TOPIC): mqtt.valid_publish_topic, vol.Optional( CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS @@ -241,20 +234,13 @@ async def async_will_remove_from_hass(self): async def _subscribe_topics(self): """(Re)Subscribe to topics.""" - template = self._config.get(CONF_VALUE_TEMPLATE) - if template is not None: - template.hass = self.hass topics = {} @callback @log_messages(self.hass, self.entity_id) def state_message_received(msg): """Handle state MQTT message.""" - payload = msg.payload - if template is not None: - payload = template.async_render_with_possible_json_value(payload) - else: - payload = json.loads(payload) + payload = json.loads(msg.payload) if STATE in payload and payload[STATE] in POSSIBLE_STATES: self._state = POSSIBLE_STATES[payload[STATE]] del payload[STATE] From 55870aec3156df052aaa67dc99172325f995defa Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 2 Apr 2020 18:47:58 -0500 Subject: [PATCH 26/44] Temporary Plex play_media workaround (#33542) * Temporary playMedia() workaround on plexapi 3.3.0 * Use constants for strings * Style cleanup --- homeassistant/components/plex/media_player.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 1be06876baf0ca..5325544bf15a30 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -11,6 +11,7 @@ MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, + MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -575,9 +576,11 @@ def play_media(self, media_type, media_id, **kwargs): shuffle = src.get("shuffle", 0) media = None + command_media_type = MEDIA_TYPE_VIDEO if media_type == "MUSIC": media = self._get_music_media(library, src) + command_media_type = MEDIA_TYPE_MUSIC elif media_type == "EPISODE": media = self._get_tv_media(library, src) elif media_type == "PLAYLIST": @@ -591,7 +594,7 @@ def play_media(self, media_type, media_id, **kwargs): playqueue = self.plex_server.create_playqueue(media, shuffle=shuffle) try: - self.device.playMedia(playqueue) + self.device.playMedia(playqueue, type=command_media_type) except ParseError: # Temporary workaround for Plexamp / plexapi issue pass From cb058ff6c08d9a80f53d04e8c88019b4506249eb Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 2 Apr 2020 17:54:11 -0600 Subject: [PATCH 27/44] Add config entry for Flu Near You (#32858) * Add config flow for Flu Near You * Cleanup * Cleanup * Add tests * Add test requirements * Code review * Reduce unnecessary async-ness * Handle API registration * Cleanup * Update homeassistant/components/flunearyou/.translations/en.json Co-Authored-By: Paulus Schoutsen * Code review * Ensure config schema allows additional keys Co-authored-by: Paulus Schoutsen --- .coveragerc | 1 + .../flunearyou/.translations/en.json | 21 ++ .../components/flunearyou/__init__.py | 215 ++++++++++++++++++ .../components/flunearyou/config_flow.py | 60 +++++ homeassistant/components/flunearyou/const.py | 38 ++++ .../components/flunearyou/manifest.json | 3 +- homeassistant/components/flunearyou/sensor.py | 193 ++++++---------- .../components/flunearyou/strings.json | 21 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/flunearyou/__init__.py | 1 + .../components/flunearyou/test_config_flow.py | 87 +++++++ 13 files changed, 521 insertions(+), 125 deletions(-) create mode 100644 homeassistant/components/flunearyou/.translations/en.json create mode 100644 homeassistant/components/flunearyou/config_flow.py create mode 100644 homeassistant/components/flunearyou/const.py create mode 100644 homeassistant/components/flunearyou/strings.json create mode 100644 tests/components/flunearyou/__init__.py create mode 100644 tests/components/flunearyou/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 851922e4f3a99d..c41d5afe169c6d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -219,6 +219,7 @@ omit = homeassistant/components/flic/binary_sensor.py homeassistant/components/flock/notify.py homeassistant/components/flume/* + homeassistant/components/flunearyou/__init__.py homeassistant/components/flunearyou/sensor.py homeassistant/components/flux_led/light.py homeassistant/components/folder/sensor.py diff --git a/homeassistant/components/flunearyou/.translations/en.json b/homeassistant/components/flunearyou/.translations/en.json new file mode 100644 index 00000000000000..cd8c0d27c3636e --- /dev/null +++ b/homeassistant/components/flunearyou/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "These coordinates are already registered." + }, + "error": { + "general_error": "There was an unknown error." + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + }, + "description": "Monitor user-based and CDC flu reports.", + "title": "Configure Flu Near You" + } + }, + "title": "Flu Near You" + } +} diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py index 5657e646be509b..ce59c959133f5d 100644 --- a/homeassistant/components/flunearyou/__init__.py +++ b/homeassistant/components/flunearyou/__init__.py @@ -1 +1,216 @@ """The flunearyou component.""" +import asyncio +from datetime import timedelta + +from pyflunearyou import Client +from pyflunearyou.errors import FluNearYouError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CATEGORY_CDC_REPORT, + CATEGORY_USER_REPORT, + DATA_CLIENT, + DOMAIN, + LOGGER, + SENSORS, + TOPIC_UPDATE, +) + +DATA_LISTENER = "listener" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.Schema( + { + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +@callback +def async_get_api_category(sensor_type): + """Get the category that a particular sensor type belongs to.""" + try: + return next( + ( + category + for category, sensors in SENSORS.items() + for sensor in sensors + if sensor[0] == sensor_type + ) + ) + except StopIteration: + raise ValueError(f"Can't find category sensor type: {sensor_type}") + + +async def async_setup(hass, config): + """Set up the Flu Near You component.""" + hass.data[DOMAIN] = {DATA_CLIENT: {}, DATA_LISTENER: {}} + + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: config[DOMAIN].get(CONF_LATITUDE, hass.config.latitude), + CONF_LONGITUDE: config[DOMAIN].get( + CONF_LATITUDE, hass.config.longitude + ), + }, + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Flu Near You as config entry.""" + websession = aiohttp_client.async_get_clientsession(hass) + + fny = FluNearYouData( + hass, + Client(websession), + config_entry.data.get(CONF_LATITUDE, hass.config.latitude), + config_entry.data.get(CONF_LONGITUDE, hass.config.longitude), + ) + + try: + await fny.async_update() + except FluNearYouError as err: + LOGGER.error("Error while setting up integration: %s", err) + raise ConfigEntryNotReady + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = fny + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + ) + + async def refresh(event_time): + """Refresh data from Flu Near You.""" + await fny.async_update() + + hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Flu Near You config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) + remove_listener() + + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + + return True + + +class FluNearYouData: + """Define a data object to retrieve info from Flu Near You.""" + + def __init__(self, hass, client, latitude, longitude): + """Initialize.""" + self._async_cancel_time_interval_listener = None + self._client = client + self._hass = hass + self.data = {} + self.latitude = latitude + self.longitude = longitude + + self._api_coros = { + CATEGORY_CDC_REPORT: self._client.cdc_reports.status_by_coordinates( + latitude, longitude + ), + CATEGORY_USER_REPORT: self._client.user_reports.status_by_coordinates( + latitude, longitude + ), + } + + self._api_category_count = { + CATEGORY_CDC_REPORT: 0, + CATEGORY_USER_REPORT: 0, + } + + self._api_category_locks = { + CATEGORY_CDC_REPORT: asyncio.Lock(), + CATEGORY_USER_REPORT: asyncio.Lock(), + } + + async def _async_get_data_from_api(self, api_category): + """Update and save data for a particular API category.""" + if self._api_category_count[api_category] == 0: + return + + try: + self.data[api_category] = await self._api_coros[api_category] + except FluNearYouError as err: + LOGGER.error("Unable to get %s data: %s", api_category, err) + self.data[api_category] = None + + async def _async_update_listener_action(self, now): + """Define an async_track_time_interval action to update data.""" + await self.async_update() + + @callback + def async_deregister_api_interest(self, sensor_type): + """Decrement the number of entities with data needs from an API category.""" + # If this deregistration should leave us with no registration at all, remove the + # time interval: + if sum(self._api_category_count.values()) == 0: + if self._async_cancel_time_interval_listener: + self._async_cancel_time_interval_listener() + self._async_cancel_time_interval_listener = None + return + + api_category = async_get_api_category(sensor_type) + self._api_category_count[api_category] -= 1 + + async def async_register_api_interest(self, sensor_type): + """Increment the number of entities with data needs from an API category.""" + # If this is the first registration we have, start a time interval: + if not self._async_cancel_time_interval_listener: + self._async_cancel_time_interval_listener = async_track_time_interval( + self._hass, self._async_update_listener_action, DEFAULT_SCAN_INTERVAL, + ) + + api_category = async_get_api_category(sensor_type) + self._api_category_count[api_category] += 1 + + # If a sensor registers interest in a particular API call and the data doesn't + # exist for it yet, make the API call and grab the data: + async with self._api_category_locks[api_category]: + if api_category not in self.data: + await self._async_get_data_from_api(api_category) + + async def async_update(self): + """Update Flu Near You data.""" + tasks = [ + self._async_get_data_from_api(api_category) + for api_category in self._api_coros + ] + + await asyncio.gather(*tasks) + + LOGGER.debug("Received new data") + async_dispatcher_send(self._hass, TOPIC_UPDATE) diff --git a/homeassistant/components/flunearyou/config_flow.py b/homeassistant/components/flunearyou/config_flow.py new file mode 100644 index 00000000000000..2c48b14bb03329 --- /dev/null +++ b/homeassistant/components/flunearyou/config_flow.py @@ -0,0 +1,60 @@ +"""Define a config flow manager for flunearyou.""" +from pyflunearyou import Client +from pyflunearyou.errors import FluNearYouError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import DOMAIN, LOGGER # pylint: disable=unused-import + + +class FluNearYouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an FluNearYou config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def data_schema(self): + """Return the data schema for integration.""" + return vol.Schema( + { + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + 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_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return self.async_show_form(step_id="user", data_schema=self.data_schema) + + unique_id = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + websession = aiohttp_client.async_get_clientsession(self.hass) + client = Client(websession) + + try: + await client.cdc_reports.status_by_coordinates( + user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE] + ) + except FluNearYouError as err: + LOGGER.error("Error while setting up integration: %s", err) + return self.async_show_form( + step_id="user", errors={"base": "general_error"} + ) + + return self.async_create_entry(title=unique_id, data=user_input) diff --git a/homeassistant/components/flunearyou/const.py b/homeassistant/components/flunearyou/const.py new file mode 100644 index 00000000000000..9693d59fc6e79a --- /dev/null +++ b/homeassistant/components/flunearyou/const.py @@ -0,0 +1,38 @@ +"""Define flunearyou constants.""" +import logging + +DOMAIN = "flunearyou" +LOGGER = logging.getLogger("homeassistant.components.flunearyou") + +DATA_CLIENT = "client" + +CATEGORY_CDC_REPORT = "cdc_report" +CATEGORY_USER_REPORT = "user_report" + +TOPIC_UPDATE = "flunearyou_update" + +TYPE_CDC_LEVEL = "level" +TYPE_CDC_LEVEL2 = "level2" +TYPE_USER_CHICK = "chick" +TYPE_USER_DENGUE = "dengue" +TYPE_USER_FLU = "flu" +TYPE_USER_LEPTO = "lepto" +TYPE_USER_NO_SYMPTOMS = "none" +TYPE_USER_SYMPTOMS = "symptoms" +TYPE_USER_TOTAL = "total" + +SENSORS = { + CATEGORY_CDC_REPORT: [ + (TYPE_CDC_LEVEL, "CDC Level", "mdi:biohazard", None), + (TYPE_CDC_LEVEL2, "CDC Level 2", "mdi:biohazard", None), + ], + CATEGORY_USER_REPORT: [ + (TYPE_USER_CHICK, "Avian Flu Symptoms", "mdi:alert", "reports"), + (TYPE_USER_DENGUE, "Dengue Fever Symptoms", "mdi:alert", "reports"), + (TYPE_USER_FLU, "Flu Symptoms", "mdi:alert", "reports"), + (TYPE_USER_LEPTO, "Leptospirosis Symptoms", "mdi:alert", "reports"), + (TYPE_USER_NO_SYMPTOMS, "No Symptoms", "mdi:alert", "reports"), + (TYPE_USER_SYMPTOMS, "Flu-like Symptoms", "mdi:alert", "reports"), + (TYPE_USER_TOTAL, "Total Symptoms", "mdi:alert", "reports"), + ], +} diff --git a/homeassistant/components/flunearyou/manifest.json b/homeassistant/components/flunearyou/manifest.json index e7394356c64d1b..1a28c3076e71c9 100644 --- a/homeassistant/components/flunearyou/manifest.json +++ b/homeassistant/components/flunearyou/manifest.json @@ -1,8 +1,9 @@ { "domain": "flunearyou", "name": "Flu Near You", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flunearyou", - "requirements": ["pyflunearyou==1.0.3"], + "requirements": ["pyflunearyou==1.0.7"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index e06eb3a8ef49dc..6868d21ce1fcb9 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,25 +1,24 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" -from datetime import timedelta -import logging - -from pyflunearyou import Client -from pyflunearyou.errors import FluNearYouError -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_STATE, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, -) -from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_STATE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) +from .const import ( + CATEGORY_CDC_REPORT, + CATEGORY_USER_REPORT, + DATA_CLIENT, + DOMAIN, + SENSORS, + TOPIC_UPDATE, + TYPE_USER_CHICK, + TYPE_USER_DENGUE, + TYPE_USER_FLU, + TYPE_USER_LEPTO, + TYPE_USER_NO_SYMPTOMS, + TYPE_USER_SYMPTOMS, + TYPE_USER_TOTAL, +) ATTR_CITY = "city" ATTR_REPORTED_DATE = "reported_date" @@ -31,94 +30,46 @@ DEFAULT_ATTRIBUTION = "Data provided by Flu Near You" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) -SCAN_INTERVAL = timedelta(minutes=30) - -CATEGORY_CDC_REPORT = "cdc_report" -CATEGORY_USER_REPORT = "user_report" - -TYPE_CDC_LEVEL = "level" -TYPE_CDC_LEVEL2 = "level2" -TYPE_USER_CHICK = "chick" -TYPE_USER_DENGUE = "dengue" -TYPE_USER_FLU = "flu" -TYPE_USER_LEPTO = "lepto" -TYPE_USER_NO_SYMPTOMS = "none" -TYPE_USER_SYMPTOMS = "symptoms" -TYPE_USER_TOTAL = "total" - EXTENDED_TYPE_MAPPING = { TYPE_USER_FLU: "ili", TYPE_USER_NO_SYMPTOMS: "no_symptoms", TYPE_USER_TOTAL: "total_surveys", } -SENSORS = { - CATEGORY_CDC_REPORT: [ - (TYPE_CDC_LEVEL, "CDC Level", "mdi:biohazard", None), - (TYPE_CDC_LEVEL2, "CDC Level 2", "mdi:biohazard", None), - ], - CATEGORY_USER_REPORT: [ - (TYPE_USER_CHICK, "Avian Flu Symptoms", "mdi:alert", "reports"), - (TYPE_USER_DENGUE, "Dengue Fever Symptoms", "mdi:alert", "reports"), - (TYPE_USER_FLU, "Flu Symptoms", "mdi:alert", "reports"), - (TYPE_USER_LEPTO, "Leptospirosis Symptoms", "mdi:alert", "reports"), - (TYPE_USER_NO_SYMPTOMS, "No Symptoms", "mdi:alert", "reports"), - (TYPE_USER_SYMPTOMS, "Flu-like Symptoms", "mdi:alert", "reports"), - (TYPE_USER_TOTAL, "Total Symptoms", "mdi:alert", "reports"), - ], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] - ), - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - websession = aiohttp_client.async_get_clientsession(hass) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Flu Near You sensors based on a config entry.""" + fny = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] - fny = FluNearYouData( - Client(websession), latitude, longitude, config[CONF_MONITORED_CONDITIONS] + async_add_entities( + [ + FluNearYouSensor(fny, sensor_type, name, category, icon, unit) + for category, sensors in SENSORS.items() + for sensor_type, name, icon, unit in sensors + ], + True, ) - await fny.async_update() - - sensors = [ - FluNearYouSensor(fny, kind, name, category, icon, unit) - for category in config[CONF_MONITORED_CONDITIONS] - for kind, name, icon, unit in SENSORS[category] - ] - - async_add_entities(sensors, True) class FluNearYouSensor(Entity): """Define a base Flu Near You sensor.""" - def __init__(self, fny, kind, name, category, icon, unit): + def __init__(self, fny, sensor_type, name, category, icon, unit): """Initialize the sensor.""" + self._async_unsub_dispatcher_connect = None self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._category = category + self._fny = fny self._icon = icon - self._kind = kind self._name = name + self._sensor_type = sensor_type self._state = None self._unit = unit - self.fny = fny @property def available(self): """Return True if entity is available.""" - return bool(self.fny.data[self._category]) + return bool(self._fny.data[self._category]) @property def device_state_attributes(self): @@ -143,19 +94,43 @@ def state(self): @property def unique_id(self): """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.fny.latitude},{self.fny.longitude}_{self._kind}" + return f"{self._fny.latitude},{self._fny.longitude}_{self._sensor_type}" @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit - async def async_update(self): - """Update the sensor.""" - await self.fny.async_update() + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update + ) - cdc_data = self.fny.data.get(CATEGORY_CDC_REPORT) - user_data = self.fny.data.get(CATEGORY_USER_REPORT) + await self._fny.async_register_api_interest(self._sensor_type) + + self.update_from_latest_data() + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + self._async_unsub_dispatcher_connect = None + + self._fny.async_deregister_api_interest(self._sensor_type) + + @callback + def update_from_latest_data(self): + """Update the sensor.""" + cdc_data = self._fny.data.get(CATEGORY_CDC_REPORT) + user_data = self._fny.data.get(CATEGORY_USER_REPORT) if self._category == CATEGORY_CDC_REPORT and cdc_data: self._attrs.update( @@ -164,7 +139,7 @@ async def async_update(self): ATTR_STATE: cdc_data["name"], } ) - self._state = cdc_data[self._kind] + self._state = cdc_data[self._sensor_type] elif self._category == CATEGORY_USER_REPORT and user_data: self._attrs.update( { @@ -176,10 +151,10 @@ async def async_update(self): } ) - if self._kind in user_data["state"]["data"]: - states_key = self._kind - elif self._kind in EXTENDED_TYPE_MAPPING: - states_key = EXTENDED_TYPE_MAPPING[self._kind] + if self._sensor_type in user_data["state"]["data"]: + states_key = self._sensor_type + elif self._sensor_type in EXTENDED_TYPE_MAPPING: + states_key = EXTENDED_TYPE_MAPPING[self._sensor_type] self._attrs[ATTR_STATE_REPORTS_THIS_WEEK] = user_data["state"]["data"][ states_key @@ -188,7 +163,7 @@ async def async_update(self): "last_week_data" ][states_key] - if self._kind == TYPE_USER_TOTAL: + if self._sensor_type == TYPE_USER_TOTAL: self._state = sum( v for k, v in user_data["local"].items() @@ -202,32 +177,4 @@ async def async_update(self): ) ) else: - self._state = user_data["local"][self._kind] - - -class FluNearYouData: - """Define a data object to retrieve info from Flu Near You.""" - - def __init__(self, client, latitude, longitude, sensor_types): - """Initialize.""" - self._client = client - self._sensor_types = sensor_types - self.data = {} - self.latitude = latitude - self.longitude = longitude - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Update Flu Near You data.""" - for key, method in [ - (CATEGORY_CDC_REPORT, self._client.cdc_reports.status_by_coordinates), - (CATEGORY_USER_REPORT, self._client.user_reports.status_by_coordinates), - ]: - if key in self._sensor_types: - try: - self.data[key] = await method(self.latitude, self.longitude) - except FluNearYouError as err: - _LOGGER.error('There was an error with "%s" data: %s', key, err) - self.data[key] = {} - - _LOGGER.debug("New data stored: %s", self.data) + self._state = user_data["local"][self._sensor_type] diff --git a/homeassistant/components/flunearyou/strings.json b/homeassistant/components/flunearyou/strings.json new file mode 100644 index 00000000000000..5539c6f82df7d6 --- /dev/null +++ b/homeassistant/components/flunearyou/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Flu Near You", + "step": { + "user": { + "title": "Configure Flu Near You", + "description": "Monitor user-based and CDC repots for a pair of coordinates.", + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "general_error": "There was an unknown error." + }, + "abort": { + "already_configured": "These coordinates are already registered." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index dd0342a06a31fa..1584d342db4f59 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -31,6 +31,7 @@ "elkm1", "emulated_roku", "esphome", + "flunearyou", "freebox", "garmin_connect", "gdacs", diff --git a/requirements_all.txt b/requirements_all.txt index 95bdb6f7bc131c..a820ea3423d817 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1281,7 +1281,7 @@ pyflic-homeassistant==0.4.dev0 pyflume==0.3.0 # homeassistant.components.flunearyou -pyflunearyou==1.0.3 +pyflunearyou==1.0.7 # homeassistant.components.futurenow pyfnip==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7278c57b38bd7..9d050e7288b6e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,6 +493,9 @@ pyeverlights==0.1.0 # homeassistant.components.fido pyfido==2.1.1 +# homeassistant.components.flunearyou +pyflunearyou==1.0.7 + # homeassistant.components.fritzbox pyfritzhome==0.4.0 diff --git a/tests/components/flunearyou/__init__.py b/tests/components/flunearyou/__init__.py new file mode 100644 index 00000000000000..21252facd75910 --- /dev/null +++ b/tests/components/flunearyou/__init__.py @@ -0,0 +1 @@ +"""Define tests for the flunearyou component.""" diff --git a/tests/components/flunearyou/test_config_flow.py b/tests/components/flunearyou/test_config_flow.py new file mode 100644 index 00000000000000..21fcb4798db387 --- /dev/null +++ b/tests/components/flunearyou/test_config_flow.py @@ -0,0 +1,87 @@ +"""Define tests for the flunearyou config flow.""" +from asynctest import patch +from pyflunearyou.errors import FluNearYouError + +from homeassistant import data_entry_flow +from homeassistant.components.flunearyou import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE + +from tests.common import MockConfigEntry + + +async def test_duplicate_error(hass): + """Test that an error is shown when duplicates are added.""" + conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} + + MockConfigEntry( + domain=DOMAIN, unique_id="51.528308, -0.3817765", data=conf + ).add_to_hass(hass) + + 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_general_error(hass): + """Test that an error is shown on a library error.""" + conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} + + with patch( + "pyflunearyou.cdc.CdcReport.status_by_coordinates", side_effect=FluNearYouError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {"base": "general_error"} + + +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"] == "user" + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} + + with patch( + "homeassistant.components.flunearyou.async_setup_entry", return_value=True + ), patch("pyflunearyou.cdc.CdcReport.status_by_coordinates"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "51.528308, -0.3817765" + assert result["data"] == { + CONF_LATITUDE: "51.528308", + CONF_LONGITUDE: "-0.3817765", + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"} + + with patch( + "homeassistant.components.flunearyou.async_setup_entry", return_value=True + ), patch("pyflunearyou.cdc.CdcReport.status_by_coordinates"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "51.528308, -0.3817765" + assert result["data"] == { + CONF_LATITUDE: "51.528308", + CONF_LONGITUDE: "-0.3817765", + } From e64104300f75f19f66da6de724cb6c6f9ba131c9 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Fri, 3 Apr 2020 01:55:44 +0200 Subject: [PATCH 28/44] =?UTF-8?q?Use=20backend-provided=20fan=20speed=20pr?= =?UTF-8?q?esets=20for=20Xiaomi=20vacuums,=20bum=E2=80=A6=20(#32850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use backend-provided fan speed presets for Xiaomi vacuums This needs input from Xiaomi vacuum owners to verify that it does not break anything. I have personally tested this on rockrobo v1 (old mapping). Related issues/PRs: home-assistant/core#32821 home-assistant/core#31268 home-assistant/core#27268 This is a WIP as it requires a new upstream release. The PR is https://github.com/rytilahti/python-miio/pull/643 * Bump version requirement for 0.5.0 * Bump requirements_test_all.txt, too * Fix linting; missing setup.cfg on local checkout caused wrong settings for black.. * Add tests for both fan speed types * Remove useless else.. * bump python-miio to 0.5.0.1 due to broken 0.5.0 packaging --- .../components/xiaomi_miio/manifest.json | 2 +- .../components/xiaomi_miio/vacuum.py | 20 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/xiaomi_miio/test_vacuum.py | 144 ++++++++++++------ 5 files changed, 110 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index 3d179c63adbdaa..4d88cdef0f2a84 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -2,7 +2,7 @@ "domain": "xiaomi_miio", "name": "Xiaomi miio", "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", - "requirements": ["construct==2.9.45", "python-miio==0.4.8"], + "requirements": ["construct==2.9.45", "python-miio==0.5.0.1"], "dependencies": [], "codeowners": ["@rytilahti", "@syssi"] } diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index a32a28993cac3e..416918e6f43603 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -60,8 +60,6 @@ extra=vol.ALLOW_EXTRA, ) -FAN_SPEEDS = {"Silent": 38, "Standard": 60, "Medium": 77, "Turbo": 90, "Gentle": 105} - ATTR_CLEAN_START = "clean_start" ATTR_CLEAN_STOP = "clean_stop" ATTR_CLEANING_TIME = "cleaning_time" @@ -246,6 +244,8 @@ def __init__(self, name, vacuum): self.clean_history = None self.dnd_state = None self.last_clean = None + self._fan_speeds = None + self._fan_speeds_reverse = None @property def name(self): @@ -281,14 +281,17 @@ def fan_speed(self): """Return the fan speed of the vacuum cleaner.""" if self.vacuum_state is not None: speed = self.vacuum_state.fanspeed - if speed in FAN_SPEEDS.values(): - return [key for key, value in FAN_SPEEDS.items() if value == speed][0] + if speed in self._fan_speeds_reverse: + return self._fan_speeds_reverse[speed] + + _LOGGER.debug("Unable to find reverse for %s", speed) + return speed @property def fan_speed_list(self): """Get the list of available fan speed steps of the vacuum cleaner.""" - return list(sorted(FAN_SPEEDS.keys(), key=lambda s: FAN_SPEEDS[s])) + return list(self._fan_speeds) @property def device_state_attributes(self): @@ -372,8 +375,8 @@ async def async_stop(self, **kwargs): async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" - if fan_speed.capitalize() in FAN_SPEEDS: - fan_speed = FAN_SPEEDS[fan_speed.capitalize()] + if fan_speed in self._fan_speeds: + fan_speed = self._fan_speeds[fan_speed] else: try: fan_speed = int(fan_speed) @@ -453,6 +456,9 @@ def update(self): state = self._vacuum.status() self.vacuum_state = state + self._fan_speeds = self._vacuum.fan_speed_presets() + self._fan_speeds_reverse = {v: k for k, v in self._fan_speeds.items()} + self.consumable_state = self._vacuum.consumable_status() self.clean_history = self._vacuum.clean_history() self.last_clean = self._vacuum.last_clean_details() diff --git a/requirements_all.txt b/requirements_all.txt index a820ea3423d817..e1d63b4b754113 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1638,7 +1638,7 @@ python-juicenet==0.1.6 # python-lirc==1.2.3 # homeassistant.components.xiaomi_miio -python-miio==0.4.8 +python-miio==0.5.0.1 # homeassistant.components.mpd python-mpd2==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d050e7288b6e0..e5b86bf59da8f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -620,7 +620,7 @@ python-forecastio==1.4.0 python-izone==1.1.2 # homeassistant.components.xiaomi_miio -python-miio==0.4.8 +python-miio==0.5.0.1 # homeassistant.components.nest python-nest==4.1.0 diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 47c7a98023ce59..d497aec0dca35e 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -100,6 +100,36 @@ def mirobo_is_got_error_fixture(): yield mock_vacuum +old_fanspeeds = { + "Silent": 38, + "Standard": 60, + "Medium": 77, + "Turbo": 90, +} +new_fanspeeds = { + "Silent": 101, + "Standard": 102, + "Medium": 103, + "Turbo": 104, + "Gentle": 105, +} + + +@pytest.fixture(name="mock_mirobo_fanspeeds", params=[old_fanspeeds, new_fanspeeds]) +def mirobo_old_speeds_fixture(request): + """Fixture for testing both types of fanspeeds.""" + mock_vacuum = mock.MagicMock() + mock_vacuum.status().battery = 32 + mock_vacuum.fan_speed_presets.return_value = request.param + mock_vacuum.status().fanspeed = list(request.param.values())[0] + + with mock.patch( + "homeassistant.components.xiaomi_miio.vacuum.Vacuum" + ) as mock_vaccum_cls: + mock_vaccum_cls.return_value = mock_vacuum + yield mock_vacuum + + @pytest.fixture(name="mock_mirobo_is_on") def mirobo_is_on_fixture(): """Mock mock_mirobo.""" @@ -204,14 +234,6 @@ async def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_got_error): assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-80" assert state.attributes.get(ATTR_CLEANING_TIME) == 155 assert state.attributes.get(ATTR_CLEANED_AREA) == 123 - assert state.attributes.get(ATTR_FAN_SPEED) == "Silent" - assert state.attributes.get(ATTR_FAN_SPEED_LIST) == [ - "Silent", - "Standard", - "Medium", - "Turbo", - "Gentle", - ] assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 12 assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 12 assert state.attributes.get(ATTR_FILTER_LEFT) == 12 @@ -257,40 +279,6 @@ async def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_got_error): mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() - # Set speed service: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_FAN_SPEED, - {"entity_id": entity_id, "fan_speed": 60}, - blocking=True, - ) - mock_mirobo_is_got_error.assert_has_calls( - [mock.call.set_fan_speed(60)], any_order=True - ) - mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) - mock_mirobo_is_got_error.reset_mock() - - await hass.services.async_call( - DOMAIN, - SERVICE_SET_FAN_SPEED, - {"entity_id": entity_id, "fan_speed": "Medium"}, - blocking=True, - ) - mock_mirobo_is_got_error.assert_has_calls( - [mock.call.set_fan_speed(77)], any_order=True - ) - mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) - mock_mirobo_is_got_error.reset_mock() - - assert "ERROR" not in caplog.text - await hass.services.async_call( - DOMAIN, - SERVICE_SET_FAN_SPEED, - {"entity_id": entity_id, "fan_speed": "invent"}, - blocking=True, - ) - assert "ERROR" in caplog.text - await hass.services.async_call( DOMAIN, SERVICE_SEND_COMMAND, @@ -346,14 +334,6 @@ async def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-30" assert state.attributes.get(ATTR_CLEANING_TIME) == 175 assert state.attributes.get(ATTR_CLEANED_AREA) == 133 - assert state.attributes.get(ATTR_FAN_SPEED) == 99 - assert state.attributes.get(ATTR_FAN_SPEED_LIST) == [ - "Silent", - "Standard", - "Medium", - "Turbo", - "Gentle", - ] assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 11 assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 11 assert state.attributes.get(ATTR_FILTER_LEFT) == 11 @@ -409,3 +389,67 @@ async def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on): ) mock_mirobo_is_on.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_on.reset_mock() + + +async def test_xiaomi_vacuum_fanspeeds(hass, caplog, mock_mirobo_fanspeeds): + """Test Xiaomi vacuum fanspeeds.""" + entity_name = "test_vacuum_cleaner_2" + entity_id = f"{DOMAIN}.{entity_name}" + + await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_PLATFORM: PLATFORM, + CONF_HOST: "192.168.1.100", + CONF_NAME: entity_name, + CONF_TOKEN: "12345678901234567890123456789012", + } + }, + ) + await hass.async_block_till_done() + + assert "Initializing with host 192.168.1.100 (token 12345" in caplog.text + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_FAN_SPEED) == "Silent" + fanspeeds = state.attributes.get(ATTR_FAN_SPEED_LIST) + for speed in ["Silent", "Standard", "Medium", "Turbo"]: + assert speed in fanspeeds + + # Set speed service: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_SPEED, + {"entity_id": entity_id, "fan_speed": 60}, + blocking=True, + ) + mock_mirobo_fanspeeds.assert_has_calls( + [mock.call.set_fan_speed(60)], any_order=True + ) + mock_mirobo_fanspeeds.assert_has_calls(STATUS_CALLS, any_order=True) + mock_mirobo_fanspeeds.reset_mock() + + fan_speed_dict = mock_mirobo_fanspeeds.fan_speed_presets() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_SPEED, + {"entity_id": entity_id, "fan_speed": "Medium"}, + blocking=True, + ) + mock_mirobo_fanspeeds.assert_has_calls( + [mock.call.set_fan_speed(fan_speed_dict["Medium"])], any_order=True + ) + mock_mirobo_fanspeeds.assert_has_calls(STATUS_CALLS, any_order=True) + mock_mirobo_fanspeeds.reset_mock() + + assert "ERROR" not in caplog.text + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_SPEED, + {"entity_id": entity_id, "fan_speed": "invent"}, + blocking=True, + ) + assert "ERROR" in caplog.text From 08c9ceb752e2f884afbf7daa689b85bb50407aa4 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Fri, 3 Apr 2020 00:04:03 +0000 Subject: [PATCH 29/44] [ci skip] Translation update --- .../airvisual/.translations/ko.json | 2 +- .../components/doorbird/.translations/ko.json | 4 +- .../flunearyou/.translations/en.json | 4 +- .../components/hue/.translations/de.json | 17 +++++++++ .../components/hue/.translations/es.json | 17 +++++++++ .../components/hue/.translations/ko.json | 17 +++++++++ .../components/hue/.translations/zh-Hant.json | 17 +++++++++ .../components/ipp/.translations/ko.json | 32 ++++++++++++++++ .../konnected/.translations/ko.json | 10 ++++- .../components/nut/.translations/ko.json | 37 +++++++++++++++++++ .../components/unifi/.translations/de.json | 3 +- .../components/unifi/.translations/en.json | 2 +- .../components/unifi/.translations/ko.json | 3 +- 13 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/ipp/.translations/ko.json create mode 100644 homeassistant/components/nut/.translations/ko.json diff --git a/homeassistant/components/airvisual/.translations/ko.json b/homeassistant/components/airvisual/.translations/ko.json index 8f1155aa5f915f..bb01114a5e3e4f 100644 --- a/homeassistant/components/airvisual/.translations/ko.json +++ b/homeassistant/components/airvisual/.translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\uc774 API \ud0a4\ub294 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + "already_configured": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "invalid_api_key": "\uc798\ubabb\ub41c API \ud0a4" diff --git a/homeassistant/components/doorbird/.translations/ko.json b/homeassistant/components/doorbird/.translations/ko.json index 121262065fd6e8..fcdee98a74d33f 100644 --- a/homeassistant/components/doorbird/.translations/ko.json +++ b/homeassistant/components/doorbird/.translations/ko.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "\uc774 DoorBird \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "already_configured": "\uc774 DoorBird \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "not_doorbird_device": "\uc774 \uae30\uae30\ub294 DoorBird \uac00 \uc544\ub2d9\ub2c8\ub2e4" }, "error": { "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", diff --git a/homeassistant/components/flunearyou/.translations/en.json b/homeassistant/components/flunearyou/.translations/en.json index cd8c0d27c3636e..ca868b8ebd91d6 100644 --- a/homeassistant/components/flunearyou/.translations/en.json +++ b/homeassistant/components/flunearyou/.translations/en.json @@ -12,10 +12,10 @@ "latitude": "Latitude", "longitude": "Longitude" }, - "description": "Monitor user-based and CDC flu reports.", + "description": "Monitor user-based and CDC repots for a pair of coordinates.", "title": "Configure Flu Near You" } }, "title": "Flu Near You" } -} +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index 1907d9d23ca545..a4ab9123b48a83 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -27,5 +27,22 @@ } }, "title": "Philips Hue" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Erste Taste", + "button_2": "Zweite Taste", + "button_3": "Dritte Taste", + "button_4": "Vierte Taste", + "dim_down": "Dimmer runter", + "dim_up": "Dimmer hoch", + "turn_off": "Ausschalten", + "turn_on": "Einschalten" + }, + "trigger_type": { + "remote_button_long_release": "\"{subtype}\" Taste nach langem Dr\u00fccken losgelassen", + "remote_button_short_press": "\"{subtype}\" Taste gedr\u00fcckt", + "remote_button_short_release": "\"{subtype}\" Taste losgelassen" + } } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json index bc41d3d2df0fc1..6a5074c6e4a2e1 100644 --- a/homeassistant/components/hue/.translations/es.json +++ b/homeassistant/components/hue/.translations/es.json @@ -27,5 +27,22 @@ } }, "title": "Philips Hue" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "dim_down": "Bajar la intensidad", + "dim_up": "Subir la intensidad", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga", + "remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado", + "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado" + } } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json index 7e837ca5ff9473..8b1c413b2053f1 100644 --- a/homeassistant/components/hue/.translations/ko.json +++ b/homeassistant/components/hue/.translations/ko.json @@ -27,5 +27,22 @@ } }, "title": "\ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\uccab \ubc88\uc9f8 \ubc84\ud2bc", + "button_2": "\ub450 \ubc88\uc9f8 \ubc84\ud2bc", + "button_3": "\uc138 \ubc88\uc9f8 \ubc84\ud2bc", + "button_4": "\ub124 \ubc88\uc9f8 \ubc84\ud2bc", + "dim_down": "\uc5b4\ub461\uac8c \ud558\uae30", + "dim_up": "\ubc1d\uac8c \ud558\uae30", + "turn_off": "\ub044\uae30", + "turn_on": "\ucf1c\uae30" + }, + "trigger_type": { + "remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c", + "remote_button_short_press": "\"{subtype}\" \ubc84\ud2bc\uc774 \ub20c\ub9b4 \ub54c", + "remote_button_short_release": "\"{subtype}\" \ubc84\ud2bc\uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c" + } } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json index 6bbe75a8019b33..0aa75438f7bbbf 100644 --- a/homeassistant/components/hue/.translations/zh-Hant.json +++ b/homeassistant/components/hue/.translations/zh-Hant.json @@ -27,5 +27,22 @@ } }, "title": "Philips Hue" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", + "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "dim_down": "\u8abf\u6697", + "dim_up": "\u8abf\u4eae", + "turn_off": "\u95dc\u9589", + "turn_on": "\u958b\u555f" + }, + "trigger_type": { + "remote_button_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e", + "remote_button_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b", + "remote_button_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e" + } } } \ No newline at end of file diff --git a/homeassistant/components/ipp/.translations/ko.json b/homeassistant/components/ipp/.translations/ko.json new file mode 100644 index 00000000000000..ab556519e07812 --- /dev/null +++ b/homeassistant/components/ipp/.translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\uc774 \ud504\ub9b0\ud130\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "connection_error": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "connection_upgrade": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud558\ub824\uba74 \uc5f0\uacb0\uc744 \uc5c5\uadf8\ub808\uc774\ub4dc\ud574\uc57c \ud569\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", + "connection_upgrade": "\ud504\ub9b0\ud130\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. SSL/TLS \uc635\uc158\uc744 \ud655\uc778\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "flow_title": "\ud504\ub9b0\ud130: {name}", + "step": { + "user": { + "data": { + "base_path": "\ud504\ub9b0\ud130\uc758 \uc0c1\ub300 \uacbd\ub85c", + "host": "\ud638\uc2a4\ud2b8 \ub610\ub294 IP \uc8fc\uc18c", + "port": "\ud3ec\ud2b8", + "ssl": "\ud504\ub9b0\ud130\ub294 SSL/TLS \ub97c \ud1b5\ud55c \ud1b5\uc2e0\uc744 \uc9c0\uc6d0\ud569\ub2c8\ub2e4", + "verify_ssl": "\ud504\ub9b0\ud130\ub294 \uc62c\ubc14\ub978 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4" + }, + "description": "\uc778\ud130\ub137 \uc778\uc1c4 \ud504\ub85c\ud1a0\ucf5c (IPP) \ub97c \ud1b5\ud574 \ud504\ub9b0\ud130\ub97c \uc124\uc815\ud558\uc5ec Home Assistant \uc640 \uc5f0\ub3d9\ud569\ub2c8\ub2e4.", + "title": "\ud504\ub9b0\ud130 \uc5f0\uacb0" + }, + "zeroconf_confirm": { + "description": "Home Assistant \uc5d0 `{name}` \ud504\ub9b0\ud130\ub97c \ucd94\uac00\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\ubc1c\uacac\ub41c \ud504\ub9b0\ud130" + } + }, + "title": "\uc778\ud130\ub137 \uc778\uc1c4 \ud504\ub85c\ud1a0\ucf5c (IPP)" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/ko.json b/homeassistant/components/konnected/.translations/ko.json index 0c5e213ea0d10f..34dd01d06b62de 100644 --- a/homeassistant/components/konnected/.translations/ko.json +++ b/homeassistant/components/konnected/.translations/ko.json @@ -33,6 +33,9 @@ "abort": { "not_konn_panel": "\uc778\uc2dd\ub41c Konnected.io \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4" }, + "error": { + "bad_host": "API \ud638\uc2a4\ud2b8 URL \uc7ac\uc815\uc758\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, "step": { "options_binary": { "data": { @@ -82,7 +85,9 @@ }, "options_misc": { "data": { - "blink": "\uc0c1\ud0dc \ubcc0\uacbd\uc744 \ubcf4\ub0bc \ub54c \uae5c\ubc15\uc784 \ud328\ub110 LED \ub97c \ucf2d\ub2c8\ub2e4" + "api_host": "API \ud638\uc2a4\ud2b8 URL \uc7ac\uc815\uc758 (\uc120\ud0dd \uc0ac\ud56d)", + "blink": "\uc0c1\ud0dc \ubcc0\uacbd\uc744 \ubcf4\ub0bc \ub54c \uae5c\ubc15\uc784 \ud328\ub110 LED \ub97c \ucf2d\ub2c8\ub2e4", + "override_api_host": "\uae30\ubcf8 Home Assistant API \ud638\uc2a4\ud2b8 \ud328\ub110 URL \uc7ac\uc815\uc758" }, "description": "\ud328\ub110\uc5d0 \uc6d0\ud558\ub294 \ub3d9\uc791\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694", "title": "\uae30\ud0c0 \uad6c\uc131" @@ -91,11 +96,12 @@ "data": { "activation": "\uc2a4\uc704\uce58\uac00 \ucf1c\uc9c8 \ub54c \ucd9c\ub825", "momentary": "\ud384\uc2a4 \uc9c0\uc18d\uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)", + "more_states": "\uc774 \uad6c\uc5ed\uc5d0 \ub300\ud55c \ucd94\uac00 \uc0c1\ud0dc \uad6c\uc131", "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d)", "pause": "\ud384\uc2a4 \uac04 \uc77c\uc2dc\uc815\uc9c0 \uc2dc\uac04 (ms) (\uc120\ud0dd \uc0ac\ud56d)", "repeat": "\ubc18\ubcf5 \uc2dc\uac04 (-1 = \ubb34\ud55c) (\uc120\ud0dd \uc0ac\ud56d)" }, - "description": "{zone} \ub300\ud55c \ucd9c\ub825 \uc635\uc158\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "description": "{zone} \uad6c\uc5ed\uc5d0 \ub300\ud55c \ucd9c\ub825 \uc635\uc158\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694: \uc0c1\ud0dc {state}", "title": "\uc2a4\uc704\uce58 \ucd9c\ub825 \uad6c\uc131" } }, diff --git a/homeassistant/components/nut/.translations/ko.json b/homeassistant/components/nut/.translations/ko.json new file mode 100644 index 00000000000000..f9fa46b6667beb --- /dev/null +++ b/homeassistant/components/nut/.translations/ko.json @@ -0,0 +1,37 @@ +{ + "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. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "alias": "\ubcc4\uba85", + "host": "\ud638\uc2a4\ud2b8", + "name": "\uc774\ub984", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8", + "resources": "\ub9ac\uc18c\uc2a4", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" + }, + "description": "NUT \uc11c\ubc84\uc5d0 UPS \uac00 \uc5ec\ub7ec \uac1c \uc5f0\uacb0\ub418\uc5b4 \uc788\ub294 \uacbd\uc6b0 '\ubcc4\uba85' \uc785\ub825\ub780\uc5d0 \uc870\ud68c\ud560 UPS \uc774\ub984\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "NUT \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud558\uae30" + } + }, + "title": "\ub124\ud2b8\uc6cc\ud06c UPS \ub3c4\uad6c (NUT)" + }, + "options": { + "step": { + "init": { + "data": { + "resources": "\ub9ac\uc18c\uc2a4" + }, + "description": "\uc13c\uc11c \ub9ac\uc18c\uc2a4 \uc120\ud0dd" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json index 655000662ec8c7..afdea87956b375 100644 --- a/homeassistant/components/unifi/.translations/de.json +++ b/homeassistant/components/unifi/.translations/de.json @@ -32,7 +32,8 @@ "client_control": { "data": { "block_client": "Clients mit Netzwerkzugriffskontrolle", - "new_client": "F\u00fcgen Sie einen neuen Client f\u00fcr die Netzwerkzugangskontrolle hinzu" + "new_client": "F\u00fcgen Sie einen neuen Client f\u00fcr die Netzwerkzugangskontrolle hinzu", + "poe_clients": "POE-Kontrolle von Clients zulassen" }, "description": "Konfigurieren Sie Client-Steuerelemente \n\nErstellen Sie Switches f\u00fcr Seriennummern, f\u00fcr die Sie den Netzwerkzugriff steuern m\u00f6chten.", "title": "UniFi-Optionen 2/3" diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json index 0124ca1cc24412..d42a647c82fe2c 100644 --- a/homeassistant/components/unifi/.translations/en.json +++ b/homeassistant/components/unifi/.translations/en.json @@ -32,7 +32,7 @@ "client_control": { "data": { "block_client": "Network access controlled clients", - "new_client": "Add new client (MAC) for network access control", + "new_client": "Add new client for network access control", "poe_clients": "Allow POE control of clients" }, "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json index 5c45e272e9174c..d57d80c7911475 100644 --- a/homeassistant/components/unifi/.translations/ko.json +++ b/homeassistant/components/unifi/.translations/ko.json @@ -32,7 +32,8 @@ "client_control": { "data": { "block_client": "\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4 \uc81c\uc5b4 \ud074\ub77c\uc774\uc5b8\ud2b8", - "new_client": "\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4 \uc81c\uc5b4\ub97c \uc704\ud55c \uc0c8\ub85c\uc6b4 \ud074\ub77c\uc774\uc5b8\ud2b8 \ucd94\uac00" + "new_client": "\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4 \uc81c\uc5b4\ub97c \uc704\ud55c \uc0c8\ub85c\uc6b4 \ud074\ub77c\uc774\uc5b8\ud2b8 \ucd94\uac00", + "poe_clients": "\ud074\ub77c\uc774\uc5b8\ud2b8\uc758 POE \uc81c\uc5b4 \ud5c8\uc6a9" }, "description": "\ud074\ub77c\uc774\uc5b8\ud2b8 \ucee8\ud2b8\ub864 \uad6c\uc131 \n\n\ub124\ud2b8\uc6cc\ud06c \uc561\uc138\uc2a4\ub97c \uc81c\uc5b4\ud558\ub824\ub294 \uc2dc\ub9ac\uc5bc \ubc88\ud638\uc5d0 \ub300\ud55c \uc2a4\uc704\uce58\ub97c \ub9cc\ub4ed\ub2c8\ub2e4.", "title": "UniFi \uc635\uc158 2/3" From 2b0bdd580cd9caf7cfe4c05a00c16ec3a3a2f3ec Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 2 Apr 2020 19:09:38 -0500 Subject: [PATCH 30/44] Update to pyipp==0.8.3 (#33554) * Update manifest.json * Update requirements_all.txt * Update requirements_test_all.txt --- homeassistant/components/ipp/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 2eae581bdc70eb..0cb788eeee7f88 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -2,7 +2,7 @@ "domain": "ipp", "name": "Internet Printing Protocol (IPP)", "documentation": "https://www.home-assistant.io/integrations/ipp", - "requirements": ["pyipp==0.8.2"], + "requirements": ["pyipp==0.8.3"], "dependencies": [], "codeowners": ["@ctalkington"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index e1d63b4b754113..6724b83d8b429b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1336,7 +1336,7 @@ pyintesishome==1.7.1 pyipma==2.0.5 # homeassistant.components.ipp -pyipp==0.8.2 +pyipp==0.8.3 # homeassistant.components.iqvia pyiqvia==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5b86bf59da8f4..83255df5714538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -522,7 +522,7 @@ pyicloud==0.9.6.1 pyipma==2.0.5 # homeassistant.components.ipp -pyipp==0.8.2 +pyipp==0.8.3 # homeassistant.components.iqvia pyiqvia==0.2.1 From f25321e010b2ced0efa07324a4002e443c0218cc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2020 20:06:13 -0500 Subject: [PATCH 31/44] Use homekit service callbacks for lights to resolve out of sync states (#32348) * Switch homekit lights to use service callbacks Service callbacks allow us to get the on/off, brightness, etc all in one call so we remove all the complexity that was previously needed to handle the out of sync states We now get the on event and brightness event at the same time which allows us to prevent lights from flashing up to 100% before the requested brightness. * Fix STATE_OFF -> STATE_ON,brightness:0 --- .../components/homekit/type_lights.py | 163 ++++----- tests/components/homekit/test_type_lights.py | 312 ++++++++++++++++-- 2 files changed, 345 insertions(+), 130 deletions(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 734568606b21b3..1720c2c58c8e90 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -25,7 +25,7 @@ ) from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import HomeAccessory from .const import ( CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, @@ -52,15 +52,6 @@ class Light(HomeAccessory): def __init__(self, *args): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - self._flag = { - CHAR_ON: False, - CHAR_BRIGHTNESS: False, - CHAR_HUE: False, - CHAR_SATURATION: False, - CHAR_COLOR_TEMPERATURE: False, - RGB_COLOR: False, - } - self._state = 0 self.chars = [] self._features = self.hass.states.get(self.entity_id).attributes.get( @@ -82,17 +73,14 @@ def __init__(self, *args): self.chars.append(CHAR_COLOR_TEMPERATURE) serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) - self.char_on = serv_light.configure_char( - CHAR_ON, value=self._state, setter_callback=self.set_state - ) + + self.char_on = serv_light.configure_char(CHAR_ON, value=0) if CHAR_BRIGHTNESS in self.chars: # Initial value is set to 100 because 0 is a special value (off). 100 is # an arbitrary non-zero value. It is updated immediately by update_state # to set to the correct initial value. - self.char_brightness = serv_light.configure_char( - CHAR_BRIGHTNESS, value=100, setter_callback=self.set_brightness - ) + self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) if CHAR_COLOR_TEMPERATURE in self.chars: min_mireds = self.hass.states.get(self.entity_id).attributes.get( @@ -105,133 +93,98 @@ def __init__(self, *args): CHAR_COLOR_TEMPERATURE, value=min_mireds, properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, - setter_callback=self.set_color_temperature, ) if CHAR_HUE in self.chars: - self.char_hue = serv_light.configure_char( - CHAR_HUE, value=0, setter_callback=self.set_hue - ) + self.char_hue = serv_light.configure_char(CHAR_HUE, value=0) if CHAR_SATURATION in self.chars: - self.char_saturation = serv_light.configure_char( - CHAR_SATURATION, value=75, setter_callback=self.set_saturation - ) + self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) - def set_state(self, value): - """Set state if call came from HomeKit.""" - if self._state == value: - return + serv_light.setter_callback = self._set_chars - _LOGGER.debug("%s: Set state to %d", self.entity_id, value) - self._flag[CHAR_ON] = True + def _set_chars(self, char_values): + _LOGGER.debug("_set_chars: %s", char_values) + events = [] + service = SERVICE_TURN_ON params = {ATTR_ENTITY_ID: self.entity_id} - service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF - self.call_service(DOMAIN, service, params) - - @debounce - def set_brightness(self, value): - """Set brightness if call came from HomeKit.""" - _LOGGER.debug("%s: Set brightness to %d", self.entity_id, value) - self._flag[CHAR_BRIGHTNESS] = True - if value == 0: - self.set_state(0) # Turn off light - return - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value} - self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"brightness at {value}%") - - def set_color_temperature(self, value): - """Set color temperature if call came from HomeKit.""" - _LOGGER.debug("%s: Set color temp to %s", self.entity_id, value) - self._flag[CHAR_COLOR_TEMPERATURE] = True - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value} - self.call_service( - DOMAIN, SERVICE_TURN_ON, params, f"color temperature at {value}" - ) + if CHAR_ON in char_values: + if not char_values[CHAR_ON]: + service = SERVICE_TURN_OFF + events.append(f"Set state to {char_values[CHAR_ON]}") + + if CHAR_BRIGHTNESS in char_values: + if char_values[CHAR_BRIGHTNESS] == 0: + events[-1] = f"Set state to 0" + service = SERVICE_TURN_OFF + else: + params[ATTR_BRIGHTNESS_PCT] = char_values[CHAR_BRIGHTNESS] + events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%") + + if CHAR_COLOR_TEMPERATURE in char_values: + params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] + events.append(f"color temperature at {char_values[CHAR_COLOR_TEMPERATURE]}") - def set_saturation(self, value): - """Set saturation if call came from HomeKit.""" - _LOGGER.debug("%s: Set saturation to %d", self.entity_id, value) - self._flag[CHAR_SATURATION] = True - self._saturation = value - self.set_color() - - def set_hue(self, value): - """Set hue if call came from HomeKit.""" - _LOGGER.debug("%s: Set hue to %d", self.entity_id, value) - self._flag[CHAR_HUE] = True - self._hue = value - self.set_color() - - def set_color(self): - """Set color if call came from HomeKit.""" if ( self._features & SUPPORT_COLOR - and self._flag[CHAR_HUE] - and self._flag[CHAR_SATURATION] + and CHAR_HUE in char_values + and CHAR_SATURATION in char_values ): - color = (self._hue, self._saturation) + color = (char_values[CHAR_HUE], char_values[CHAR_SATURATION]) _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) - self._flag.update( - {CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True} - ) - params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color} - self.call_service(DOMAIN, SERVICE_TURN_ON, params, f"set color at {color}") + params[ATTR_HS_COLOR] = color + events.append(f"set color at {color}") + + self.call_service(DOMAIN, service, params, ", ".join(events)) def update_state(self, new_state): """Update light after state change.""" # Handle State state = new_state.state - if state in (STATE_ON, STATE_OFF): - self._state = 1 if state == STATE_ON else 0 - if not self._flag[CHAR_ON] and self.char_on.value != self._state: - self.char_on.set_value(self._state) - self._flag[CHAR_ON] = False + if state == STATE_ON and self.char_on.value != 1: + self.char_on.set_value(1) + elif state == STATE_OFF and self.char_on.value != 0: + self.char_on.set_value(0) # Handle Brightness if CHAR_BRIGHTNESS in self.chars: brightness = new_state.attributes.get(ATTR_BRIGHTNESS) - if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int): + if isinstance(brightness, int): brightness = round(brightness / 255 * 100, 0) + # The homeassistant component might report its brightness as 0 but is + # not off. But 0 is a special value in homekit. When you turn on a + # homekit accessory it will try to restore the last brightness state + # which will be the last value saved by char_brightness.set_value. + # But if it is set to 0, HomeKit will update the brightness to 100 as + # it thinks 0 is off. + # + # Therefore, if the the brightness is 0 and the device is still on, + # the brightness is mapped to 1 otherwise the update is ignored in + # order to avoid this incorrect behavior. + if brightness == 0 and state == STATE_ON: + brightness = 1 if self.char_brightness.value != brightness: - # The homeassistant component might report its brightness as 0 but is - # not off. But 0 is a special value in homekit. When you turn on a - # homekit accessory it will try to restore the last brightness state - # which will be the last value saved by char_brightness.set_value. - # But if it is set to 0, HomeKit will update the brightness to 100 as - # it thinks 0 is off. - # - # Therefore, if the the brightness is 0 and the device is still on, - # the brightness is mapped to 1 otherwise the update is ignored in - # order to avoid this incorrect behavior. - if brightness == 0: - if state == STATE_ON: - self.char_brightness.set_value(1) - else: - self.char_brightness.set_value(brightness) - self._flag[CHAR_BRIGHTNESS] = False + self.char_brightness.set_value(brightness) # Handle color temperature if CHAR_COLOR_TEMPERATURE in self.chars: color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) if ( - not self._flag[CHAR_COLOR_TEMPERATURE] - and isinstance(color_temperature, int) + isinstance(color_temperature, int) and self.char_color_temperature.value != color_temperature ): self.char_color_temperature.set_value(color_temperature) - self._flag[CHAR_COLOR_TEMPERATURE] = False # Handle Color if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars: hue, saturation = new_state.attributes.get(ATTR_HS_COLOR, (None, None)) if ( - not self._flag[RGB_COLOR] - and (hue != self._hue or saturation != self._saturation) - and isinstance(hue, (int, float)) + isinstance(hue, (int, float)) and isinstance(saturation, (int, float)) + and ( + hue != self.char_hue.value + or saturation != self.char_saturation.value + ) ): self.char_hue.set_value(hue) self.char_saturation.set_value(saturation) - self._hue, self._saturation = (hue, saturation) - self._flag[RGB_COLOR] = False diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 8834f730bcea91..888ad87a848afa 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,6 +1,9 @@ """Test different accessory types: Lights.""" from collections import namedtuple +from asynctest import patch +from pyhap.accessory_driver import AccessoryDriver +from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE import pytest from homeassistant.components.homekit.const import ATTR_VALUE @@ -30,6 +33,15 @@ from tests.components.homekit.common import patch_debounce +@pytest.fixture +def driver(): + """Patch AccessoryDriver without zeroconf or HAPServer.""" + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"): + yield AccessoryDriver() + + @pytest.fixture(scope="module") def cls(): """Patch debounce decorator during import of type_lights.""" @@ -43,15 +55,16 @@ def cls(): patcher.stop() -async def test_light_basic(hass, hk_driver, cls, events): +async def test_light_basic(hass, hk_driver, cls, events, driver): """Test light with char state.""" entity_id = "light.demo" hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None) + acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + driver.add_accessory(acc) - assert acc.aid == 2 + assert acc.aid == 1 assert acc.category == 5 # Lightbulb assert acc.char_on.value == 0 @@ -75,25 +88,43 @@ async def test_light_basic(hass, hk_driver, cls, events): call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1} + ] + }, + "mock_addr", + ) + await hass.async_add_job(acc.char_on.client_update_value, 1) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] is None + assert events[-1].data[ATTR_VALUE] == "Set state to 1" hass.states.async_set(entity_id, STATE_ON) await hass.async_block_till_done() - await hass.async_add_job(acc.char_on.client_update_value, 0) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 0} + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] is None + assert events[-1].data[ATTR_VALUE] == "Set state to 0" -async def test_light_brightness(hass, hk_driver, cls, events): +async def test_light_brightness(hass, hk_driver, cls, events, driver): """Test light with brightness.""" entity_id = "light.demo" @@ -103,11 +134,14 @@ async def test_light_brightness(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None) + acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + driver.add_accessory(acc) # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] await hass.async_add_job(acc.run) await hass.async_block_till_done() @@ -121,34 +155,88 @@ async def test_light_brightness(hass, hk_driver, cls, events): call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") - await hass.async_add_job(acc.char_brightness.client_update_value, 20) - await hass.async_add_job(acc.char_on.client_update_value, 1) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_turn_on[0] assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == f"brightness at 20{UNIT_PERCENTAGE}" + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}" + ) - await hass.async_add_job(acc.char_on.client_update_value, 1) - await hass.async_add_job(acc.char_brightness.client_update_value, 40) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 40, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_turn_on[1] assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == f"brightness at 40{UNIT_PERCENTAGE}" + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 40{UNIT_PERCENTAGE}" + ) - await hass.async_add_job(acc.char_on.client_update_value, 1) - await hass.async_add_job(acc.char_brightness.client_update_value, 0) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 0, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_turn_off assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 3 - assert events[-1].data[ATTR_VALUE] is None + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 0, brightness at 0{UNIT_PERCENTAGE}" + ) + + # 0 is a special case for homekit, see "Handle Brightness" + # in update_state + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 1 + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 100 + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 1 -async def test_light_color_temperature(hass, hk_driver, cls, events): +async def test_light_color_temperature(hass, hk_driver, cls, events, driver): """Test light with color temperature.""" entity_id = "light.demo" @@ -158,7 +246,8 @@ async def test_light_color_temperature(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP: 190}, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None) + acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + driver.add_accessory(acc) assert acc.char_color_temperature.value == 153 @@ -169,6 +258,20 @@ async def test_light_color_temperature(hass, hk_driver, cls, events): # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_VALUE: 250, + } + ] + }, + "mock_addr", + ) await hass.async_add_job(acc.char_color_temperature.client_update_value, 250) await hass.async_block_till_done() assert call_turn_on @@ -197,7 +300,7 @@ async def test_light_color_temperature_and_rgb_color(hass, hk_driver, cls, event assert not hasattr(acc, "char_color_temperature") -async def test_light_rgb_color(hass, hk_driver, cls, events): +async def test_light_rgb_color(hass, hk_driver, cls, events, driver): """Test light with rgb_color.""" entity_id = "light.demo" @@ -207,7 +310,8 @@ async def test_light_rgb_color(hass, hk_driver, cls, events): {ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, ATTR_HS_COLOR: (260, 90)}, ) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None) + acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + driver.add_accessory(acc) assert acc.char_hue.value == 0 assert acc.char_saturation.value == 75 @@ -220,8 +324,26 @@ async def test_light_rgb_color(hass, hk_driver, cls, events): # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - await hass.async_add_job(acc.char_hue.client_update_value, 145) - await hass.async_add_job(acc.char_saturation.client_update_value, 75) + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id @@ -230,7 +352,7 @@ async def test_light_rgb_color(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == "set color at (145, 75)" -async def test_light_restore(hass, hk_driver, cls, events): +async def test_light_restore(hass, hk_driver, cls, events, driver): """Test setting up an entity from state in the event registry.""" hass.state = CoreState.not_running @@ -250,7 +372,9 @@ async def test_light_restore(hass, hk_driver, cls, events): hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = cls.light(hass, hk_driver, "Light", "light.simple", 2, None) + acc = cls.light(hass, hk_driver, "Light", "light.simple", 1, None) + driver.add_accessory(acc) + assert acc.category == 5 # Lightbulb assert acc.chars == [] assert acc.char_on.value == 0 @@ -259,3 +383,141 @@ async def test_light_restore(hass, hk_driver, cls, events): assert acc.category == 5 # Lightbulb assert acc.chars == ["Brightness"] assert acc.char_on.value == 0 + + +async def test_light_set_brightness_and_color(hass, hk_driver, cls, events, driver): + """Test light with all chars in one go.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS | SUPPORT_COLOR, + ATTR_BRIGHTNESS: 255, + }, + ) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + driver.add_accessory(acc) + + # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the + # brightness to 100 when turning on a light on a freshly booted up server. + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_brightness.value == 100 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 40 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75) + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}, set color at (145, 75)" + ) + + +async def test_light_set_brightness_and_color_temp( + hass, hk_driver, cls, events, driver +): + """Test light with all chars in one go.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP, + ATTR_BRIGHTNESS: 255, + }, + ) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, "Light", entity_id, 1, None) + driver.add_accessory(acc) + + # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the + # brightness to 100 when turning on a light on a freshly booted up server. + assert acc.char_brightness.value != 0 + char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_brightness.value == 100 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 40 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 20, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_VALUE: 250, + }, + ] + }, + "mock_addr", + ) + await hass.async_block_till_done() + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + + assert len(events) == 1 + assert ( + events[-1].data[ATTR_VALUE] + == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}, color temperature at 250" + ) From 081b822d25a05b7e16c3e34e729e0ceb7459bbcc Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 2 Apr 2020 22:48:19 -0400 Subject: [PATCH 32/44] Add support for Vizio sound mode (#33200) * add sound mode support for devices that support it * make setting and unsetting flag better * move eq and audio settings into constants * fix missed statement to use constant instead of hardcoded string * further fixes based on review * bump pyvizio version to include newly identified app --- homeassistant/components/vizio/const.py | 3 ++ homeassistant/components/vizio/manifest.json | 2 +- .../components/vizio/media_player.py | 42 +++++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vizio/conftest.py | 8 +++- tests/components/vizio/const.py | 3 ++ tests/components/vizio/test_media_player.py | 28 +++++++++++-- 8 files changed, 79 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index 795f12266fb440..43cb993cec3fe1 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -63,6 +63,9 @@ ), } +VIZIO_SOUND_MODE = "eq" +VIZIO_AUDIO_SETTINGS = "audio" + # Since Vizio component relies on device class, this dict will ensure that changes to # the values of DEVICE_CLASS_SPEAKER or DEVICE_CLASS_TV don't require changes to pyvizio. VIZIO_DEVICE_CLASSES = { diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 885cfacca41529..2436ce6298b3f6 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -2,7 +2,7 @@ "domain": "vizio", "name": "VIZIO SmartCast", "documentation": "https://www.home-assistant.io/integrations/vizio", - "requirements": ["pyvizio==0.1.44"], + "requirements": ["pyvizio==0.1.45"], "dependencies": [], "codeowners": ["@raman325"], "config_flow": true, diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index d463ebca36a422..bb7ae3f75b0510 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -9,6 +9,7 @@ from homeassistant.components.media_player import ( DEVICE_CLASS_SPEAKER, + SUPPORT_SELECT_SOUND_MODE, MediaPlayerDevice, ) from homeassistant.config_entries import ConfigEntry @@ -41,7 +42,9 @@ DOMAIN, ICON, SUPPORTED_COMMANDS, + VIZIO_AUDIO_SETTINGS, VIZIO_DEVICE_CLASSES, + VIZIO_SOUND_MODE, ) _LOGGER = logging.getLogger(__name__) @@ -133,6 +136,8 @@ def __init__( self._current_input = None self._current_app = None self._current_app_config = None + self._current_sound_mode = None + self._available_sound_modes = None self._available_inputs = [] self._available_apps = [] self._conf_apps = config_entry.options.get(CONF_APPS, {}) @@ -191,17 +196,29 @@ async def async_update(self) -> None: self._current_app = None self._current_app_config = None self._available_apps = None + self._current_sound_mode = None + self._available_sound_modes = None return self._state = STATE_ON - audio_settings = await self._device.get_all_audio_settings( - log_api_exception=False + audio_settings = await self._device.get_all_settings( + VIZIO_AUDIO_SETTINGS, log_api_exception=False ) if audio_settings is not None: self._volume_level = float(audio_settings["volume"]) / self._max_volume self._is_muted = audio_settings["mute"].lower() == "on" + if VIZIO_SOUND_MODE in audio_settings: + self._supported_commands |= SUPPORT_SELECT_SOUND_MODE + self._current_sound_mode = audio_settings[VIZIO_SOUND_MODE] + if self._available_sound_modes is None: + self._available_sound_modes = await self._device.get_setting_options( + VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE + ) + else: + self._supported_commands ^= SUPPORT_SELECT_SOUND_MODE + input_ = await self._device.get_current_input(log_api_exception=False) if input_ is not None: self._current_input = input_ @@ -367,7 +384,7 @@ def unique_id(self) -> str: return self._config_entry.unique_id @property - def device_info(self): + def device_info(self) -> Dict[str, Any]: """Return device registry information.""" return { "identifiers": {(DOMAIN, self._config_entry.unique_id)}, @@ -378,10 +395,27 @@ def device_info(self): } @property - def device_class(self): + def device_class(self) -> str: """Return device class for entity.""" return self._device_class + @property + def sound_mode(self) -> Optional[str]: + """Name of the current sound mode.""" + return self._current_sound_mode + + @property + def sound_mode_list(self) -> Optional[List[str]]: + """List of available sound modes.""" + return self._available_sound_modes + + async def async_select_sound_mode(self, sound_mode): + """Select sound mode.""" + if sound_mode in self._available_sound_modes: + await self._device.set_setting( + VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE, sound_mode + ) + async def async_turn_on(self) -> None: """Turn the device on.""" await self._device.pow_on() diff --git a/requirements_all.txt b/requirements_all.txt index 6724b83d8b429b..6ebf1213c633fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyversasense==0.0.6 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.44 +pyvizio==0.1.45 # homeassistant.components.velux pyvlx==0.2.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 83255df5714538..307113e9b182ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -650,7 +650,7 @@ pyvera==0.3.7 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.44 +pyvizio==0.1.45 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 868bf44a11ba7f..e630f201e121d8 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -8,7 +8,9 @@ APP_LIST, CH_TYPE, CURRENT_APP_CONFIG, + CURRENT_EQ, CURRENT_INPUT, + EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, MODEL, @@ -135,11 +137,15 @@ def vizio_update_fixture(): "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check", return_value=True, ), patch( - "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings", + "homeassistant.components.vizio.media_player.VizioAsync.get_all_settings", return_value={ "volume": int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2), + "eq": CURRENT_EQ, "mute": "Off", }, + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_setting_options", + return_value=EQ_LIST, ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_current_input", return_value=CURRENT_INPUT, diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index f1ddc4abba69d4..034fa23a3af2f1 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -64,6 +64,9 @@ def __init__(self, auth_token: str) -> None: self.auth_token = auth_token +CURRENT_EQ = "Music" +EQ_LIST = ["Music", "Movie"] + CURRENT_INPUT = "HDMI" INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"] diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index f860c1cec4fdb6..7678712db5156b 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -21,11 +21,13 @@ ATTR_INPUT_SOURCE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV, DOMAIN as MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -58,9 +60,11 @@ APP_LIST, CURRENT_APP, CURRENT_APP_CONFIG, + CURRENT_EQ, CURRENT_INPUT, CUSTOM_CONFIG, ENTITY_ID, + EQ_LIST, INPUT_LIST, INPUT_LIST_WITH_APPS, MOCK_SPEAKER_APPS_FAILURE, @@ -99,6 +103,11 @@ async def _test_setup( data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), unique_id=UNIQUE_ID, ) + dict_to_return = { + "volume": int(MAX_VOLUME[vizio_device_class] / 2), + "mute": "Off", + "eq": CURRENT_EQ, + } else: vizio_device_class = VIZIO_DEVICE_CLASS_TV config_entry = MockConfigEntry( @@ -106,10 +115,17 @@ async def _test_setup( data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), unique_id=UNIQUE_ID, ) + dict_to_return = { + "volume": int(MAX_VOLUME[vizio_device_class] / 2), + "mute": "Off", + } with patch( - "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings", - return_value={"volume": int(MAX_VOLUME[vizio_device_class] / 2), "mute": "Off"}, + "homeassistant.components.vizio.media_player.VizioAsync.get_all_settings", + return_value=dict_to_return, + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_setting_options", + return_value=EQ_LIST, ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", return_value=vizio_power_state, @@ -130,6 +146,9 @@ async def _test_setup( assert attr["source"] == CURRENT_INPUT if ha_device_class == DEVICE_CLASS_SPEAKER: assert not service_call.called + assert "sound_mode" in attr + else: + assert "sound_mode" not in attr assert ( attr["volume_level"] == float(int(MAX_VOLUME[vizio_device_class] / 2)) @@ -149,7 +168,7 @@ async def _test_setup_with_apps( ) with patch( - "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings", + "homeassistant.components.vizio.media_player.VizioAsync.get_all_settings", return_value={ "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), "mute": "Off", @@ -351,6 +370,9 @@ async def test_services( ) await _test_service(hass, "ch_up", SERVICE_MEDIA_NEXT_TRACK, None) await _test_service(hass, "ch_down", SERVICE_MEDIA_PREVIOUS_TRACK, None) + await _test_service( + hass, "set_setting", SERVICE_SELECT_SOUND_MODE, {ATTR_SOUND_MODE: "Music"} + ) async def test_options_update( From 2065039f653e4e746d072d16152b194ce12a005b Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Thu, 2 Apr 2020 23:55:17 -0400 Subject: [PATCH 33/44] Rachio Async fixes (#33549) * Async fix * Update homeassistant/components/rachio/switch.py Co-Authored-By: J. Nick Koston * Update homeassistant/components/rachio/switch.py Co-Authored-By: J. Nick Koston * Fix format * Remove from hass * undo dispatcher Co-authored-by: J. Nick Koston --- homeassistant/components/rachio/switch.py | 51 +++++++++++------------ 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 86b6097ad13f5a..2b9a959deb2c9e 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -105,17 +105,18 @@ def is_on(self) -> bool: def _poll_update(self, data=None) -> bool: """Poll the API.""" - def _handle_any_update(self, *args, **kwargs) -> None: + @callback + def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" if args[0][KEY_DEVICE_ID] != self._controller.controller_id: # For another device return # For this device - self._handle_update(args, kwargs) + self._async_handle_update(args, kwargs) @abstractmethod - def _handle_update(self, *args, **kwargs) -> None: + def _async_handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook data.""" @@ -149,14 +150,15 @@ def _poll_update(self, data=None) -> bool: return not data[KEY_ON] - def _handle_update(self, *args, **kwargs) -> None: + @callback + def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: self._state = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: self._state = False - self.schedule_update_ha_state() + self.async_write_ha_state() def turn_on(self, **kwargs) -> None: """Put the controller in standby mode.""" @@ -170,7 +172,9 @@ async def async_added_to_hass(self): """Subscribe to updates.""" self.async_on_remove( async_dispatcher_connect( - self.hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, self._handle_any_update + self.hass, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._async_handle_any_update, ) ) @@ -192,7 +196,6 @@ def __init__(self, person, controller, data, current_schedule): self._current_schedule = current_schedule super().__init__(controller, poll=False) self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) - self._undo_dispatcher = None def __str__(self): """Display the zone as a string.""" @@ -229,7 +232,7 @@ def entity_picture(self): return self._entity_picture @property - def state_attributes(self) -> dict: + def device_state_attributes(self) -> dict: """Return the optional state attributes.""" props = {ATTR_ZONE_NUMBER: self._zone_number, ATTR_ZONE_SUMMARY: self._summary} if self._shade_type: @@ -266,7 +269,8 @@ def _poll_update(self, data=None) -> bool: self._current_schedule = self._controller.current_schedule return self.zone_id == self._current_schedule.get(KEY_ZONE_ID) - def _handle_update(self, *args, **kwargs) -> None: + @callback + def _async_handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook zone data.""" if args[0][KEY_ZONE_ID] != self.zone_id: return @@ -278,19 +282,16 @@ def _handle_update(self, *args, **kwargs) -> None: elif args[0][KEY_SUBTYPE] in [SUBTYPE_ZONE_STOPPED, SUBTYPE_ZONE_COMPLETED]: self._state = False - self.schedule_update_ha_state() + self.async_write_ha_state() async def async_added_to_hass(self): """Subscribe to updates.""" - self._undo_dispatcher = async_dispatcher_connect( - self.hass, SIGNAL_RACHIO_ZONE_UPDATE, self._handle_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_RACHIO_ZONE_UPDATE, self._async_handle_update + ) ) - async def async_will_remove_from_hass(self): - """Unsubscribe from updates.""" - if self._undo_dispatcher: - self._undo_dispatcher() - class RachioSchedule(RachioSwitch): """Representation of one fixed schedule on the Rachio Iro.""" @@ -305,7 +306,6 @@ def __init__(self, person, controller, data, current_schedule): self._current_schedule = current_schedule super().__init__(controller, poll=False) self._state = self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) - self._undo_dispatcher = None @property def name(self) -> str: @@ -354,7 +354,7 @@ def _poll_update(self, data=None) -> bool: return self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) @callback - async def _handle_update(self, *args, **kwargs) -> None: + def _async_handle_update(self, *args, **kwargs) -> None: """Handle incoming webhook schedule data.""" # Schedule ID not passed when running individual zones, so we catch that error try: @@ -369,15 +369,12 @@ async def _handle_update(self, *args, **kwargs) -> None: except KeyError: pass - self.schedule_update_ha_state() + self.async_write_ha_state() async def async_added_to_hass(self): """Subscribe to updates.""" - self._undo_dispatcher = async_dispatcher_connect( - self.hass, SIGNAL_RACHIO_SCHEDULE_UPDATE, self._handle_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_RACHIO_SCHEDULE_UPDATE, self._async_handle_update + ) ) - - async def async_will_remove_from_hass(self): - """Unsubscribe from updates.""" - if self._undo_dispatcher: - self._undo_dispatcher() From d98171ed05e68de1bf8e5a38b37b42c1e014375d Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 2 Apr 2020 23:02:27 -0500 Subject: [PATCH 34/44] Plex followup to #33542 (#33558) --- homeassistant/components/plex/const.py | 3 +++ homeassistant/components/plex/media_player.py | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index d5cb3db3aba745..44bb25b3fd9844 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -38,3 +38,6 @@ X_PLEX_PLATFORM = "Home Assistant" X_PLEX_PRODUCT = "Home Assistant" X_PLEX_VERSION = __version__ + +COMMAND_MEDIA_TYPE_MUSIC = "music" +COMMAND_MEDIA_TYPE_VIDEO = "video" diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 5325544bf15a30..aea8ecadaff2aa 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -11,7 +11,6 @@ MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, - MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -28,6 +27,8 @@ from homeassistant.util import dt as dt_util from .const import ( + COMMAND_MEDIA_TYPE_MUSIC, + COMMAND_MEDIA_TYPE_VIDEO, COMMON_PLAYERS, CONF_SERVER_IDENTIFIER, DISPATCHERS, @@ -576,11 +577,11 @@ def play_media(self, media_type, media_id, **kwargs): shuffle = src.get("shuffle", 0) media = None - command_media_type = MEDIA_TYPE_VIDEO + command_media_type = COMMAND_MEDIA_TYPE_VIDEO if media_type == "MUSIC": media = self._get_music_media(library, src) - command_media_type = MEDIA_TYPE_MUSIC + command_media_type = COMMAND_MEDIA_TYPE_MUSIC elif media_type == "EPISODE": media = self._get_tv_media(library, src) elif media_type == "PLAYLIST": From d2cd5575234fe538000665ed34e5c84612af38e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Apr 2020 23:02:50 -0500 Subject: [PATCH 35/44] Add missing flow_title to doorbird (#33557) When placeholders are in use, flow_title needs to be set in the json to prevent an empty name in the integrations dashboard. This affected doorbirds that were found via ssdp. --- homeassistant/components/doorbird/.translations/en.json | 5 +++-- homeassistant/components/doorbird/strings.json | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/doorbird/.translations/en.json b/homeassistant/components/doorbird/.translations/en.json index 27c16fac3a17d5..f933b9c9929583 100644 --- a/homeassistant/components/doorbird/.translations/en.json +++ b/homeassistant/components/doorbird/.translations/en.json @@ -21,7 +21,8 @@ "title": "Connect to the DoorBird" } }, - "title": "DoorBird" + "title": "DoorBird", + "flow_title" : "DoorBird {name} ({host})" }, "options": { "step": { @@ -33,4 +34,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json index 9b2c95dd7c9898..e4fb72db91baf9 100644 --- a/homeassistant/components/doorbird/strings.json +++ b/homeassistant/components/doorbird/strings.json @@ -27,6 +27,7 @@ "not_doorbird_device": "This device is not a DoorBird" }, "title" : "DoorBird", + "flow_title" : "DoorBird {name} ({host})", "error" : { "invalid_auth" : "Invalid authentication", "unknown" : "Unexpected error", From 83cc871edfb49850e686e65969c28e3004a05ea6 Mon Sep 17 00:00:00 2001 From: akasma74 Date: Fri, 3 Apr 2020 07:48:41 +0100 Subject: [PATCH 36/44] Add force_update to timer integration (#31646) * force_update added As per this discussion we need to update last_changed when active timer restarted. One way to do that is to force HA update the state on each request even if it remains the same. More details here - https://github.com/home-assistant/architecture/issues/345 * add test for force_update make sure state_change event fired every time timer (re)started * remove whitespaces * remove whitespace * Update tests/components/timer/test_init.py Co-Authored-By: Alexei Chetroi * fix lint * fix isort Co-authored-by: Alexei Chetroi --- homeassistant/components/timer/__init__.py | 5 +++ tests/components/timer/test_init.py | 42 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 5172322a63d22e..e47ac69be5bdda 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -201,6 +201,11 @@ def should_poll(self): """If entity should be polled.""" return False + @property + def force_update(self) -> bool: + """Return True to fix restart issues.""" + return True + @property def name(self): """Return name of the timer.""" diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index dcf9c36474fcad..dea116b3905a67 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -33,6 +33,7 @@ ATTR_ID, ATTR_NAME, CONF_ENTITY_ID, + EVENT_STATE_CHANGED, SERVICE_RELOAD, ) from homeassistant.core import Context, CoreState @@ -406,6 +407,47 @@ def fake_event_listener(event): assert len(results) == 4 +async def test_state_changed_when_timer_restarted(hass): + """Ensure timer's state changes when it restarted.""" + hass.state = CoreState.starting + + await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) + + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_IDLE + + results = [] + + def fake_event_listener(event): + """Fake event listener for trigger.""" + results.append(event) + + hass.bus.async_listen(EVENT_STATE_CHANGED, fake_event_listener) + + await hass.services.async_call( + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"} + ) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + + assert results[-1].event_type == EVENT_STATE_CHANGED + assert len(results) == 1 + + await hass.services.async_call( + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"} + ) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + + assert results[-1].event_type == EVENT_STATE_CHANGED + assert len(results) == 2 + + async def test_load_from_storage(hass, storage_setup): """Test set up from storage.""" assert await storage_setup() From aef06a35448935693ac02ddbb13667c5bd066710 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 3 Apr 2020 00:34:50 -0700 Subject: [PATCH 37/44] Directly call write state 2 (#33513) * Directly call async_write_ha_state pt2 * Directly call async_write_ha_state pt2 * Fix mock * Address comments --- homeassistant/components/aftership/sensor.py | 2 +- homeassistant/components/alert/__init__.py | 10 +++---- .../components/anthemav/media_player.py | 12 ++++++++- .../components/automation/__init__.py | 2 +- homeassistant/components/buienradar/sensor.py | 10 ++++++- homeassistant/components/buienradar/util.py | 15 +++++------ homeassistant/components/camera/__init__.py | 2 +- .../components/device_tracker/legacy.py | 6 ++--- homeassistant/components/dsmr/sensor.py | 27 +++++++++++++------ .../components/generic_thermostat/climate.py | 6 ++--- homeassistant/components/group/__init__.py | 4 +-- homeassistant/components/imap/sensor.py | 4 +-- .../components/input_boolean/__init__.py | 4 +-- homeassistant/components/knx/binary_sensor.py | 2 +- homeassistant/components/knx/climate.py | 8 +++--- homeassistant/components/knx/cover.py | 2 +- homeassistant/components/knx/light.py | 2 +- homeassistant/components/knx/sensor.py | 2 +- homeassistant/components/knx/switch.py | 2 +- homeassistant/components/lcn/cover.py | 12 ++++----- homeassistant/components/lcn/light.py | 8 +++--- homeassistant/components/lcn/switch.py | 8 +++--- homeassistant/components/lifx/light.py | 4 +-- .../components/microsoft_face/__init__.py | 6 ++--- homeassistant/components/netio/switch.py | 3 +-- .../components/owntracks/messages.py | 2 +- homeassistant/components/rflink/__init__.py | 2 +- homeassistant/components/saj/sensor.py | 13 +++------ homeassistant/components/script/__init__.py | 2 +- homeassistant/components/sma/sensor.py | 11 +++----- .../components/songpal/media_player.py | 8 +++--- .../components/utility_meter/__init__.py | 4 +-- .../components/utility_meter/sensor.py | 4 +-- homeassistant/components/velux/cover.py | 2 +- homeassistant/components/yr/sensor.py | 13 +++++---- homeassistant/components/zwave/node_entity.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/script.py | 6 +---- tests/components/zwave/test_node_entity.py | 7 ++--- tests/helpers/test_entity_component.py | 5 ++-- 40 files changed, 128 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index 293fe4c647a277..9b8a5637de20d2 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -154,7 +154,7 @@ async def async_added_to_hass(self): async def _force_update(self): """Force update of data.""" await self.async_update(no_throttle=True) - await self.async_update_ha_state() + self.async_write_ha_state() @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self, **kwargs): diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 1f8176968d80db..aa8d19dc40fe72 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -1,5 +1,4 @@ """Support for repeating alerts when conditions are met.""" -import asyncio from datetime import timedelta import logging @@ -144,9 +143,8 @@ async def async_handle_alert_service(service_call): DOMAIN, SERVICE_TOGGLE, async_handle_alert_service, schema=ALERT_SERVICE_SCHEMA ) - tasks = [alert.async_update_ha_state() for alert in entities] - if tasks: - await asyncio.wait(tasks) + for alert in entities: + alert.async_write_ha_state() return True @@ -318,13 +316,13 @@ async def async_turn_on(self, **kwargs): """Async Unacknowledge alert.""" _LOGGER.debug("Reset Alert: %s", self._name) self._ack = False - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Async Acknowledge alert.""" _LOGGER.debug("Acknowledged Alert: %s", self._name) self._ack = True - await self.async_update_ha_state() + self.async_write_ha_state() async def async_toggle(self, **kwargs): """Async toggle alert.""" diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index f4efd0de355265..b7df82961c7962 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -22,6 +22,10 @@ ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) _LOGGER = logging.getLogger(__name__) @@ -60,7 +64,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= def async_anthemav_update_callback(message): """Receive notification from transport that new data exists.""" _LOGGER.debug("Received update callback from AVR: %s", message) - hass.async_create_task(device.async_update_ha_state()) + async_dispatcher_send(hass, DOMAIN) avr = await anthemav.Connection.create( host=host, port=port, update_callback=async_anthemav_update_callback @@ -87,6 +91,12 @@ def __init__(self, avr, name): def _lookup(self, propname, dval=None): return getattr(self.avr.protocol, propname, dval) + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) + ) + @property def supported_features(self): """Flag media player features that are supported.""" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index c19a0033f86336..76fe619cc1cc64 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -389,7 +389,7 @@ async def async_trigger(self, variables, skip_condition=False, context=None): pass self._last_triggered = utcnow() - await self.async_update_ha_state() + self.async_write_ha_state() async def async_will_remove_from_hass(self): """Remove listeners when removing automation from Home Assistant.""" diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 5d709ab1e63f6f..afa5013b339e26 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -33,6 +33,7 @@ TIME_HOURS, UNIT_PERCENTAGE, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util @@ -260,7 +261,14 @@ def uid(self, coordinates): self.type, ) - def load_data(self, data): + @callback + def data_updated(self, data): + """Update data.""" + if self._load_data(data) and self.hass: + self.async_write_ha_state() + + @callback + def _load_data(self, data): """Load the sensor with relevant data.""" # Find sensor diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 7d16f072b98e4e..900b1caaf9797e 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -67,15 +67,12 @@ def __init__(self, hass, coordinates, timeframe, devices): async def update_devices(self): """Update all devices/sensors.""" - if self.devices: - tasks = [] - # Update all devices - for dev in self.devices: - if dev.load_data(self.data): - tasks.append(dev.async_update_ha_state()) - - if tasks: - await asyncio.wait(tasks) + if not self.devices: + return + + # Update all devices + for dev in self.devices: + dev.data_updated(self.data) async def schedule_update(self, minute=1): """Schedule an update after minute minutes.""" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 6bbf30b000e9dd..0fc3e55a5876da 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -271,7 +271,7 @@ def update_tokens(time): """Update tokens of the entities.""" for entity in component.entities: entity.async_update_token() - hass.async_create_task(entity.async_update_ha_state()) + entity.async_write_ha_state() hass.helpers.event.async_track_time_interval(update_tokens, TOKEN_CHANGE_INTERVAL) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 515b7cbc6141db..3157a00aa9f085 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -175,7 +175,7 @@ async def async_see( consider_home, ) if device.track: - await device.async_update_ha_state() + device.async_write_ha_state() return # Guard from calling see on entity registry entities. @@ -212,7 +212,7 @@ async def async_see( ) if device.track: - await device.async_update_ha_state() + device.async_write_ha_state() self.hass.bus.async_fire( EVENT_NEW_DEVICE, @@ -259,7 +259,7 @@ async def async_setup_tracked_device(self): async def async_init_single_device(dev): """Init a single device_tracker entity.""" await dev.async_added_to_hass() - await dev.async_update_ha_state() + dev.async_write_ha_state() tasks = [] for device in self.devices.values(): diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 257407bb763870..484bd708489caf 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -15,8 +15,12 @@ EVENT_HOMEASSISTANT_STOP, TIME_HOURS, ) -from homeassistant.core import CoreState -import homeassistant.helpers.config_validation as cv +from homeassistant.core import CoreState, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -109,12 +113,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(devices) - def update_entities_telegram(telegram): - """Update entities with latest telegram and trigger state update.""" - # Make all device entities aware of new telegram - for device in devices: - device.telegram = telegram - hass.async_create_task(device.async_update_ha_state()) + update_entities_telegram = partial(async_dispatcher_send, hass, DOMAIN) # Creates an asyncio.Protocol factory for reading DSMR telegrams from # serial and calls update_entities_telegram to update entities on arrival @@ -188,6 +187,18 @@ def __init__(self, name, obis, config): self._config = config self.telegram = {} + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.update_data) + ) + + @callback + def update_data(self, telegram): + """Update data.""" + self.telegram = telegram + self.async_write_ha_state() + def get_dsmr_object_attr(self, attribute): """Read attribute from last received telegram for this DSMR object.""" # Make sure telegram contains an object for this entities obis diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 09af5ad5c44fb1..9a8d6177214861 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -334,7 +334,7 @@ async def async_set_temperature(self, **kwargs): return self._target_temp = temperature await self._async_control_heating(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() @property def min_temp(self): @@ -361,7 +361,7 @@ async def _async_sensor_changed(self, entity_id, old_state, new_state): self._async_update_temp(new_state) await self._async_control_heating() - await self.async_update_ha_state() + self.async_write_ha_state() @callback def _async_switch_changed(self, entity_id, old_state, new_state): @@ -468,4 +468,4 @@ async def async_set_preset_mode(self, preset_mode: str): self._target_temp = self._saved_target_temp await self._async_control_heating(force=True) - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index f8a10017cab1af..53ce1c0634b9d3 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -289,7 +289,7 @@ async def groups_service_handler(service): need_update = True if need_update: - await group.async_update_ha_state() + group.async_write_ha_state() return @@ -538,7 +538,7 @@ async def _async_state_changed_listener(self, entity_id, old_state, new_state): return self._async_update_group_state(new_state) - await self.async_update_ha_state() + self.async_write_ha_state() @property def _tracking_states(self): diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py index ceef8acf7c3f27..a824d5f8ee9c0e 100644 --- a/homeassistant/components/imap/sensor.py +++ b/homeassistant/components/imap/sensor.py @@ -130,7 +130,7 @@ async def idle_loop(self): try: if await self.connection(): await self.refresh_email_count() - await self.async_update_ha_state() + self.async_write_ha_state() idle = await self._connection.idle_start() await self._connection.wait_server_push() @@ -138,7 +138,7 @@ async def idle_loop(self): with async_timeout.timeout(10): await idle else: - await self.async_update_ha_state() + self.async_write_ha_state() except (AioImapException, asyncio.TimeoutError): self.disconnected() diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 603aa82612301a..88fe94eac48a01 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -198,12 +198,12 @@ async def async_added_to_hass(self): async def async_turn_on(self, **kwargs): """Turn the entity on.""" self._state = True - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" self._state = False - await self.async_update_ha_state() + self.async_write_ha_state() async def async_update_config(self, config: typing.Dict) -> None: """Handle when the config is updated.""" diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 94a171d9c2a75e..95e7e2cc400f7a 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -116,7 +116,7 @@ def async_register_callbacks(self): async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 554ae59f3972c4..919a20d0e4c756 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -210,7 +210,7 @@ async def async_added_to_hass(self) -> None: async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.device.register_device_updated_cb(after_update_callback) self.device.mode.register_device_updated_cb(after_update_callback) @@ -266,7 +266,7 @@ async def async_set_temperature(self, **kwargs) -> None: if temperature is None: return await self.device.set_target_temperature(temperature) - await self.async_update_ha_state() + self.async_write_ha_state() @property def hvac_mode(self) -> Optional[str]: @@ -304,7 +304,7 @@ async def async_set_hvac_mode(self, hvac_mode: str) -> None: elif self.device.mode.supports_operation_mode: knx_operation_mode = HVACOperationMode(OPERATION_MODES_INV.get(hvac_mode)) await self.device.mode.set_operation_mode(knx_operation_mode) - await self.async_update_ha_state() + self.async_write_ha_state() @property def preset_mode(self) -> Optional[str]: @@ -334,4 +334,4 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if self.device.mode.supports_operation_mode: knx_operation_mode = HVACOperationMode(PRESET_MODES_INV.get(preset_mode)) await self.device.mode.set_operation_mode(knx_operation_mode) - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 3c67e2fd55895c..5c4aa762b5c235 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -108,7 +108,7 @@ def async_register_callbacks(self): async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 6eb539c19ce41b..efd1a74f5a2ec9 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -154,7 +154,7 @@ def async_register_callbacks(self): async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index a0a0f6ea18d016..2679170b03dd8d 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -69,7 +69,7 @@ def async_register_callbacks(self): async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index e9a0df5c983688..ae798bf4c08237 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -65,7 +65,7 @@ def async_register_callbacks(self): async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index ba831c3a1a92bc..61ae05fa010d8f 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -74,21 +74,21 @@ async def async_close_cover(self, **kwargs): state = pypck.lcn_defs.MotorStateModifier.DOWN self.address_connection.control_motors_outputs(state) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_open_cover(self, **kwargs): """Open the cover.""" self._closed = False state = pypck.lcn_defs.MotorStateModifier.UP self.address_connection.control_motors_outputs(state, self.reverse_time) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_stop_cover(self, **kwargs): """Stop the cover.""" self._closed = None state = pypck.lcn_defs.MotorStateModifier.STOP self.address_connection.control_motors_outputs(state, self.reverse_time) - await self.async_update_ha_state() + self.async_write_ha_state() def input_received(self, input_obj): """Set cover states when LCN input object (command) is received.""" @@ -140,7 +140,7 @@ async def async_close_cover(self, **kwargs): states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN self.address_connection.control_motors_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_open_cover(self, **kwargs): """Open the cover.""" @@ -148,7 +148,7 @@ async def async_open_cover(self, **kwargs): states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP self.address_connection.control_motors_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_stop_cover(self, **kwargs): """Stop the cover.""" @@ -156,7 +156,7 @@ async def async_stop_cover(self, **kwargs): states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP self.address_connection.control_motors_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() def input_received(self, input_obj): """Set cover states when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 40a592f89c92b4..7f1cd547c02c93 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -102,7 +102,7 @@ async def async_turn_on(self, **kwargs): transition = self._transition self.address_connection.dim_output(self.output.value, percent, transition) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" @@ -117,7 +117,7 @@ async def async_turn_off(self, **kwargs): self._is_dimming_to_zero = bool(transition) self.address_connection.dim_output(self.output.value, 0, transition) - await self.async_update_ha_state() + self.async_write_ha_state() def input_received(self, input_obj): """Set light state when LCN input object (command) is received.""" @@ -164,7 +164,7 @@ async def async_turn_on(self, **kwargs): states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON self.address_connection.control_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" @@ -174,7 +174,7 @@ async def async_turn_off(self, **kwargs): states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF self.address_connection.control_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() def input_received(self, input_obj): """Set light state when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index b0e16e412b325d..a2adda95b3b0ef 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -59,13 +59,13 @@ async def async_turn_on(self, **kwargs): """Turn the entity on.""" self._is_on = True self.address_connection.dim_output(self.output.value, 100, 0) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" self._is_on = False self.address_connection.dim_output(self.output.value, 0, 0) - await self.async_update_ha_state() + self.async_write_ha_state() def input_received(self, input_obj): """Set switch state when LCN input object (command) is received.""" @@ -107,7 +107,7 @@ async def async_turn_on(self, **kwargs): states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON self.address_connection.control_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" @@ -116,7 +116,7 @@ async def async_turn_off(self, **kwargs): states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF self.address_connection.control_relays(states) - await self.async_update_ha_state() + self.async_write_ha_state() def input_received(self, input_obj): """Set switch state when LCN input object (command) is received.""" diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 5bc0c1bc53b1ac..e5bbe88edfb846 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -435,7 +435,7 @@ def unregister(self, bulb): entity = self.entities[bulb.mac_addr] _LOGGER.debug("%s unregister", entity.who) entity.registered = False - self.hass.async_create_task(entity.async_update_ha_state()) + entity.async_write_ha_state() class AwaitAioLIFX: @@ -573,7 +573,7 @@ async def update_hass(self, now=None): """Request new status and push it to hass.""" self.postponed_update = None await self.async_update() - await self.async_update_ha_state() + self.async_write_ha_state() async def update_during_transition(self, when): """Update state at the start and end of a transition.""" diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 1bc9c116cda036..16b25e4f85de46 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -96,7 +96,7 @@ async def async_create_group(service): face.store[g_id] = {} entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name) - await entities[g_id].async_update_ha_state() + entities[g_id].async_write_ha_state() except HomeAssistantError as err: _LOGGER.error("Can't create group '%s' with error: %s", g_id, err) @@ -145,7 +145,7 @@ async def async_create_person(service): ) face.store[g_id][name] = user_data["personId"] - await entities[g_id].async_update_ha_state() + entities[g_id].async_write_ha_state() except HomeAssistantError as err: _LOGGER.error("Can't create person '%s' with error: %s", name, err) @@ -163,7 +163,7 @@ async def async_delete_person(service): await face.call_api("delete", f"persongroups/{g_id}/persons/{p_id}") face.store[g_id].pop(name) - await entities[g_id].async_update_ha_state() + entities[g_id].async_write_ha_state() except HomeAssistantError as err: _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err) diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 4c9b6343f2b905..6baa3a63f9fad4 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -92,7 +92,6 @@ class NetioApiView(HomeAssistantView): @callback def get(self, request, host): """Request handler.""" - hass = request.app["hass"] data = request.query states, consumptions, cumulated_consumptions, start_dates = [], [], [], [] @@ -121,7 +120,7 @@ def get(self, request, host): ndev.start_dates = start_dates for dev in DEVICES[host].entities: - hass.async_create_task(dev.async_update_ha_state()) + dev.async_write_ha_state() return self.json(True) diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 42f1f62d10a965..8814c8968a0320 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -314,7 +314,7 @@ async def async_handle_waypoint(hass, name_base, waypoint): ) zone.hass = hass zone.entity_id = entity_id - await zone.async_update_ha_state() + zone.async_write_ha_state() @HANDLERS.register("waypoint") diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index b33f2623b9d6df..76b225bd93ac90 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -498,7 +498,7 @@ async def _async_handle_command(self, command, *args): await self._async_send_command(cmd, self._signal_repetitions) # Update state of entity - await self.async_update_ha_state() + self.async_write_ha_state() def cancel_queued_send_commands(self): """Cancel queued signal repetition commands. diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 55c2371aabbf0b..fb0df36d4cb48a 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -1,5 +1,4 @@ """SAJ solar inverter interface.""" -import asyncio from datetime import date import logging @@ -100,8 +99,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_saj(): """Update all the SAJ sensors.""" - tasks = [] - values = await saj.read(sensor_def) for sensor in hass_sensors: @@ -118,11 +115,8 @@ async def async_saj(): not sensor.per_day_basis and not sensor.per_total_basis ): state_unknown = True - task = sensor.async_update_values(unknown_state=state_unknown) - if task: - tasks.append(task) - if tasks: - await asyncio.wait(tasks) + sensor.async_update_values(unknown_state=state_unknown) + return values def start_update_interval(event): @@ -237,7 +231,8 @@ def async_update_values(self, unknown_state=False): update = True self._state = None - return self.async_update_ha_state() if update else None + if update: + self.async_write_ha_state() @property def unique_id(self): diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 9384c58db81783..6efd4c849aa5c5 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -243,7 +243,7 @@ def __init__(self, hass, object_id, name, icon, sequence): self.icon = icon self.entity_id = ENTITY_ID_FORMAT.format(object_id) self.script = Script( - hass, sequence, name, self.async_update_ha_state, logger=_LOGGER + hass, sequence, name, self.async_write_ha_state, logger=_LOGGER ) @property diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 40ec4179cd1cee..e5afa272c40ee9 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -1,5 +1,4 @@ """SMA Solar Webconnect interface.""" -import asyncio from datetime import timedelta import logging @@ -163,13 +162,8 @@ async def async_sma(event): return backoff_step = 0 - tasks = [] for sensor in hass_sensors: - task = sensor.async_update_values() - if task: - tasks.append(task) - if tasks: - await asyncio.wait(tasks) + sensor.async_update_values() interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=5) async_track_time_interval(hass, async_sma, interval) @@ -226,7 +220,8 @@ def async_update_values(self): update = True self._state = self._sensor.value - return self.async_update_ha_state() if update else None + if update: + self.async_write_ha_state() @property def unique_id(self): diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 27a81b2a667d5b..d11ff84a73ca1a 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -159,21 +159,21 @@ async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) self._volume = volume.volume self._is_muted = volume.mute - await self.async_update_ha_state() + self.async_write_ha_state() async def _source_changed(content: ContentChange): _LOGGER.debug("Source changed: %s", content) if content.is_input: self._active_source = self._sources[content.source] _LOGGER.debug("New active source: %s", self._active_source) - await self.async_update_ha_state() + self.async_write_ha_state() else: _LOGGER.debug("Got non-handled content change: %s", content) async def _power_changed(power: PowerChange): _LOGGER.debug("Power changed: %s", power) self._state = power.status - await self.async_update_ha_state() + self.async_write_ha_state() async def _try_reconnect(connect: ConnectChange): _LOGGER.error( @@ -181,7 +181,7 @@ async def _try_reconnect(connect: ConnectChange): ) self._available = False self.dev.clear_notification_callbacks() - await self.async_update_ha_state() + self.async_write_ha_state() # Try to reconnect forever, a successful reconnect will initialize # the websocket connection again. diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index ef9d9b1ddcee90..24bfd77f762fa4 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -184,11 +184,11 @@ async def async_select_tariff(self, tariff): ) return self._current_tariff = tariff - await self.async_update_ha_state() + self.async_write_ha_state() async def async_next_tariff(self): """Offset current index.""" current_index = self._tariffs.index(self._current_tariff) new_index = (current_index + 1) % len(self._tariffs) self._current_tariff = self._tariffs[new_index] - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index ad1b27f1fe09e9..c891c698cf6f61 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -217,7 +217,7 @@ async def async_reset_meter(self, entity_id): self._last_reset = dt_util.now() self._last_period = str(self._state) self._state = 0 - await self.async_update_ha_state() + self.async_write_ha_state() async def async_calibrate(self, value): """Calibrate the Utility Meter with a given value.""" @@ -253,7 +253,7 @@ async def async_added_to_hass(self): self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._last_period = state.attributes.get(ATTR_LAST_PERIOD) self._last_reset = state.attributes.get(ATTR_LAST_RESET) - await self.async_update_ha_state() + self.async_write_ha_state() if state.attributes.get(ATTR_STATUS) == PAUSED: # Fake cancellation function to init the meter paused self._collecting = lambda: None diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c9b4aa53fe5233..fe5b1dcf3afca3 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -38,7 +38,7 @@ def async_register_callbacks(self): async def after_update_callback(device): """Call after device was updated.""" - await self.async_update_ha_state() + self.async_write_ha_state() self.node.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py index c6aaeea7ac9a46..9955a650cd3684 100644 --- a/homeassistant/components/yr/sensor.py +++ b/homeassistant/components/yr/sensor.py @@ -96,11 +96,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= dev = [] for sensor_type in config[CONF_MONITORED_CONDITIONS]: dev.append(YrSensor(name, sensor_type)) - async_add_entities(dev) weather = YrData(hass, coordinates, forecast, dev) - async_track_utc_time_change(hass, weather.updating_devices, minute=31, second=0) + async_track_utc_time_change( + hass, weather.updating_devices, minute=randrange(60), second=0 + ) await weather.fetching_data() + async_add_entities(dev) class YrSensor(Entity): @@ -234,7 +236,6 @@ async def updating_devices(self, *_): ordered_entries.sort(key=lambda item: item[0]) # Update all devices - tasks = [] if ordered_entries: for dev in self.devices: new_state = None @@ -274,7 +275,5 @@ async def updating_devices(self, *_): # pylint: disable=protected-access if new_state != dev._state: dev._state = new_state - tasks.append(dev.async_update_ha_state()) - - if tasks: - await asyncio.wait(tasks) + if dev.hass: + dev.async_write_ha_state() diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 3fb5491ea62632..f1b76075ae8bc7 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -87,7 +87,7 @@ def _schedule_update(self): @callback def do_update(): """Really update.""" - self.hass.async_add_job(self.async_update_ha_state) + self.async_write_ha_state() self._update_scheduled = False self._update_scheduled = True diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 4cbb7a23496c5c..bf6db55a2da4ae 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -444,7 +444,7 @@ async def _async_add_entity( await entity.async_internal_added_to_hass() await entity.async_added_to_hass() - await entity.async_update_ha_state() + entity.async_write_ha_state() async def async_reset(self) -> None: """Remove all entities and reset data. diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 145bb42af5bfe6..c724b9e890d01a 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -36,7 +36,6 @@ Context, HomeAssistant, callback, - is_callback, ) from homeassistant.helpers import ( condition, @@ -679,10 +678,7 @@ def __init__( def _changed(self): if self.change_listener: - if is_callback(self.change_listener): - self.change_listener() - else: - self._hass.async_add_job(self.change_listener) + self._hass.async_run_job(self.change_listener) @property def is_running(self) -> bool: diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 9136b53ff0b3a4..28161cfa18b67c 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -13,6 +13,7 @@ async def test_maybe_schedule_update(hass, mock_openzwave): """Test maybe schedule update.""" base_entity = node_entity.ZWaveBaseEntity() + base_entity.entity_id = "zwave.bla" base_entity.hass = hass with patch.object(hass.loop, "call_later") as mock_call_later: @@ -21,12 +22,12 @@ async def test_maybe_schedule_update(hass, mock_openzwave): base_entity._schedule_update() assert len(mock_call_later.mock_calls) == 1 + assert base_entity._update_scheduled is True do_update = mock_call_later.mock_calls[0][1][1] - with patch.object(hass, "async_add_job") as mock_add_job: - do_update() - assert mock_add_job.called + do_update() + assert base_entity._update_scheduled is False base_entity._schedule_update() assert len(mock_call_later.mock_calls) == 2 diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 306402cd2b9812..1c5224d89c3e3b 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -379,15 +379,16 @@ async def test_update_entity(hass): """Test that we can update an entity with the helper.""" component = EntityComponent(_LOGGER, DOMAIN, hass) entity = MockEntity() + entity.async_write_ha_state = Mock() entity.async_update_ha_state = Mock(return_value=mock_coro()) await component.async_add_entities([entity]) # Called as part of async_add_entities - assert len(entity.async_update_ha_state.mock_calls) == 1 + assert len(entity.async_write_ha_state.mock_calls) == 1 await hass.helpers.entity_component.async_update_entity(entity.entity_id) - assert len(entity.async_update_ha_state.mock_calls) == 2 + assert len(entity.async_update_ha_state.mock_calls) == 1 assert entity.async_update_ha_state.mock_calls[-1][1][0] is True From ae22b5187aaf189e45ff00081e17cf679ce67491 Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Fri, 3 Apr 2020 00:49:50 -0700 Subject: [PATCH 38/44] Add vera config entries support (#29880) * Adding vera config entries support. * Fixing lint error. * Applying minimal changes necessary to get config entries working. * Addressing PR feedback by further reducing the scope of the change. * Addressing PR feedback. * Fixing pyvera import to make it easier to patch. Addressing PR feedback regarding creation of controller and scheduling of async config flow actions. * Updating code owners file. * Small fixes. * Adding a user config flow step. * Adding optional configs for user config flow. * Updating strings to be more clear to the user. * Adding options flow. Fixing some PR feedback. * Better handling of options. PR feedback changes. * Using config registry to update config options. * Better managing config from file or config from UI Disabling config through UI if config is provided from a file. More tests to account for these adjustments. * Address PR feedback. * Fixing test, merging with master. * Disabling all Vera UI for configs managed by configuration.yml. Adding more tests. * Updating config based on unique_id. Addressing additional PR feedback. * Rebasing off dev. Addressing feedback. * Addressing PR feedback. --- CODEOWNERS | 1 + homeassistant/components/vera/__init__.py | 121 ++++++++----- .../components/vera/binary_sensor.py | 31 +++- homeassistant/components/vera/climate.py | 31 +++- homeassistant/components/vera/common.py | 29 ++++ homeassistant/components/vera/config_flow.py | 130 ++++++++++++++ homeassistant/components/vera/const.py | 11 ++ homeassistant/components/vera/cover.py | 32 +++- homeassistant/components/vera/light.py | 26 ++- homeassistant/components/vera/lock.py | 33 ++-- homeassistant/components/vera/manifest.json | 5 +- homeassistant/components/vera/scene.py | 24 ++- homeassistant/components/vera/sensor.py | 26 ++- homeassistant/components/vera/strings.json | 32 ++++ homeassistant/components/vera/switch.py | 31 +++- homeassistant/generated/config_flows.py | 1 + tests/components/vera/common.py | 124 ++++++++++---- tests/components/vera/conftest.py | 4 +- tests/components/vera/test_binary_sensor.py | 16 +- tests/components/vera/test_climate.py | 28 +-- tests/components/vera/test_config_flow.py | 159 ++++++++++++++++++ tests/components/vera/test_cover.py | 14 +- tests/components/vera/test_init.py | 154 ++++++++++------- tests/components/vera/test_light.py | 14 +- tests/components/vera/test_lock.py | 14 +- tests/components/vera/test_scene.py | 8 +- tests/components/vera/test_sensor.py | 55 +++--- tests/components/vera/test_switch.py | 14 +- 28 files changed, 875 insertions(+), 293 deletions(-) create mode 100644 homeassistant/components/vera/common.py create mode 100644 homeassistant/components/vera/config_flow.py create mode 100644 homeassistant/components/vera/const.py create mode 100644 homeassistant/components/vera/strings.json create mode 100644 tests/components/vera/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 4d4c7d3d900736..5cbf0d411a0f23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -409,6 +409,7 @@ homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes homeassistant/components/velbus/* @Cereal2nd @brefra homeassistant/components/velux/* @Julius2342 +homeassistant/components/vera/* @vangorra homeassistant/components/versasense/* @flamm3blemuff1n homeassistant/components/version/* @fabaff homeassistant/components/vesync/* @markperdue @webdjoe diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index 1c9d412d974ddd..c98833a7daa014 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -1,4 +1,5 @@ """Support for Vera devices.""" +import asyncio from collections import defaultdict import logging @@ -6,6 +7,8 @@ from requests.exceptions import RequestException import voluptuous as vol +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ARMED, ATTR_BATTERY_LEVEL, @@ -15,26 +18,23 @@ CONF_LIGHTS, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import convert, slugify from homeassistant.util.dt import utc_from_timestamp -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "vera" - -VERA_CONTROLLER = "vera_controller" - -CONF_CONTROLLER = "vera_controller_url" - -VERA_ID_FORMAT = "{}_{}" - -ATTR_CURRENT_POWER_W = "current_power_w" -ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" +from .common import ControllerData, get_configured_platforms +from .config_flow import new_options +from .const import ( + ATTR_CURRENT_ENERGY_KWH, + ATTR_CURRENT_POWER_W, + CONF_CONTROLLER, + DOMAIN, + VERA_ID_FORMAT, +) -VERA_DEVICES = "vera_devices" -VERA_SCENES = "vera_scenes" +_LOGGER = logging.getLogger(__name__) VERA_ID_LIST_SCHEMA = vol.Schema([int]) @@ -51,42 +51,53 @@ extra=vol.ALLOW_EXTRA, ) -VERA_COMPONENTS = [ - "binary_sensor", - "sensor", - "light", - "switch", - "lock", - "climate", - "cover", - "scene", -] +async def async_setup(hass: HomeAssistant, base_config: dict) -> bool: + """Set up for Vera controllers.""" + config = base_config.get(DOMAIN) -def setup(hass, base_config): - """Set up for Vera devices.""" + if not config: + return True - def stop_subscription(event): - """Shutdown Vera subscriptions and subscription thread on exit.""" - _LOGGER.info("Shutting down subscriptions") - hass.data[VERA_CONTROLLER].stop() + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config, + ) + ) - config = base_config.get(DOMAIN) + return True - # Get Vera specific configuration. - base_url = config.get(CONF_CONTROLLER) - light_ids = config.get(CONF_LIGHTS) - exclude_ids = config.get(CONF_EXCLUDE) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Do setup of vera.""" + # Use options entered during initial config flow or provided from configuration.yml + if config_entry.data.get(CONF_LIGHTS) or config_entry.data.get(CONF_EXCLUDE): + hass.config_entries.async_update_entry( + entry=config_entry, + data=config_entry.data, + options=new_options( + config_entry.data.get(CONF_LIGHTS, []), + config_entry.data.get(CONF_EXCLUDE, []), + ), + ) + + base_url = config_entry.data[CONF_CONTROLLER] + light_ids = config_entry.options.get(CONF_LIGHTS, []) + exclude_ids = config_entry.options.get(CONF_EXCLUDE, []) # Initialize the Vera controller. - controller, _ = veraApi.init_controller(base_url) - hass.data[VERA_CONTROLLER] = controller - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) + controller = veraApi.VeraController(base_url) + controller.start() + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, + lambda event: hass.async_add_executor_job(controller.stop), + ) try: - all_devices = controller.get_devices() + all_devices = await hass.async_add_executor_job(controller.get_devices) - all_scenes = controller.get_scenes() + all_scenes = await hass.async_add_executor_job(controller.get_scenes) except RequestException: # There was a network related error connecting to the Vera controller. _LOGGER.exception("Error communicating with Vera API") @@ -102,15 +113,35 @@ def stop_subscription(event): continue vera_devices[device_type].append(device) - hass.data[VERA_DEVICES] = vera_devices vera_scenes = [] for scene in all_scenes: vera_scenes.append(scene) - hass.data[VERA_SCENES] = vera_scenes - for component in VERA_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, base_config) + controller_data = ControllerData( + controller=controller, devices=vera_devices, scenes=vera_scenes + ) + + hass.data[DOMAIN] = controller_data + + # Forward the config data to the necessary platforms. + for platform in get_configured_platforms(controller_data): + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload Withings config entry.""" + controller_data = hass.data[DOMAIN] + + tasks = [ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in get_configured_platforms(controller_data) + ] + await asyncio.gather(*tasks) return True diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 061d2c5c99aef4..621dc09930d99f 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -1,21 +1,34 @@ """Support for Vera binary sensors.""" import logging +from typing import Callable, List -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN as PLATFORM_DOMAIN, + ENTITY_ID_FORMAT, + BinarySensorDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Perform the setup for Vera controller devices.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["binary_sensor"] - ], - True, + VeraBinarySensor(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 60e73d48978cdb..520c3b516df34f 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -1,7 +1,12 @@ """Support for Vera thermostats.""" import logging +from typing import Callable, List -from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateDevice +from homeassistant.components.climate import ( + DOMAIN as PLATFORM_DOMAIN, + ENTITY_ID_FORMAT, + ClimateDevice, +) from homeassistant.components.climate.const import ( FAN_AUTO, FAN_ON, @@ -12,10 +17,14 @@ SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity from homeassistant.util import convert -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -25,14 +34,18 @@ SUPPORT_HVAC = [HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF] -def setup_platform(hass, config, add_entities_callback, discovery_info=None): - """Set up of Vera thermostats.""" - add_entities_callback( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraThermostat(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["climate"] - ], - True, + VeraThermostat(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/components/vera/common.py b/homeassistant/components/vera/common.py new file mode 100644 index 00000000000000..cdfdff404ec911 --- /dev/null +++ b/homeassistant/components/vera/common.py @@ -0,0 +1,29 @@ +"""Common vera code.""" +import logging +from typing import DefaultDict, List, NamedTuple, Set + +import pyvera as pv + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ControllerData(NamedTuple): + """Controller data.""" + + controller: pv.VeraController + devices: DefaultDict[str, List[pv.VeraDevice]] + scenes: List[pv.VeraScene] + + +def get_configured_platforms(controller_data: ControllerData) -> Set[str]: + """Get configured platforms for a controller.""" + platforms = [] + for platform in controller_data.devices: + platforms.append(platform) + + if controller_data.scenes: + platforms.append(SCENE_DOMAIN) + + return set(platforms) diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py new file mode 100644 index 00000000000000..3d2b30f1079fc7 --- /dev/null +++ b/homeassistant/components/vera/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for Vera.""" +import logging +import re +from typing import List, cast + +import pyvera as pv +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE +from homeassistant.core import callback + +from .const import CONF_CONTROLLER, DOMAIN + +LIST_REGEX = re.compile("[^0-9]+") +_LOGGER = logging.getLogger(__name__) + + +def str_to_int_list(data: str) -> List[str]: + """Convert a string to an int list.""" + if isinstance(str, list): + return cast(List[str], data) + + return [s for s in LIST_REGEX.split(data) if len(s) > 0] + + +def int_list_to_str(data: List[str]) -> str: + """Convert an int list to a string.""" + return " ".join([str(i) for i in data]) + + +def new_options(lights: List[str], exclude: List[str]) -> dict: + """Create a standard options object.""" + return {CONF_LIGHTS: lights, CONF_EXCLUDE: exclude} + + +def options_schema(options: dict = None) -> dict: + """Return options schema.""" + options = options or {} + return { + vol.Optional( + CONF_LIGHTS, default=int_list_to_str(options.get(CONF_LIGHTS, [])), + ): str, + vol.Optional( + CONF_EXCLUDE, default=int_list_to_str(options.get(CONF_EXCLUDE, [])), + ): str, + } + + +def options_data(user_input: dict) -> dict: + """Return options dict.""" + return new_options( + str_to_int_list(user_input.get(CONF_LIGHTS, "")), + str_to_int_list(user_input.get(CONF_EXCLUDE, "")), + ) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Init object.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=options_data(user_input),) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(options_schema(self.config_entry.options)), + ) + + +class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Vera config flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> OptionsFlowHandler: + """Get the options flow.""" + return OptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input: dict = None): + """Handle user initiated flow.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_configured") + + if user_input is not None: + return await self.async_step_finish( + { + **user_input, + **options_data(user_input), + **{CONF_SOURCE: config_entries.SOURCE_USER}, + } + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {**{vol.Required(CONF_CONTROLLER): str}, **options_schema()} + ), + ) + + async def async_step_import(self, config: dict): + """Handle a flow initialized by import.""" + return await self.async_step_finish( + {**config, **{CONF_SOURCE: config_entries.SOURCE_IMPORT}} + ) + + async def async_step_finish(self, config: dict): + """Validate and create config entry.""" + base_url = config[CONF_CONTROLLER] = config[CONF_CONTROLLER].rstrip("/") + controller = pv.VeraController(base_url) + + # Verify the controller is online and get the serial number. + try: + await self.hass.async_add_executor_job(controller.refresh_data) + except RequestException: + _LOGGER.error("Failed to connect to vera controller %s", base_url) + return self.async_abort( + reason="cannot_connect", description_placeholders={"base_url": base_url} + ) + + await self.async_set_unique_id(controller.serial_number) + self._abort_if_unique_id_configured(config) + + return self.async_create_entry(title=base_url, data=config) diff --git a/homeassistant/components/vera/const.py b/homeassistant/components/vera/const.py new file mode 100644 index 00000000000000..c4f1d0efa3a0b2 --- /dev/null +++ b/homeassistant/components/vera/const.py @@ -0,0 +1,11 @@ +"""Vera constants.""" +DOMAIN = "vera" + +CONF_CONTROLLER = "vera_controller_url" + +VERA_ID_FORMAT = "{}_{}" + +ATTR_CURRENT_POWER_W = "current_power_w" +ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" + +CONTROLLER_DATAS = "controller_datas" diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index b90dd8a053112d..0d0edb841c1ec0 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -1,21 +1,35 @@ """Support for Vera cover - curtains, rollershutters etc.""" import logging +from typing import Callable, List -from homeassistant.components.cover import ATTR_POSITION, ENTITY_ID_FORMAT, CoverDevice +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as PLATFORM_DOMAIN, + ENTITY_ID_FORMAT, + CoverDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Vera covers.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraCover(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["cover"] - ], - True, + VeraCover(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index fee992356816e3..877fdf51f0a48d 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -1,29 +1,39 @@ """Support for Vera lights.""" import logging +from typing import Callable, List from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, + DOMAIN as PLATFORM_DOMAIN, ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity import homeassistant.util.color as color_util -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Vera lights.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraLight(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["light"] - ], - True, + VeraLight(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 23b62bb0331466..da3c432a6afa91 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -1,10 +1,19 @@ """Support for Vera locks.""" import logging - -from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice +from typing import Callable, List + +from homeassistant.components.lock import ( + DOMAIN as PLATFORM_DOMAIN, + ENTITY_ID_FORMAT, + LockDevice, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -12,14 +21,18 @@ ATTR_LOW_BATTERY = "low_battery" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Find and return Vera locks.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraLock(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["lock"] - ], - True, + VeraLock(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/components/vera/manifest.json b/homeassistant/components/vera/manifest.json index 63102c29687862..4f585d964a86a7 100644 --- a/homeassistant/components/vera/manifest.json +++ b/homeassistant/components/vera/manifest.json @@ -1,8 +1,11 @@ { "domain": "vera", "name": "Vera", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vera", "requirements": ["pyvera==0.3.7"], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@vangorra" + ] } diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index af5266ed4b3d7b..7d09e248893881 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -1,22 +1,30 @@ """Support for Vera scenes.""" import logging +from typing import Callable, List from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -from . import VERA_CONTROLLER, VERA_ID_FORMAT, VERA_SCENES +from .const import DOMAIN, VERA_ID_FORMAT _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Vera scenes.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraScene(scene, hass.data[VERA_CONTROLLER]) - for scene in hass.data[VERA_SCENES] - ], - True, + VeraScene(device, controller_data.controller) + for device in controller_data.scenes + ] ) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 9ac0a36ff9cd00..60ebeeb156655e 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -1,29 +1,37 @@ """Support for Vera sensors.""" from datetime import timedelta import logging +from typing import Callable, List import pyvera as veraApi -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN, ENTITY_ID_FORMAT +from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.util import convert -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Vera controller devices.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraSensor(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["sensor"] - ], - True, + VeraSensor(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/components/vera/strings.json b/homeassistant/components/vera/strings.json new file mode 100644 index 00000000000000..d8dec2c40cfa70 --- /dev/null +++ b/homeassistant/components/vera/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "Vera", + "abort": { + "already_configured": "A controller is already configured.", + "cannot_connect": "Could not connect to controller with url {base_url}" + }, + "step": { + "user": { + "title": "Setup Vera controller", + "description": "Provide a Vera controller url below. It should look like this: http://192.168.1.161:3480.", + "data": { + "vera_controller_url": "Controller URL", + "lights": "Vera switch device ids to treat as lights in Home Assistant.", + "exclude": "Vera device ids to exclude from Home Assistant." + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Vera controller options", + "description": "See the vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here will need a restart to the home assistant server. To clear values, provide a space.", + "data": { + "lights": "Vera switch device ids to treat as lights in Home Assistant.", + "exclude": "Vera device ids to exclude from Home Assistant." + } + } + } + } +} diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index ab3c3e6adb9555..a7ae6d45573e41 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -1,22 +1,35 @@ """Support for Vera switches.""" import logging +from typing import Callable, List -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.switch import ( + DOMAIN as PLATFORM_DOMAIN, + ENTITY_ID_FORMAT, + SwitchDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity from homeassistant.util import convert -from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice +from . import VeraDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Vera switches.""" - add_entities( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up the sensor config entry.""" + controller_data = hass.data[DOMAIN] + async_add_entities( [ - VeraSwitch(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]["switch"] - ], - True, + VeraSwitch(device, controller_data.controller) + for device in controller_data.devices.get(PLATFORM_DOMAIN) + ] ) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1584d342db4f59..e00cd1b5936b34 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -122,6 +122,7 @@ "unifi", "upnp", "velbus", + "vera", "vesync", "vilfo", "vizio", diff --git a/tests/components/vera/common.py b/tests/components/vera/common.py index 649cf9af6a54e8..5574c93c515d09 100644 --- a/tests/components/vera/common.py +++ b/tests/components/vera/common.py @@ -1,47 +1,91 @@ """Common code for tests.""" -from typing import Callable, NamedTuple, Tuple +from typing import Callable, Dict, NamedTuple, Tuple from mock import MagicMock -from pyvera import VeraController, VeraDevice, VeraScene +import pyvera as pv -from homeassistant.components.vera import CONF_CONTROLLER, DOMAIN +from homeassistant.components.vera.const import CONF_CONTROLLER, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry -class ComponentData(NamedTuple): - """Component data.""" +SetupCallback = Callable[[pv.VeraController, dict], None] + + +class ControllerData(NamedTuple): + """Test data about a specific vera controller.""" + + controller: pv.VeraController + update_callback: Callable - controller: VeraController + +class ComponentData(NamedTuple): + """Test data about the vera component.""" + + controller_data: ControllerData + + +class ControllerConfig(NamedTuple): + """Test config for mocking a vera controller.""" + + config: Dict + options: Dict + config_from_file: bool + serial_number: str + devices: Tuple[pv.VeraDevice, ...] + scenes: Tuple[pv.VeraScene, ...] + setup_callback: SetupCallback + + +def new_simple_controller_config( + config: dict = None, + options: dict = None, + config_from_file=False, + serial_number="1111", + devices: Tuple[pv.VeraDevice, ...] = (), + scenes: Tuple[pv.VeraScene, ...] = (), + setup_callback: SetupCallback = None, +) -> ControllerConfig: + """Create simple contorller config.""" + return ControllerConfig( + config=config or {CONF_CONTROLLER: "http://127.0.0.1:123"}, + options=options, + config_from_file=config_from_file, + serial_number=serial_number, + devices=devices, + scenes=scenes, + setup_callback=setup_callback, + ) class ComponentFactory: """Factory class.""" - def __init__(self, init_controller_mock): - """Initialize component factory.""" - self.init_controller_mock = init_controller_mock + def __init__(self, vera_controller_class_mock): + """Initialize the factory.""" + self.vera_controller_class_mock = vera_controller_class_mock async def configure_component( - self, - hass: HomeAssistant, - devices: Tuple[VeraDevice] = (), - scenes: Tuple[VeraScene] = (), - setup_callback: Callable[[VeraController], None] = None, + self, hass: HomeAssistant, controller_config: ControllerConfig ) -> ComponentData: """Configure the component with specific mock data.""" - controller_url = "http://127.0.0.1:123" - - hass_config = { - DOMAIN: {CONF_CONTROLLER: controller_url}, + component_config = { + **(controller_config.config or {}), + **(controller_config.options or {}), } - controller = MagicMock(spec=VeraController) # type: VeraController - controller.base_url = controller_url + controller = MagicMock(spec=pv.VeraController) # type: pv.VeraController + controller.base_url = component_config.get(CONF_CONTROLLER) controller.register = MagicMock() - controller.get_devices = MagicMock(return_value=devices or ()) - controller.get_scenes = MagicMock(return_value=scenes or ()) + controller.start = MagicMock() + controller.stop = MagicMock() + controller.refresh_data = MagicMock() + controller.temperature_units = "C" + controller.serial_number = controller_config.serial_number + controller.get_devices = MagicMock(return_value=controller_config.devices) + controller.get_scenes = MagicMock(return_value=controller_config.scenes) for vera_obj in controller.get_devices() + controller.get_scenes(): vera_obj.vera_controller = controller @@ -49,17 +93,39 @@ async def configure_component( controller.get_devices.reset_mock() controller.get_scenes.reset_mock() - if setup_callback: - setup_callback(controller, hass_config) + if controller_config.setup_callback: + controller_config.setup_callback(controller) + + self.vera_controller_class_mock.return_value = controller - def init_controller(base_url: str) -> list: - nonlocal controller - return [controller, True] + hass_config = {} - self.init_controller_mock.side_effect = init_controller + # Setup component through config file import. + if controller_config.config_from_file: + hass_config[DOMAIN] = component_config # Setup Home Assistant. assert await async_setup_component(hass, DOMAIN, hass_config) await hass.async_block_till_done() - return ComponentData(controller=controller) + # Setup component through config flow. + if not controller_config.config_from_file: + entry = MockConfigEntry( + domain=DOMAIN, data=component_config, options={}, unique_id="12345" + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + update_callback = ( + controller.register.call_args_list[0][0][1] + if controller.register.call_args_list + else None + ) + + return ComponentData( + controller_data=ControllerData( + controller=controller, update_callback=update_callback + ) + ) diff --git a/tests/components/vera/conftest.py b/tests/components/vera/conftest.py index b94a40135d8b09..2c15d3e4182fbe 100644 --- a/tests/components/vera/conftest.py +++ b/tests/components/vera/conftest.py @@ -9,5 +9,5 @@ @pytest.fixture() def vera_component_factory(): """Return a factory for initializing the vera component.""" - with patch("pyvera.init_controller") as init_controller_mock: - yield ComponentFactory(init_controller_mock) + with patch("pyvera.VeraController") as vera_controller_class_mock: + yield ComponentFactory(vera_controller_class_mock) diff --git a/tests/components/vera/test_binary_sensor.py b/tests/components/vera/test_binary_sensor.py index 2c2e2b8638818a..72651d6eda4f74 100644 --- a/tests/components/vera/test_binary_sensor.py +++ b/tests/components/vera/test_binary_sensor.py @@ -1,38 +1,36 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import VeraBinarySensor +import pyvera as pv from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_binary_sensor( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraBinarySensor) # type: VeraBinarySensor + vera_device = MagicMock(spec=pv.VeraBinarySensor) # type: pv.VeraBinarySensor vera_device.device_id = 1 + vera_device.vera_device_id = 1 vera_device.name = "dev1" vera_device.is_tripped = False entity_id = "binary_sensor.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,) + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback vera_device.is_tripped = False update_callback(vera_device) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "off" - controller.register.reset_mock() vera_device.is_tripped = True update_callback(vera_device) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "on" - controller.register.reset_mock() diff --git a/tests/components/vera/test_climate.py b/tests/components/vera/test_climate.py index c27a72865fd995..9e5fa983ed053f 100644 --- a/tests/components/vera/test_climate.py +++ b/tests/components/vera/test_climate.py @@ -1,7 +1,7 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import CATEGORY_THERMOSTAT, VeraController, VeraThermostat +import pyvera as pv from homeassistant.components.climate.const import ( FAN_AUTO, @@ -13,17 +13,17 @@ ) from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_climate( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraThermostat) # type: VeraThermostat + vera_device = MagicMock(spec=pv.VeraThermostat) # type: pv.VeraThermostat vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_THERMOSTAT + vera_device.category = pv.CATEGORY_THERMOSTAT vera_device.power = 10 vera_device.get_current_temperature.return_value = 71 vera_device.get_hvac_mode.return_value = "Off" @@ -31,10 +31,10 @@ async def test_climate( entity_id = "climate.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback assert hass.states.get(entity_id).state == HVAC_MODE_OFF @@ -123,24 +123,26 @@ async def test_climate_f( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraThermostat) # type: VeraThermostat + vera_device = MagicMock(spec=pv.VeraThermostat) # type: pv.VeraThermostat vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_THERMOSTAT + vera_device.category = pv.CATEGORY_THERMOSTAT vera_device.power = 10 vera_device.get_current_temperature.return_value = 71 vera_device.get_hvac_mode.return_value = "Off" vera_device.get_current_goal_temperature.return_value = 72 entity_id = "climate.dev1_1" - def setup_callback(controller: VeraController, hass_config: dict) -> None: + def setup_callback(controller: pv.VeraController) -> None: controller.temperature_units = "F" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), setup_callback=setup_callback + hass=hass, + controller_config=new_simple_controller_config( + devices=(vera_device,), setup_callback=setup_callback + ), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback await hass.services.async_call( "climate", "set_temperature", {"entity_id": entity_id, "temperature": 30}, diff --git a/tests/components/vera/test_config_flow.py b/tests/components/vera/test_config_flow.py new file mode 100644 index 00000000000000..52ba55b509c8ac --- /dev/null +++ b/tests/components/vera/test_config_flow.py @@ -0,0 +1,159 @@ +"""Vera tests.""" +from unittest.mock import MagicMock + +from mock import patch +from requests.exceptions import RequestException + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.vera import CONF_CONTROLLER, DOMAIN +from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_async_step_user_success(hass: HomeAssistant) -> None: + """Test user step success.""" + with patch("pyvera.VeraController") as vera_controller_class_mock: + controller = MagicMock() + controller.refresh_data = MagicMock() + controller.serial_number = "serial_number_0" + vera_controller_class_mock.return_value = controller + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CONTROLLER: "http://127.0.0.1:123/", + CONF_LIGHTS: "12 13", + CONF_EXCLUDE: "14 15", + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "http://127.0.0.1:123" + assert result["data"] == { + CONF_CONTROLLER: "http://127.0.0.1:123", + CONF_SOURCE: config_entries.SOURCE_USER, + CONF_LIGHTS: ["12", "13"], + CONF_EXCLUDE: ["14", "15"], + } + assert result["result"].unique_id == controller.serial_number + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + + +async def test_async_step_user_already_configured(hass: HomeAssistant) -> None: + """Test user step with entry already configured.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id="12345") + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_import_success(hass: HomeAssistant) -> None: + """Test import step success.""" + with patch("pyvera.VeraController") as vera_controller_class_mock: + controller = MagicMock() + controller.refresh_data = MagicMock() + controller.serial_number = "serial_number_1" + vera_controller_class_mock.return_value = controller + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_CONTROLLER: "http://127.0.0.1:123/"}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "http://127.0.0.1:123" + assert result["data"] == { + CONF_CONTROLLER: "http://127.0.0.1:123", + CONF_SOURCE: config_entries.SOURCE_IMPORT, + } + assert result["result"].unique_id == controller.serial_number + + +async def test_async_step_import_alredy_setup(hass: HomeAssistant) -> None: + """Test import step with entry already setup.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options={}, unique_id="12345") + entry.add_to_hass(hass) + + with patch("pyvera.VeraController") as vera_controller_class_mock: + controller = MagicMock() + controller.refresh_data = MagicMock() + controller.serial_number = "12345" + vera_controller_class_mock.return_value = controller + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_CONTROLLER: "http://localhost:445"}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_async_step_finish_error(hass: HomeAssistant) -> None: + """Test finish step with error.""" + with patch("pyvera.VeraController") as vera_controller_class_mock: + controller = MagicMock() + controller.refresh_data = MagicMock(side_effect=RequestException()) + vera_controller_class_mock.return_value = controller + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_CONTROLLER: "http://127.0.0.1:123/"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert result["description_placeholders"] == { + "base_url": "http://127.0.0.1:123" + } + + +async def test_options(hass): + """Test updating options.""" + base_url = "http://127.0.0.1/" + entry = MockConfigEntry( + domain=DOMAIN, + title=base_url, + data={CONF_CONTROLLER: "http://127.0.0.1/"}, + options={CONF_LIGHTS: [1, 2, 3]}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"}, data=None + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LIGHTS: "1,2;3 4 5_6bb7", + CONF_EXCLUDE: "8,9;10 11 12_13bb14", + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_LIGHTS: ["1", "2", "3", "4", "5", "6", "7"], + CONF_EXCLUDE: ["8", "9", "10", "11", "12", "13", "14"], + } diff --git a/tests/components/vera/test_cover.py b/tests/components/vera/test_cover.py index 79cb4adedfbc92..62cd47f831cdc3 100644 --- a/tests/components/vera/test_cover.py +++ b/tests/components/vera/test_cover.py @@ -1,30 +1,30 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import CATEGORY_CURTAIN, VeraCurtain +import pyvera as pv from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_cover( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraCurtain) # type: VeraCurtain + vera_device = MagicMock(spec=pv.VeraCurtain) # type: pv.VeraCurtain vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_CURTAIN + vera_device.category = pv.CATEGORY_CURTAIN vera_device.is_closed = False vera_device.get_level.return_value = 0 entity_id = "cover.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback assert hass.states.get(entity_id).state == "closed" assert hass.states.get(entity_id).attributes["current_position"] == 0 diff --git a/tests/components/vera/test_init.py b/tests/components/vera/test_init.py index 9ff6cb4058b3c0..a6208726451ba0 100644 --- a/tests/components/vera/test_init.py +++ b/tests/components/vera/test_init.py @@ -1,78 +1,112 @@ """Vera tests.""" -from unittest.mock import MagicMock - -from pyvera import ( - VeraArmableDevice, - VeraBinarySensor, - VeraController, - VeraCurtain, - VeraDevice, - VeraDimmer, - VeraLock, - VeraScene, - VeraSceneController, - VeraSensor, - VeraSwitch, - VeraThermostat, -) - -from homeassistant.components.vera import ( - CONF_EXCLUDE, - CONF_LIGHTS, - DOMAIN, - VERA_DEVICES, -) +from asynctest import MagicMock +import pyvera as pv +from requests.exceptions import RequestException + +from homeassistant.components.vera import CONF_CONTROLLER, DOMAIN +from homeassistant.config_entries import ENTRY_STATE_NOT_LOADED from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config + +from tests.common import MockConfigEntry -def new_vera_device(cls, device_id: int) -> VeraDevice: - """Create new mocked vera device..""" - vera_device = MagicMock(spec=cls) # type: VeraDevice - vera_device.device_id = device_id - vera_device.name = f"dev${device_id}" - return vera_device +async def test_init( + hass: HomeAssistant, vera_component_factory: ComponentFactory +) -> None: + """Test function.""" + vera_device1 = MagicMock(spec=pv.VeraBinarySensor) # type: pv.VeraBinarySensor + vera_device1.device_id = 1 + vera_device1.vera_device_id = 1 + vera_device1.name = "first_dev" + vera_device1.is_tripped = False + entity1_id = "binary_sensor.first_dev_1" + + await vera_component_factory.configure_component( + hass=hass, + controller_config=new_simple_controller_config( + config={CONF_CONTROLLER: "http://127.0.0.1:111"}, + config_from_file=False, + serial_number="first_serial", + devices=(vera_device1,), + ), + ) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + entry1 = entity_registry.async_get(entity1_id) -def assert_hass_vera_devices(hass: HomeAssistant, platform: str, arr_len: int) -> None: - """Assert vera devices are present..""" - assert hass.data[VERA_DEVICES][platform] - assert len(hass.data[VERA_DEVICES][platform]) == arr_len + assert entry1 -async def test_init( +async def test_init_from_file( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - - def setup_callback(controller: VeraController, hass_config: dict) -> None: - hass_config[DOMAIN][CONF_EXCLUDE] = [11] - hass_config[DOMAIN][CONF_LIGHTS] = [10] + vera_device1 = MagicMock(spec=pv.VeraBinarySensor) # type: pv.VeraBinarySensor + vera_device1.device_id = 1 + vera_device1.vera_device_id = 1 + vera_device1.name = "first_dev" + vera_device1.is_tripped = False + entity1_id = "binary_sensor.first_dev_1" await vera_component_factory.configure_component( hass=hass, - devices=( - new_vera_device(VeraDimmer, 1), - new_vera_device(VeraBinarySensor, 2), - new_vera_device(VeraSensor, 3), - new_vera_device(VeraArmableDevice, 4), - new_vera_device(VeraLock, 5), - new_vera_device(VeraThermostat, 6), - new_vera_device(VeraCurtain, 7), - new_vera_device(VeraSceneController, 8), - new_vera_device(VeraSwitch, 9), - new_vera_device(VeraSwitch, 10), - new_vera_device(VeraSwitch, 11), + controller_config=new_simple_controller_config( + config={CONF_CONTROLLER: "http://127.0.0.1:111"}, + config_from_file=True, + serial_number="first_serial", + devices=(vera_device1,), ), - scenes=(MagicMock(spec=VeraScene),), - setup_callback=setup_callback, ) - assert_hass_vera_devices(hass, "light", 2) - assert_hass_vera_devices(hass, "binary_sensor", 1) - assert_hass_vera_devices(hass, "sensor", 2) - assert_hass_vera_devices(hass, "switch", 2) - assert_hass_vera_devices(hass, "lock", 1) - assert_hass_vera_devices(hass, "climate", 1) - assert_hass_vera_devices(hass, "cover", 1) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + entry1 = entity_registry.async_get(entity1_id) + assert entry1 + + +async def test_unload( + hass: HomeAssistant, vera_component_factory: ComponentFactory +) -> None: + """Test function.""" + vera_device1 = MagicMock(spec=pv.VeraBinarySensor) # type: pv.VeraBinarySensor + vera_device1.device_id = 1 + vera_device1.vera_device_id = 1 + vera_device1.name = "first_dev" + vera_device1.is_tripped = False + + await vera_component_factory.configure_component( + hass=hass, controller_config=new_simple_controller_config() + ) + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + + for config_entry in entries: + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state == ENTRY_STATE_NOT_LOADED + + +async def test_async_setup_entry_error( + hass: HomeAssistant, vera_component_factory: ComponentFactory +) -> None: + """Test function.""" + + def setup_callback(controller: pv.VeraController) -> None: + controller.get_devices.side_effect = RequestException() + controller.get_scenes.side_effect = RequestException() + + await vera_component_factory.configure_component( + hass=hass, + controller_config=new_simple_controller_config(setup_callback=setup_callback), + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_CONTROLLER: "http://127.0.0.1"}, + options={}, + unique_id="12345", + ) + entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/vera/test_light.py b/tests/components/vera/test_light.py index fa63ce63454444..fefa07ffa6ee40 100644 --- a/tests/components/vera/test_light.py +++ b/tests/components/vera/test_light.py @@ -1,22 +1,22 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import CATEGORY_DIMMER, VeraDimmer +import pyvera as pv from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_HS_COLOR from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_light( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraDimmer) # type: VeraDimmer + vera_device = MagicMock(spec=pv.VeraDimmer) # type: pv.VeraDimmer vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_DIMMER + vera_device.category = pv.CATEGORY_DIMMER vera_device.is_switched_on = MagicMock(return_value=False) vera_device.get_brightness = MagicMock(return_value=0) vera_device.get_color = MagicMock(return_value=[0, 0, 0]) @@ -24,10 +24,10 @@ async def test_light( entity_id = "light.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback assert hass.states.get(entity_id).state == "off" diff --git a/tests/components/vera/test_lock.py b/tests/components/vera/test_lock.py index 362bdbeddc0fde..d1b2209294a375 100644 --- a/tests/components/vera/test_lock.py +++ b/tests/components/vera/test_lock.py @@ -1,30 +1,30 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import CATEGORY_LOCK, VeraLock +import pyvera as pv from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_lock( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraLock) # type: VeraLock + vera_device = MagicMock(spec=pv.VeraLock) # type: pv.VeraLock vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_LOCK + vera_device.category = pv.CATEGORY_LOCK vera_device.is_locked = MagicMock(return_value=False) entity_id = "lock.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback assert hass.states.get(entity_id).state == STATE_UNLOCKED diff --git a/tests/components/vera/test_scene.py b/tests/components/vera/test_scene.py index 136227ffa7126e..732a331681bdb2 100644 --- a/tests/components/vera/test_scene.py +++ b/tests/components/vera/test_scene.py @@ -1,24 +1,24 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import VeraScene +import pyvera as pv from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_scene( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_scene = MagicMock(spec=VeraScene) # type: VeraScene + vera_scene = MagicMock(spec=pv.VeraScene) # type: pv.VeraScene vera_scene.scene_id = 1 vera_scene.name = "dev1" entity_id = "scene.dev1_1" await vera_component_factory.configure_component( - hass=hass, scenes=(vera_scene,), + hass=hass, controller_config=new_simple_controller_config(scenes=(vera_scene,)), ) await hass.services.async_call( diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py index 9e84815d636230..c915c5ead0fd17 100644 --- a/tests/components/vera/test_sensor.py +++ b/tests/components/vera/test_sensor.py @@ -2,21 +2,12 @@ from typing import Any, Callable, Tuple from unittest.mock import MagicMock -from pyvera import ( - CATEGORY_HUMIDITY_SENSOR, - CATEGORY_LIGHT_SENSOR, - CATEGORY_POWER_METER, - CATEGORY_SCENE_CONTROLLER, - CATEGORY_TEMPERATURE_SENSOR, - CATEGORY_UV_SENSOR, - VeraController, - VeraSensor, -) +import pyvera as pv from homeassistant.const import UNIT_PERCENTAGE from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def run_sensor_test( @@ -26,10 +17,10 @@ async def run_sensor_test( class_property: str, assert_states: Tuple[Tuple[Any, Any]], assert_unit_of_measurement: str = None, - setup_callback: Callable[[VeraController], None] = None, + setup_callback: Callable[[pv.VeraController], None] = None, ) -> None: """Test generic sensor.""" - vera_device = MagicMock(spec=VeraSensor) # type: VeraSensor + vera_device = MagicMock(spec=pv.VeraSensor) # type: pv.VeraSensor vera_device.device_id = 1 vera_device.name = "dev1" vera_device.category = category @@ -37,10 +28,12 @@ async def run_sensor_test( entity_id = "sensor.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), setup_callback=setup_callback + hass=hass, + controller_config=new_simple_controller_config( + devices=(vera_device,), setup_callback=setup_callback + ), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback for (initial_value, state_value) in assert_states: setattr(vera_device, class_property, initial_value) @@ -57,13 +50,13 @@ async def test_temperature_sensor_f( ) -> None: """Test function.""" - def setup_callback(controller: VeraController, hass_config: dict) -> None: + def setup_callback(controller: pv.VeraController) -> None: controller.temperature_units = "F" await run_sensor_test( hass=hass, vera_component_factory=vera_component_factory, - category=CATEGORY_TEMPERATURE_SENSOR, + category=pv.CATEGORY_TEMPERATURE_SENSOR, class_property="temperature", assert_states=(("33", "1"), ("44", "7")), setup_callback=setup_callback, @@ -77,7 +70,7 @@ async def test_temperature_sensor_c( await run_sensor_test( hass=hass, vera_component_factory=vera_component_factory, - category=CATEGORY_TEMPERATURE_SENSOR, + category=pv.CATEGORY_TEMPERATURE_SENSOR, class_property="temperature", assert_states=(("33", "33"), ("44", "44")), ) @@ -90,7 +83,7 @@ async def test_light_sensor( await run_sensor_test( hass=hass, vera_component_factory=vera_component_factory, - category=CATEGORY_LIGHT_SENSOR, + category=pv.CATEGORY_LIGHT_SENSOR, class_property="light", assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement="lx", @@ -104,7 +97,7 @@ async def test_uv_sensor( await run_sensor_test( hass=hass, vera_component_factory=vera_component_factory, - category=CATEGORY_UV_SENSOR, + category=pv.CATEGORY_UV_SENSOR, class_property="light", assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement="level", @@ -118,7 +111,7 @@ async def test_humidity_sensor( await run_sensor_test( hass=hass, vera_component_factory=vera_component_factory, - category=CATEGORY_HUMIDITY_SENSOR, + category=pv.CATEGORY_HUMIDITY_SENSOR, class_property="humidity", assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement=UNIT_PERCENTAGE, @@ -132,7 +125,7 @@ async def test_power_meter_sensor( await run_sensor_test( hass=hass, vera_component_factory=vera_component_factory, - category=CATEGORY_POWER_METER, + category=pv.CATEGORY_POWER_METER, class_property="power", assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement="watts", @@ -144,7 +137,7 @@ async def test_trippable_sensor( ) -> None: """Test function.""" - def setup_callback(controller: VeraController, hass_config: dict) -> None: + def setup_callback(controller: pv.VeraController) -> None: controller.get_devices()[0].is_trippable = True await run_sensor_test( @@ -162,7 +155,7 @@ async def test_unknown_sensor( ) -> None: """Test function.""" - def setup_callback(controller: VeraController, hass_config: dict) -> None: + def setup_callback(controller: pv.VeraController) -> None: controller.get_devices()[0].is_trippable = False await run_sensor_test( @@ -179,21 +172,21 @@ async def test_scene_controller_sensor( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraSensor) # type: VeraSensor + vera_device = MagicMock(spec=pv.VeraSensor) # type: pv.VeraSensor vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_SCENE_CONTROLLER + vera_device.category = pv.CATEGORY_SCENE_CONTROLLER vera_device.get_last_scene_id = MagicMock(return_value="id0") vera_device.get_last_scene_time = MagicMock(return_value="0000") entity_id = "sensor.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,) + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback - vera_device.get_last_scene_time = "1111" + vera_device.get_last_scene_time.return_value = "1111" update_callback(vera_device) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "id0" diff --git a/tests/components/vera/test_switch.py b/tests/components/vera/test_switch.py index ba09068e7e602f..c41afad4759f8f 100644 --- a/tests/components/vera/test_switch.py +++ b/tests/components/vera/test_switch.py @@ -1,29 +1,29 @@ """Vera tests.""" from unittest.mock import MagicMock -from pyvera import CATEGORY_SWITCH, VeraSwitch +import pyvera as pv from homeassistant.core import HomeAssistant -from .common import ComponentFactory +from .common import ComponentFactory, new_simple_controller_config async def test_switch( hass: HomeAssistant, vera_component_factory: ComponentFactory ) -> None: """Test function.""" - vera_device = MagicMock(spec=VeraSwitch) # type: VeraSwitch + vera_device = MagicMock(spec=pv.VeraSwitch) # type: pv.VeraSwitch vera_device.device_id = 1 vera_device.name = "dev1" - vera_device.category = CATEGORY_SWITCH + vera_device.category = pv.CATEGORY_SWITCH vera_device.is_switched_on = MagicMock(return_value=False) entity_id = "switch.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), + hass=hass, + controller_config=new_simple_controller_config(devices=(vera_device,)), ) - controller = component_data.controller - update_callback = controller.register.call_args_list[0][0][1] + update_callback = component_data.controller_data.update_callback assert hass.states.get(entity_id).state == "off" From d1c1aa518d2e3f173d3156cb08857ab2b68c8b28 Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Fri, 3 Apr 2020 10:11:08 +0200 Subject: [PATCH 39/44] Identify cameras in error logs for generic and mjpeg cameras (#33561) --- homeassistant/components/generic/camera.py | 10 +++++++--- homeassistant/components/mjpeg/camera.py | 4 ++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 3abeab322620eb..768ef108969b8e 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -132,7 +132,9 @@ def fetch(): ) return response.content except requests.exceptions.RequestException as error: - _LOGGER.error("Error getting camera image: %s", error) + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, error + ) return self._last_image self._last_image = await self.hass.async_add_job(fetch) @@ -146,10 +148,12 @@ def fetch(): response = await websession.get(url, auth=self._auth) self._last_image = await response.read() except asyncio.TimeoutError: - _LOGGER.error("Timeout getting image from: %s", self._name) + _LOGGER.error("Timeout getting camera image from %s", self._name) return self._last_image except aiohttp.ClientError as err: - _LOGGER.error("Error getting new camera image: %s", err) + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, err + ) return self._last_image self._last_url = url diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index ab0409694d1b12..c42901cd6c5a6b 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -122,10 +122,10 @@ async def async_camera_image(self): return image except asyncio.TimeoutError: - _LOGGER.error("Timeout getting camera image") + _LOGGER.error("Timeout getting camera image from %s", self._name) except aiohttp.ClientError as err: - _LOGGER.error("Error getting new camera image: %s", err) + _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) def camera_image(self): """Return a still image response from the camera.""" From 2d751c1edd795a98b01d84c8fbdbab985188d5aa Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 3 Apr 2020 11:13:48 +0200 Subject: [PATCH 40/44] Upgrade luftdaten to 0.6.4 (#33564) --- homeassistant/components/luftdaten/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json index 13fa67a8b6b300..e6e9110b33a591 100644 --- a/homeassistant/components/luftdaten/manifest.json +++ b/homeassistant/components/luftdaten/manifest.json @@ -3,7 +3,7 @@ "name": "Luftdaten", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/luftdaten", - "requirements": ["luftdaten==0.6.3"], + "requirements": ["luftdaten==0.6.4"], "dependencies": [], "codeowners": ["@fabaff"], "quality_scale": "gold" diff --git a/requirements_all.txt b/requirements_all.txt index 6ebf1213c633fa..1eacae0399cffa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -840,7 +840,7 @@ logi_circle==0.2.2 london-tube-status==0.2 # homeassistant.components.luftdaten -luftdaten==0.6.3 +luftdaten==0.6.4 # homeassistant.components.lupusec lupupy==0.0.18 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 307113e9b182ae..65453aeff2beca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -329,7 +329,7 @@ libsoundtouch==0.7.2 logi_circle==0.2.2 # homeassistant.components.luftdaten -luftdaten==0.6.3 +luftdaten==0.6.4 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 From b5c89b4ef47a265361c9b8139ce8a353944948d7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 3 Apr 2020 11:58:37 +0200 Subject: [PATCH 41/44] Bump gios library to version 0.1.1 (#33569) --- homeassistant/components/gios/__init__.py | 9 +++++++-- homeassistant/components/gios/config_flow.py | 11 ++--------- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 0a7973709c1539..c7e708e3207ef0 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -3,7 +3,7 @@ from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, NoStationError +from gios import ApiError, Gios, InvalidSensorsData, NoStationError from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -63,7 +63,12 @@ async def _async_update_data(self): try: with timeout(30): await self.gios.update() - except (ApiError, NoStationError, ClientConnectorError) as error: + except ( + ApiError, + NoStationError, + ClientConnectorError, + InvalidSensorsData, + ) as error: raise UpdateFailed(error) if not self.gios.data: raise UpdateFailed("Invalid sensors data") diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 368d610c22640e..5741af47a072eb 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -3,10 +3,10 @@ from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout -from gios import ApiError, Gios, NoStationError +from gios import ApiError, Gios, InvalidSensorsData, NoStationError import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -43,9 +43,6 @@ async def async_step_user(self, user_input=None): gios = Gios(user_input[CONF_STATION_ID], websession) await gios.update() - if not gios.available: - raise InvalidSensorsData() - return self.async_create_entry( title=user_input[CONF_STATION_ID], data=user_input, ) @@ -59,7 +56,3 @@ async def async_step_user(self, user_input=None): return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - - -class InvalidSensorsData(exceptions.HomeAssistantError): - """Error to indicate invalid sensors data.""" diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 67fcbebe9a2cd9..3e3d63965d3f0d 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/gios", "dependencies": [], "codeowners": ["@bieniu"], - "requirements": ["gios==0.0.5"], + "requirements": ["gios==0.1.1"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 1eacae0399cffa..713679395a8bc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -611,7 +611,7 @@ georss_qld_bushfire_alert_client==0.3 getmac==0.8.1 # homeassistant.components.gios -gios==0.0.5 +gios==0.1.1 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65453aeff2beca..d3ad10c676fd01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -243,7 +243,7 @@ georss_qld_bushfire_alert_client==0.3 getmac==0.8.1 # homeassistant.components.gios -gios==0.0.5 +gios==0.1.1 # homeassistant.components.glances glances_api==0.2.0 From eccab375a26c3776c787e08b79314d926a5cb72e Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Fri, 3 Apr 2020 12:06:28 +0200 Subject: [PATCH 42/44] Upgrade Mastodon.py to 1.5.1 (#33243) * Upgrade Mastodon.py to 1.5.1 * Remove left-overs --- homeassistant/components/mastodon/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index c6430546bf23c7..b73b749cddf368 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -2,7 +2,7 @@ "domain": "mastodon", "name": "Mastodon", "documentation": "https://www.home-assistant.io/integrations/mastodon", - "requirements": ["Mastodon.py==1.5.0"], + "requirements": ["Mastodon.py==1.5.1"], "dependencies": [], "codeowners": ["@fabaff"] } diff --git a/requirements_all.txt b/requirements_all.txt index 713679395a8bc5..9a03a72d30ba82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -38,7 +38,7 @@ Adafruit-SHT31==1.0.2 HAP-python==2.8.0 # homeassistant.components.mastodon -Mastodon.py==1.5.0 +Mastodon.py==1.5.1 # homeassistant.components.orangepi_gpio OPi.GPIO==0.4.0 From 730d90fb8c32a3d6c9c85ce253f988214eda48e2 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Fri, 3 Apr 2020 13:36:10 +0100 Subject: [PATCH 43/44] Fix browsing regression (#33572) --- 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 b0808d83d68fc2..3171b8e953bc58 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.24.5"], + "requirements": ["zeroconf==0.25.0"], "dependencies": ["api"], "codeowners": ["@robbiet480", "@Kane610"], "quality_scale": "internal" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f450fb6283c936..3258f0fb2f2991 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ruamel.yaml==0.15.100 sqlalchemy==1.3.15 voluptuous-serialize==2.3.0 voluptuous==0.11.7 -zeroconf==0.24.5 +zeroconf==0.25.0 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 9a03a72d30ba82..ed8a0dd98e1986 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2173,7 +2173,7 @@ youtube_dl==2020.03.24 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.24.5 +zeroconf==0.25.0 # homeassistant.components.zha zha-quirks==0.0.38 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3ad10c676fd01..bc8276cf458754 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -798,7 +798,7 @@ ya_ma==0.3.8 yahooweather==0.10 # homeassistant.components.zeroconf -zeroconf==0.24.5 +zeroconf==0.25.0 # homeassistant.components.zha zha-quirks==0.0.38 From 62835f0536e323207d48b6257f01a47f86a1e1cf Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Fri, 3 Apr 2020 15:02:48 +0200 Subject: [PATCH 44/44] Fix asuswrt network failure startup (#33485) * Fix network failure startup Fix for issue ##33284 - Asuswrt component fail at startup after power failure * Removed comment * Removed bare except * is_connected moved out try-catch * Removed pointless-string-statement * Raise PlatformNotReady on "not is_connected" * Removed unnecessary check * Revert "Removed unnecessary check" This reverts commit a2ccddab2c4b1ba441f1d7482d802d9774527a26. * Implemented custom retry mechanism * Fix new line missing * Fix formatting * Fix indent * Reviewed check * Recoded based on tibber implementation * Formatting review * Changes requested * Fix tests for setup retry * Updated missing test * Fixed check on Tests * Return false if not exception * Format correction --- homeassistant/components/asuswrt/__init__.py | 30 +++++++++++++++++-- .../components/asuswrt/test_device_tracker.py | 12 ++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index a0eee38c3f8afa..446fe898aaaf5b 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -14,6 +14,7 @@ ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.event import async_call_later _LOGGER = logging.getLogger(__name__) @@ -31,6 +32,9 @@ DEFAULT_INTERFACE = "eth0" DEFAULT_DNSMASQ = "/var/lib/misc" +FIRST_RETRY_TIME = 60 +MAX_RETRY_TIME = 900 + SECRET_GROUP = "Password or SSH Key" SENSOR_TYPES = ["upload_speed", "download_speed", "download", "upload"] @@ -59,7 +63,7 @@ ) -async def async_setup(hass, config): +async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): """Set up the asuswrt component.""" conf = config[DOMAIN] @@ -77,9 +81,29 @@ async def async_setup(hass, config): dnsmasq=conf[CONF_DNSMASQ], ) - await api.connection.async_connect() + try: + await api.connection.async_connect() + except OSError as ex: + _LOGGER.warning( + "Error [%s] connecting %s to %s. Will retry in %s seconds...", + str(ex), + DOMAIN, + conf[CONF_HOST], + retry_delay, + ) + + async def retry_setup(now): + """Retry setup if a error happens on asuswrt API.""" + await async_setup( + hass, config, retry_delay=min(2 * retry_delay, MAX_RETRY_TIME) + ) + + async_call_later(hass, retry_delay, retry_setup) + + return True + if not api.is_connected: - _LOGGER.error("Unable to setup component") + _LOGGER.error("Error connecting %s to %s.", DOMAIN, conf[CONF_HOST]) return False hass.data[DATA_ASUSWRT] = api diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py index b91b815d58e855..62e5ed891ffaeb 100644 --- a/tests/components/asuswrt/test_device_tracker.py +++ b/tests/components/asuswrt/test_device_tracker.py @@ -24,6 +24,18 @@ async def test_password_or_pub_key_required(hass): assert not result +async def test_network_unreachable(hass): + """Test creating an AsusWRT scanner without a pass or pubkey.""" + with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: + AsusWrt().connection.async_connect = mock_coro_func(exception=OSError) + AsusWrt().is_connected = False + result = await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}} + ) + assert result + assert hass.data.get(DATA_ASUSWRT, None) is None + + async def test_get_scanner_with_password_no_pubkey(hass): """Test creating an AsusWRT scanner with a password and no pubkey.""" with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: