From 3b05a12e62139fc992f905f07943facfc8e51b86 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 6 Mar 2021 09:28:33 -0700 Subject: [PATCH 01/20] Adjust litterrobot tests and code to match guidelines (#47060) * Use SwitchEntity instead of ToggleEntity and adjust test patches as recommended * Move async_create_entry out of try block in config_flow * Patch pypi package instead of HA code * Bump pylitterbot to 2021.2.6, fix tests, and implement other code review suggestions * Bump pylitterbot to 2021.2.8, remove sleep mode start/end time from vacuum, adjust and add sensors for sleep mode start/end time * Move icon helper back to Litter-Robot component and isoformat times on time sensors --- .../components/litterrobot/config_flow.py | 8 +- homeassistant/components/litterrobot/hub.py | 21 ++-- .../components/litterrobot/manifest.json | 2 +- .../components/litterrobot/sensor.py | 95 +++++++++++++------ .../components/litterrobot/switch.py | 6 +- .../components/litterrobot/vacuum.py | 37 ++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/litterrobot/conftest.py | 72 ++++++++------ .../litterrobot/test_config_flow.py | 33 +++++-- tests/components/litterrobot/test_init.py | 38 +++++++- tests/components/litterrobot/test_sensor.py | 57 +++++++++-- tests/components/litterrobot/test_switch.py | 14 +-- tests/components/litterrobot/test_vacuum.py | 32 +++++-- 14 files changed, 277 insertions(+), 142 deletions(-) diff --git a/homeassistant/components/litterrobot/config_flow.py b/homeassistant/components/litterrobot/config_flow.py index d6c92d8dad6..36fc2064abb 100644 --- a/homeassistant/components/litterrobot/config_flow.py +++ b/homeassistant/components/litterrobot/config_flow.py @@ -35,9 +35,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): hub = LitterRobotHub(self.hass, user_input) try: await hub.login() - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) except LitterRobotLoginException: errors["base"] = "invalid_auth" except LitterRobotException: @@ -46,6 +43,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) diff --git a/homeassistant/components/litterrobot/hub.py b/homeassistant/components/litterrobot/hub.py index 0d0559140c7..943ef5bfe37 100644 --- a/homeassistant/components/litterrobot/hub.py +++ b/homeassistant/components/litterrobot/hub.py @@ -4,7 +4,7 @@ import logging from types import MethodType from typing import Any, Optional -from pylitterbot import Account, Robot +import pylitterbot from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -49,7 +49,7 @@ class LitterRobotHub: async def login(self, load_robots: bool = False): """Login to Litter-Robot.""" self.logged_in = False - self.account = Account() + self.account = pylitterbot.Account() try: await self.account.connect( username=self._data[CONF_USERNAME], @@ -69,11 +69,11 @@ class LitterRobotHub: class LitterRobotEntity(CoordinatorEntity): """Generic Litter-Robot entity representing common data and methods.""" - def __init__(self, robot: Robot, entity_type: str, hub: LitterRobotHub): + def __init__(self, robot: pylitterbot.Robot, entity_type: str, hub: LitterRobotHub): """Pass coordinator to CoordinatorEntity.""" super().__init__(hub.coordinator) self.robot = robot - self.entity_type = entity_type if entity_type else "" + self.entity_type = entity_type self.hub = hub @property @@ -89,22 +89,21 @@ class LitterRobotEntity(CoordinatorEntity): @property def device_info(self): """Return the device information for a Litter-Robot.""" - model = "Litter-Robot 3 Connect" - if not self.robot.serial.startswith("LR3C"): - model = "Other Litter-Robot Connected Device" return { "identifiers": {(DOMAIN, self.robot.serial)}, "name": self.robot.name, "manufacturer": "Litter-Robot", - "model": model, + "model": self.robot.model, } async def perform_action_and_refresh(self, action: MethodType, *args: Any): """Perform an action and initiates a refresh of the robot data after a few seconds.""" + + async def async_call_later_callback(*_) -> None: + await self.hub.coordinator.async_request_refresh() + await action(*args) - async_call_later( - self.hass, REFRESH_WAIT_TIME, self.hub.coordinator.async_request_refresh - ) + async_call_later(self.hass, REFRESH_WAIT_TIME, async_call_later_callback) @staticmethod def parse_time_at_default_timezone(time_str: str) -> Optional[time]: diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 1c6ac7274bf..8fa7ab8dcb5 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,6 +3,6 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.2.5"], + "requirements": ["pylitterbot==2021.2.8"], "codeowners": ["@natekspencer"] } diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 2843660bcee..8900c6c54ca 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -1,32 +1,44 @@ """Support for Litter-Robot sensors.""" -from homeassistant.const import PERCENTAGE +from typing import Optional + +from pylitterbot.robot import Robot + +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE from homeassistant.helpers.entity import Entity from .const import DOMAIN -from .hub import LitterRobotEntity - -WASTE_DRAWER = "Waste Drawer" +from .hub import LitterRobotEntity, LitterRobotHub -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up Litter-Robot sensors using config entry.""" - hub = hass.data[DOMAIN][config_entry.entry_id] - - entities = [] - for robot in hub.account.robots: - entities.append(LitterRobotSensor(robot, WASTE_DRAWER, hub)) - - if entities: - async_add_entities(entities, True) +def icon_for_gauge_level(gauge_level: Optional[int] = None, offset: int = 0) -> str: + """Return a gauge icon valid identifier.""" + if gauge_level is None or gauge_level <= 0 + offset: + return "mdi:gauge-empty" + if gauge_level > 70 + offset: + return "mdi:gauge-full" + if gauge_level > 30 + offset: + return "mdi:gauge" + return "mdi:gauge-low" -class LitterRobotSensor(LitterRobotEntity, Entity): - """Litter-Robot sensors.""" +class LitterRobotPropertySensor(LitterRobotEntity, Entity): + """Litter-Robot property sensors.""" + + def __init__( + self, robot: Robot, entity_type: str, hub: LitterRobotHub, sensor_attribute: str + ): + """Pass coordinator to CoordinatorEntity.""" + super().__init__(robot, entity_type, hub) + self.sensor_attribute = sensor_attribute @property def state(self): """Return the state.""" - return self.robot.waste_drawer_gauge + return getattr(self.robot, self.sensor_attribute) + + +class LitterRobotWasteSensor(LitterRobotPropertySensor, Entity): + """Litter-Robot sensors.""" @property def unit_of_measurement(self): @@ -36,19 +48,40 @@ class LitterRobotSensor(LitterRobotEntity, Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - if self.robot.waste_drawer_gauge <= 10: - return "mdi:gauge-empty" - if self.robot.waste_drawer_gauge < 50: - return "mdi:gauge-low" - if self.robot.waste_drawer_gauge <= 90: - return "mdi:gauge" - return "mdi:gauge-full" + return icon_for_gauge_level(self.state, 10) + + +class LitterRobotSleepTimeSensor(LitterRobotPropertySensor, Entity): + """Litter-Robot sleep time sensors.""" @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return { - "cycle_count": self.robot.cycle_count, - "cycle_capacity": self.robot.cycle_capacity, - "cycles_after_drawer_full": self.robot.cycles_after_drawer_full, - } + def state(self): + """Return the state.""" + if self.robot.sleep_mode_active: + return super().state.isoformat() + return None + + @property + def device_class(self): + """Return the device class, if any.""" + return DEVICE_CLASS_TIMESTAMP + + +ROBOT_SENSORS = [ + (LitterRobotWasteSensor, "Waste Drawer", "waste_drawer_gauge"), + (LitterRobotSleepTimeSensor, "Sleep Mode Start Time", "sleep_mode_start_time"), + (LitterRobotSleepTimeSensor, "Sleep Mode End Time", "sleep_mode_end_time"), +] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Litter-Robot sensors using config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + for robot in hub.account.robots: + for (sensor_class, entity_type, sensor_attribute) in ROBOT_SENSORS: + entities.append(sensor_class(robot, entity_type, hub, sensor_attribute)) + + if entities: + async_add_entities(entities, True) diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index b94b29a35e1..9164cc35e90 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -1,11 +1,11 @@ """Support for Litter-Robot switches.""" -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.components.switch import SwitchEntity from .const import DOMAIN from .hub import LitterRobotEntity -class LitterRobotNightLightModeSwitch(LitterRobotEntity, ToggleEntity): +class LitterRobotNightLightModeSwitch(LitterRobotEntity, SwitchEntity): """Litter-Robot Night Light Mode Switch.""" @property @@ -27,7 +27,7 @@ class LitterRobotNightLightModeSwitch(LitterRobotEntity, ToggleEntity): await self.perform_action_and_refresh(self.robot.set_night_light, False) -class LitterRobotPanelLockoutSwitch(LitterRobotEntity, ToggleEntity): +class LitterRobotPanelLockoutSwitch(LitterRobotEntity, SwitchEntity): """Litter-Robot Panel Lockout Switch.""" @property diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 6ee92993869..4fe76d446f4 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -14,7 +14,6 @@ from homeassistant.components.vacuum import ( VacuumEntity, ) from homeassistant.const import STATE_OFF -import homeassistant.util.dt as dt_util from .const import DOMAIN from .hub import LitterRobotEntity @@ -54,27 +53,22 @@ class LitterRobotCleaner(LitterRobotEntity, VacuumEntity): def state(self): """Return the state of the cleaner.""" switcher = { - Robot.UnitStatus.CCP: STATE_CLEANING, - Robot.UnitStatus.EC: STATE_CLEANING, - Robot.UnitStatus.CCC: STATE_DOCKED, - Robot.UnitStatus.CST: STATE_DOCKED, - Robot.UnitStatus.DF1: STATE_DOCKED, - Robot.UnitStatus.DF2: STATE_DOCKED, - Robot.UnitStatus.RDY: STATE_DOCKED, + Robot.UnitStatus.CLEAN_CYCLE: STATE_CLEANING, + Robot.UnitStatus.EMPTY_CYCLE: STATE_CLEANING, + Robot.UnitStatus.CLEAN_CYCLE_COMPLETE: STATE_DOCKED, + Robot.UnitStatus.CAT_SENSOR_TIMING: STATE_DOCKED, + Robot.UnitStatus.DRAWER_FULL_1: STATE_DOCKED, + Robot.UnitStatus.DRAWER_FULL_2: STATE_DOCKED, + Robot.UnitStatus.READY: STATE_DOCKED, Robot.UnitStatus.OFF: STATE_OFF, } return switcher.get(self.robot.unit_status, STATE_ERROR) - @property - def error(self): - """Return the error associated with the current state, if any.""" - return self.robot.unit_status.value - @property def status(self): """Return the status of the cleaner.""" - return f"{self.robot.unit_status.value}{' (Sleeping)' if self.robot.is_sleeping else ''}" + return f"{self.robot.unit_status.label}{' (Sleeping)' if self.robot.is_sleeping else ''}" async def async_turn_on(self, **kwargs): """Turn the cleaner on, starting a clean cycle.""" @@ -119,22 +113,11 @@ class LitterRobotCleaner(LitterRobotEntity, VacuumEntity): @property def device_state_attributes(self): """Return device specific state attributes.""" - [sleep_mode_start_time, sleep_mode_end_time] = [None, None] - - if self.robot.sleep_mode_active: - sleep_mode_start_time = dt_util.as_local( - self.robot.sleep_mode_start_time - ).strftime("%H:%M:00") - sleep_mode_end_time = dt_util.as_local( - self.robot.sleep_mode_end_time - ).strftime("%H:%M:00") - return { "clean_cycle_wait_time_minutes": self.robot.clean_cycle_wait_time_minutes, "is_sleeping": self.robot.is_sleeping, - "sleep_mode_start_time": sleep_mode_start_time, - "sleep_mode_end_time": sleep_mode_end_time, + "sleep_mode_active": self.robot.sleep_mode_active, "power_status": self.robot.power_status, - "unit_status_code": self.robot.unit_status.name, + "unit_status_code": self.robot.unit_status.value, "last_seen": self.robot.last_seen, } diff --git a/requirements_all.txt b/requirements_all.txt index 33d2ee7aad6..688f9aefdce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1504,7 +1504,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.2.5 +pylitterbot==2021.2.8 # homeassistant.components.loopenergy pyloopenergy==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 360fd281434..3131f9c31e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -794,7 +794,7 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.2.5 +pylitterbot==2021.2.8 # homeassistant.components.lutron_caseta pylutron-caseta==0.9.0 diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index dae183b4cf6..aadf7d810aa 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -1,45 +1,59 @@ """Configure pytest for Litter-Robot tests.""" +from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch +import pylitterbot from pylitterbot import Robot import pytest from homeassistant.components import litterrobot -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .common import CONFIG, ROBOT_DATA from tests.common import MockConfigEntry -def create_mock_robot(hass): +def create_mock_robot(unit_status_code: Optional[str] = None): """Create a mock Litter-Robot device.""" - robot = Robot(data=ROBOT_DATA) - robot.start_cleaning = AsyncMock() - robot.set_power_status = AsyncMock() - robot.reset_waste_drawer = AsyncMock() - robot.set_sleep_mode = AsyncMock() - robot.set_night_light = AsyncMock() - robot.set_panel_lockout = AsyncMock() - return robot + if not ( + unit_status_code + and Robot.UnitStatus(unit_status_code) != Robot.UnitStatus.UNKNOWN + ): + unit_status_code = ROBOT_DATA["unitStatus"] + + with patch.dict(ROBOT_DATA, {"unitStatus": unit_status_code}): + robot = Robot(data=ROBOT_DATA) + robot.start_cleaning = AsyncMock() + robot.set_power_status = AsyncMock() + robot.reset_waste_drawer = AsyncMock() + robot.set_sleep_mode = AsyncMock() + robot.set_night_light = AsyncMock() + robot.set_panel_lockout = AsyncMock() + return robot -@pytest.fixture() -def mock_hub(hass): - """Mock a Litter-Robot hub.""" - hub = MagicMock( - hass=hass, - account=MagicMock(), - logged_in=True, - coordinator=MagicMock(spec=DataUpdateCoordinator), - spec=litterrobot.LitterRobotHub, - ) - hub.coordinator.last_update_success = True - hub.account.robots = [create_mock_robot(hass)] - return hub +def create_mock_account(unit_status_code: Optional[str] = None): + """Create a mock Litter-Robot account.""" + account = MagicMock(spec=pylitterbot.Account) + account.connect = AsyncMock() + account.refresh_robots = AsyncMock() + account.robots = [create_mock_robot(unit_status_code)] + return account -async def setup_hub(hass, mock_hub, platform_domain): +@pytest.fixture +def mock_account(): + """Mock a Litter-Robot account.""" + return create_mock_account() + + +@pytest.fixture +def mock_account_with_error(): + """Mock a Litter-Robot account with error.""" + return create_mock_account("BR") + + +async def setup_integration(hass, mock_account, platform_domain=None): """Load a Litter-Robot platform with the provided hub.""" entry = MockConfigEntry( domain=litterrobot.DOMAIN, @@ -47,9 +61,11 @@ async def setup_hub(hass, mock_hub, platform_domain): ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.litterrobot.LitterRobotHub", - return_value=mock_hub, + with patch("pylitterbot.Account", return_value=mock_account), patch( + "homeassistant.components.litterrobot.PLATFORMS", + [platform_domain] if platform_domain else [], ): - await hass.config_entries.async_forward_entry_setup(entry, platform_domain) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + return entry diff --git a/tests/components/litterrobot/test_config_flow.py b/tests/components/litterrobot/test_config_flow.py index fd88595d37e..5068ecf721b 100644 --- a/tests/components/litterrobot/test_config_flow.py +++ b/tests/components/litterrobot/test_config_flow.py @@ -4,11 +4,14 @@ from unittest.mock import patch from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException from homeassistant import config_entries, setup +from homeassistant.components import litterrobot from .common import CONF_USERNAME, CONFIG, DOMAIN +from tests.common import MockConfigEntry -async def test_form(hass): + +async def test_form(hass, mock_account): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -17,10 +20,7 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch( - "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", - return_value=True, - ), patch( + with patch("pylitterbot.Account", return_value=mock_account), patch( "homeassistant.components.litterrobot.async_setup", return_value=True ) as mock_setup, patch( "homeassistant.components.litterrobot.async_setup_entry", @@ -38,6 +38,23 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 +async def test_already_configured(hass): + """Test we handle already configured.""" + MockConfigEntry( + domain=litterrobot.DOMAIN, + data=CONFIG[litterrobot.DOMAIN], + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONFIG[litterrobot.DOMAIN], + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + async def test_form_invalid_auth(hass): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -45,7 +62,7 @@ async def test_form_invalid_auth(hass): ) with patch( - "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + "pylitterbot.Account.connect", side_effect=LitterRobotLoginException, ): result2 = await hass.config_entries.flow.async_configure( @@ -63,7 +80,7 @@ async def test_form_cannot_connect(hass): ) with patch( - "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + "pylitterbot.Account.connect", side_effect=LitterRobotException, ): result2 = await hass.config_entries.flow.async_configure( @@ -81,7 +98,7 @@ async def test_form_unknown_error(hass): ) with patch( - "homeassistant.components.litterrobot.config_flow.LitterRobotHub.login", + "pylitterbot.Account.connect", side_effect=Exception, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 1d0ed075cc7..7cd36f33883 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -1,20 +1,48 @@ """Test Litter-Robot setup process.""" +from unittest.mock import patch + +from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException +import pytest + from homeassistant.components import litterrobot -from homeassistant.setup import async_setup_component +from homeassistant.config_entries import ( + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) from .common import CONFIG +from .conftest import setup_integration from tests.common import MockConfigEntry -async def test_unload_entry(hass): +async def test_unload_entry(hass, mock_account): """Test being able to unload an entry.""" + entry = await setup_integration(hass, mock_account) + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert hass.data[litterrobot.DOMAIN] == {} + + +@pytest.mark.parametrize( + "side_effect,expected_state", + ( + (LitterRobotLoginException, ENTRY_STATE_SETUP_ERROR), + (LitterRobotException, ENTRY_STATE_SETUP_RETRY), + ), +) +async def test_entry_not_setup(hass, side_effect, expected_state): + """Test being able to handle config entry not setup.""" entry = MockConfigEntry( domain=litterrobot.DOMAIN, data=CONFIG[litterrobot.DOMAIN], ) entry.add_to_hass(hass) - assert await async_setup_component(hass, litterrobot.DOMAIN, {}) is True - assert await litterrobot.async_unload_entry(hass, entry) - assert hass.data[litterrobot.DOMAIN] == {} + with patch( + "pylitterbot.Account.connect", + side_effect=side_effect, + ): + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == expected_state diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 2421489e237..7f1570c553e 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -1,20 +1,57 @@ """Test the Litter-Robot sensor entity.""" +from unittest.mock import Mock + +from homeassistant.components.litterrobot.sensor import LitterRobotSleepTimeSensor from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN -from homeassistant.const import PERCENTAGE +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE -from .conftest import setup_hub +from .conftest import create_mock_robot, setup_integration -ENTITY_ID = "sensor.test_waste_drawer" +WASTE_DRAWER_ENTITY_ID = "sensor.test_waste_drawer" -async def test_sensor(hass, mock_hub): - """Tests the sensor entity was set up.""" - await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) +async def test_waste_drawer_sensor(hass, mock_account): + """Tests the waste drawer sensor entity was set up.""" + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) - sensor = hass.states.get(ENTITY_ID) + sensor = hass.states.get(WASTE_DRAWER_ENTITY_ID) assert sensor assert sensor.state == "50" - assert sensor.attributes["cycle_count"] == 15 - assert sensor.attributes["cycle_capacity"] == 30 - assert sensor.attributes["cycles_after_drawer_full"] == 0 assert sensor.attributes["unit_of_measurement"] == PERCENTAGE + + +async def test_sleep_time_sensor_with_none_state(hass): + """Tests the sleep mode start time sensor where sleep mode is inactive.""" + robot = create_mock_robot() + robot.sleep_mode_active = False + sensor = LitterRobotSleepTimeSensor( + robot, "Sleep Mode Start Time", Mock(), "sleep_mode_start_time" + ) + + assert sensor + assert sensor.state is None + assert sensor.device_class == DEVICE_CLASS_TIMESTAMP + + +async def test_gauge_icon(): + """Test icon generator for gauge sensor.""" + from homeassistant.components.litterrobot.sensor import icon_for_gauge_level + + GAUGE_EMPTY = "mdi:gauge-empty" + GAUGE_LOW = "mdi:gauge-low" + GAUGE = "mdi:gauge" + GAUGE_FULL = "mdi:gauge-full" + + assert icon_for_gauge_level(None) == GAUGE_EMPTY + assert icon_for_gauge_level(0) == GAUGE_EMPTY + assert icon_for_gauge_level(5) == GAUGE_LOW + assert icon_for_gauge_level(40) == GAUGE + assert icon_for_gauge_level(80) == GAUGE_FULL + assert icon_for_gauge_level(100) == GAUGE_FULL + + assert icon_for_gauge_level(None, 10) == GAUGE_EMPTY + assert icon_for_gauge_level(0, 10) == GAUGE_EMPTY + assert icon_for_gauge_level(5, 10) == GAUGE_EMPTY + assert icon_for_gauge_level(40, 10) == GAUGE_LOW + assert icon_for_gauge_level(80, 10) == GAUGE + assert icon_for_gauge_level(100, 10) == GAUGE_FULL diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index c7f85db7412..69154bef8f5 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.util.dt import utcnow -from .conftest import setup_hub +from .conftest import setup_integration from tests.common import async_fire_time_changed @@ -20,9 +20,9 @@ NIGHT_LIGHT_MODE_ENTITY_ID = "switch.test_night_light_mode" PANEL_LOCKOUT_ENTITY_ID = "switch.test_panel_lockout" -async def test_switch(hass, mock_hub): +async def test_switch(hass, mock_account): """Tests the switch entity was set up.""" - await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) switch = hass.states.get(NIGHT_LIGHT_MODE_ENTITY_ID) assert switch @@ -36,9 +36,9 @@ async def test_switch(hass, mock_hub): (PANEL_LOCKOUT_ENTITY_ID, "set_panel_lockout"), ], ) -async def test_on_off_commands(hass, mock_hub, entity_id, robot_command): +async def test_on_off_commands(hass, mock_account, entity_id, robot_command): """Test sending commands to the switch.""" - await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) switch = hass.states.get(entity_id) assert switch @@ -48,12 +48,14 @@ async def test_on_off_commands(hass, mock_hub, entity_id, robot_command): count = 0 for service in [SERVICE_TURN_ON, SERVICE_TURN_OFF]: count += 1 + await hass.services.async_call( PLATFORM_DOMAIN, service, data, blocking=True, ) + future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME) async_fire_time_changed(hass, future) - assert getattr(mock_hub.account.robots[0], robot_command).call_count == count + assert getattr(mock_account.robots[0], robot_command).call_count == count diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 03e63b472b6..2db2ef21546 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -12,20 +12,21 @@ from homeassistant.components.vacuum import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_DOCKED, + STATE_ERROR, ) from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID from homeassistant.util.dt import utcnow -from .conftest import setup_hub +from .conftest import setup_integration from tests.common import async_fire_time_changed ENTITY_ID = "vacuum.test_litter_box" -async def test_vacuum(hass, mock_hub): +async def test_vacuum(hass, mock_account): """Tests the vacuum entity was set up.""" - await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) vacuum = hass.states.get(ENTITY_ID) assert vacuum @@ -33,6 +34,15 @@ async def test_vacuum(hass, mock_hub): assert vacuum.attributes["is_sleeping"] is False +async def test_vacuum_with_error(hass, mock_account_with_error): + """Tests a vacuum entity with an error.""" + await setup_integration(hass, mock_account_with_error, PLATFORM_DOMAIN) + + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + assert vacuum.state == STATE_ERROR + + @pytest.mark.parametrize( "service,command,extra", [ @@ -52,14 +62,22 @@ async def test_vacuum(hass, mock_hub): ATTR_PARAMS: {"enabled": True, "sleep_time": "22:30"}, }, ), + ( + SERVICE_SEND_COMMAND, + "set_sleep_mode", + { + ATTR_COMMAND: "set_sleep_mode", + ATTR_PARAMS: {"enabled": True, "sleep_time": None}, + }, + ), ], ) -async def test_commands(hass, mock_hub, service, command, extra): +async def test_commands(hass, mock_account, service, command, extra): """Test sending commands to the vacuum.""" - await setup_hub(hass, mock_hub, PLATFORM_DOMAIN) + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) vacuum = hass.states.get(ENTITY_ID) - assert vacuum is not None + assert vacuum assert vacuum.state == STATE_DOCKED data = {ATTR_ENTITY_ID: ENTITY_ID} @@ -74,4 +92,4 @@ async def test_commands(hass, mock_hub, service, command, extra): ) future = utcnow() + timedelta(seconds=REFRESH_WAIT_TIME) async_fire_time_changed(hass, future) - getattr(mock_hub.account.robots[0], command).assert_called_once() + getattr(mock_account.robots[0], command).assert_called_once() From c1a5a18b53ef66aa61a4c25324c86cd676044d08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 6 Mar 2021 14:30:57 -1000 Subject: [PATCH 02/20] Bump HAP-python to 3.4.0 (#47476) * Bump HAP-python to 3.3.3 * bump * fix mocking --- homeassistant/components/homekit/__init__.py | 2 +- .../components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit/test_homekit.py | 26 +++++++++---------- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c042872f4cd..28e2683c259 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -616,7 +616,7 @@ class HomeKit: self._async_register_bridge(dev_reg) await self._async_start(bridged_states) _LOGGER.debug("Driver start for %s", self._name) - self.hass.add_job(self.driver.start_service) + await self.driver.async_start() self.status = STATUS_RUNNING @callback diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index ac3fb0251e2..d7ec3297fa4 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.3.2", + "HAP-python==3.4.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/requirements_all.txt b/requirements_all.txt index 688f9aefdce..112f69e817c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -17,7 +17,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.3.2 +HAP-python==3.4.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3131f9c31e8..f6f39403ac8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.1.8 # homeassistant.components.homekit -HAP-python==3.3.2 +HAP-python==3.4.0 # homeassistant.components.flick_electric PyFlick==0.0.2 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 9ce3e96f06f..4d2fbfe951d 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -493,7 +493,7 @@ async def test_homekit_start(hass, hk_driver, device_reg): ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.add_accessory" ) as hk_driver_add_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: await homekit.async_start() @@ -528,7 +528,7 @@ async def test_homekit_start(hass, hk_driver, device_reg): ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.add_accessory" ) as hk_driver_add_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: await homekit.async_start() @@ -567,7 +567,7 @@ async def test_homekit_start_with_a_broken_accessory(hass, hk_driver, mock_zeroc ) as mock_setup_msg, patch( "pyhap.accessory_driver.AccessoryDriver.add_accessory", ) as hk_driver_add_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: await homekit.async_start() @@ -630,7 +630,7 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): ), patch("pyhap.accessory.Bridge.add_accessory") as mock_add_accessory, patch( "pyhap.accessory_driver.AccessoryDriver.config_changed" ) as hk_driver_config_changed, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): await async_init_entry(hass, entry) @@ -674,7 +674,7 @@ async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroco hass.states.async_set("light.demo2", "on") hass.states.async_set("light.demo3", "on") - with patch("pyhap.accessory_driver.AccessoryDriver.start_service"), patch( + with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( "pyhap.accessory_driver.AccessoryDriver.add_accessory" ), patch(f"{PATH_HOMEKIT}.show_setup_message"), patch( f"{PATH_HOMEKIT}.HomeBridge", _mock_bridge @@ -738,7 +738,7 @@ async def test_homekit_finds_linked_batteries( with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): await homekit.async_start() await hass.async_block_till_done() @@ -810,7 +810,7 @@ async def test_homekit_async_get_integration_fails( with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): await homekit.async_start() await hass.async_block_till_done() @@ -895,7 +895,7 @@ async def test_homekit_uses_system_zeroconf(hass, hk_driver, mock_zeroconf): assert await async_setup_component(hass, "zeroconf", {"zeroconf": {}}) system_zc = await zeroconf.async_get_instance(hass) - with patch("pyhap.accessory_driver.AccessoryDriver.start_service"), patch( + with patch("pyhap.accessory_driver.AccessoryDriver.async_start"), patch( f"{PATH_HOMEKIT}.HomeKit.async_stop" ): entry.add_to_hass(hass) @@ -963,7 +963,7 @@ async def test_homekit_ignored_missing_devices( with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): await homekit.async_start() await hass.async_block_till_done() @@ -1025,7 +1025,7 @@ async def test_homekit_finds_linked_motion_sensors( with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): await homekit.async_start() await hass.async_block_till_done() @@ -1090,7 +1090,7 @@ async def test_homekit_finds_linked_humidity_sensors( with patch.object(homekit.bridge, "add_accessory"), patch( f"{PATH_HOMEKIT}.show_setup_message" ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): await homekit.async_start() await hass.async_block_till_done() @@ -1153,7 +1153,7 @@ async def test_reload(hass, mock_zeroconf): ), patch( f"{PATH_HOMEKIT}.get_accessory" ), patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ): mock_homekit2.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() @@ -1205,7 +1205,7 @@ async def test_homekit_start_in_accessory_mode(hass, hk_driver, device_reg): with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( "pyhap.accessory_driver.AccessoryDriver.add_accessory" ), patch(f"{PATH_HOMEKIT}.show_setup_message") as mock_setup_msg, patch( - "pyhap.accessory_driver.AccessoryDriver.start_service" + "pyhap.accessory_driver.AccessoryDriver.async_start" ) as hk_driver_start: await homekit.async_start() From b25f8461365ecb2430b27d9cf2a160e5db7bda62 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 6 Mar 2021 10:21:00 +0100 Subject: [PATCH 03/20] Fix Sonos polling mode (#47498) --- homeassistant/components/sonos/media_player.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index e6ee45e7a57..1a9e9ef58df 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -646,9 +646,12 @@ class SonosEntity(MediaPlayerEntity): update_position = new_status != self._status self._status = new_status - track_uri = variables["current_track_uri"] if variables else None - - music_source = self.soco.music_source_from_uri(track_uri) + if variables: + track_uri = variables["current_track_uri"] + music_source = self.soco.music_source_from_uri(track_uri) + else: + # This causes a network round-trip so we avoid it when possible + music_source = self.soco.music_source if music_source == MUSIC_SRC_TV: self.update_media_linein(SOURCE_TV) From ba2b62305b2b491adeca5468638c46f7f5754f9b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 6 Mar 2021 18:33:55 +0100 Subject: [PATCH 04/20] Fix mysensors notify platform (#47517) --- .../components/mysensors/__init__.py | 47 ++++++++++++++----- .../components/mysensors/device_tracker.py | 3 ++ homeassistant/components/mysensors/helpers.py | 4 +- homeassistant/components/mysensors/notify.py | 3 ++ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 25b4d3106da..d7f1ffab400 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,5 +1,6 @@ """Connect to a MySensors gateway via pymysensors API.""" import asyncio +from functools import partial import logging from typing import Callable, Dict, List, Optional, Tuple, Type, Union @@ -8,10 +9,13 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_OPTIMISTIC -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import ( @@ -28,6 +32,7 @@ from .const import ( CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, + MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT, @@ -43,6 +48,8 @@ _LOGGER = logging.getLogger(__name__) CONF_DEBUG = "debug" CONF_NODE_NAME = "name" +DATA_HASS_CONFIG = "hass_config" + DEFAULT_BAUD_RATE = 115200 DEFAULT_TCP_PORT = 5003 DEFAULT_VERSION = "1.4" @@ -134,6 +141,8 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the MySensors component.""" + hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} + if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)): return True @@ -181,14 +190,31 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool _LOGGER.error("Gateway setup failed for %s", entry.data) return False - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if MYSENSORS_GATEWAYS not in hass.data[DOMAIN]: hass.data[DOMAIN][MYSENSORS_GATEWAYS] = {} hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway - async def finish(): + # Connect notify discovery as that integration doesn't support entry forwarding. + + load_notify_platform = partial( + async_load_platform, + hass, + NOTIFY_DOMAIN, + DOMAIN, + hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], + ) + + await on_unload( + hass, + entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(entry.entry_id, NOTIFY_DOMAIN), + load_notify_platform, + ), + ) + + async def finish() -> None: await asyncio.gather( *[ hass.config_entries.async_forward_entry_setup(entry, platform) @@ -248,14 +274,14 @@ async def on_unload( @callback def setup_mysensors_platform( - hass, + hass: HomeAssistant, domain: str, # hass platform name - discovery_info: Optional[Dict[str, List[DevId]]], + discovery_info: Dict[str, List[DevId]], device_class: Union[Type[MySensorsDevice], Dict[SensorType, Type[MySensorsEntity]]], device_args: Optional[ Tuple ] = None, # extra arguments that will be given to the entity constructor - async_add_entities: Callable = None, + async_add_entities: Optional[Callable] = None, ) -> Optional[List[MySensorsDevice]]: """Set up a MySensors platform. @@ -264,11 +290,6 @@ def setup_mysensors_platform( The function is also given a class. A new instance of the class is created for every device id, and the device id is given to the constructor of the class """ - # Only act if called via MySensors by discovery event. - # Otherwise gateway is not set up. - if not discovery_info: - _LOGGER.debug("Skipping setup due to no discovery info") - return None if device_args is None: device_args = () new_devices: List[MySensorsDevice] = [] diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index b395a48f28b..d1f89e4fe04 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -12,6 +12,9 @@ async def async_setup_scanner( hass: HomeAssistantType, config, async_see, discovery_info=None ): """Set up the MySensors device scanner.""" + if not discovery_info: + return False + new_devices = mysensors.setup_mysensors_platform( hass, DOMAIN, diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index d06bf0dee2f..4452dd0575b 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -9,7 +9,7 @@ from mysensors.sensor import ChildSensor import voluptuous as vol from homeassistant.const import CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.decorator import Registry @@ -33,7 +33,7 @@ SCHEMAS = Registry() @callback def discover_mysensors_platform( - hass, gateway_id: GatewayId, platform: str, new_devices: List[DevId] + hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: List[DevId] ) -> None: """Discover a MySensors platform.""" _LOGGER.debug("Discovering platform %s with devIds: %s", platform, new_devices) diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py index 99e731762df..50fca55ab39 100644 --- a/homeassistant/components/mysensors/notify.py +++ b/homeassistant/components/mysensors/notify.py @@ -5,6 +5,9 @@ from homeassistant.components.notify import ATTR_TARGET, DOMAIN, BaseNotificatio async def async_get_service(hass, config, discovery_info=None): """Get the MySensors notification service.""" + if not discovery_info: + return None + new_devices = mysensors.setup_mysensors_platform( hass, DOMAIN, discovery_info, MySensorsNotificationDevice ) From e7717694a3e7f5f0e4b96ae32b5177d974695999 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 8 Mar 2021 19:34:12 +0100 Subject: [PATCH 05/20] Fix AsusWRT wrong api call (#47522) --- CODEOWNERS | 2 +- homeassistant/components/asuswrt/config_flow.py | 2 +- homeassistant/components/asuswrt/manifest.json | 2 +- homeassistant/components/asuswrt/router.py | 2 +- tests/components/asuswrt/test_config_flow.py | 4 ++-- tests/components/asuswrt/test_sensor.py | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b0a31203009..0e069f94e73 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -46,7 +46,7 @@ homeassistant/components/arcam_fmj/* @elupus homeassistant/components/arduino/* @fabaff homeassistant/components/arest/* @fabaff homeassistant/components/arris_tg2492lg/* @vanbalken -homeassistant/components/asuswrt/* @kennedyshead +homeassistant/components/asuswrt/* @kennedyshead @ollo69 homeassistant/components/atag/* @MatsNL homeassistant/components/aten_pe/* @mtdcr homeassistant/components/atome/* @baqs diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 303b3cc3822..b3e3ec4d68d 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -128,7 +128,7 @@ class AsusWrtFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): conf_protocol = user_input[CONF_PROTOCOL] if conf_protocol == PROTOCOL_TELNET: - await api.connection.disconnect() + api.connection.disconnect() return RESULT_SUCCESS async def async_step_user(self, user_input=None): diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 744a05b9728..ab739f1c7ec 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -4,5 +4,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/asuswrt", "requirements": ["aioasuswrt==1.3.1"], - "codeowners": ["@kennedyshead"] + "codeowners": ["@kennedyshead", "@ollo69"] } diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 11545919b43..4af157387f9 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -205,7 +205,7 @@ class AsusWrtRouter: """Close the connection.""" if self._api is not None: if self._protocol == PROTOCOL_TELNET: - await self._api.connection.disconnect() + self._api.connection.disconnect() self._api = None for func in self._on_close: diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index 7faec5d336c..a6e24b09462 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -1,6 +1,6 @@ """Tests for the AsusWrt config flow.""" from socket import gaierror -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -46,7 +46,7 @@ def mock_controller_connect(): with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock: service_mock.return_value.connection.async_connect = AsyncMock() service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = AsyncMock() + service_mock.return_value.connection.disconnect = Mock() yield service_mock diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 994111370fd..0e663ae548b 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the AsusWrt sensor.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from aioasuswrt.asuswrt import Device import pytest @@ -49,7 +49,7 @@ def mock_controller_connect(): with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock: service_mock.return_value.connection.async_connect = AsyncMock() service_mock.return_value.is_connected = True - service_mock.return_value.connection.disconnect = AsyncMock() + service_mock.return_value.connection.disconnect = Mock() service_mock.return_value.async_get_connected_devices = AsyncMock( return_value=MOCK_DEVICES ) From e63f766c2073df936fa5ed113ee6d0ffbef25d8a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 6 Mar 2021 23:06:50 +0100 Subject: [PATCH 06/20] Bump pymysensors to 0.21.0 (#47530) --- homeassistant/components/mysensors/manifest.json | 13 +++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index 8371f2930c2..c7d439dedc4 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -2,15 +2,8 @@ "domain": "mysensors", "name": "MySensors", "documentation": "https://www.home-assistant.io/integrations/mysensors", - "requirements": [ - "pymysensors==0.20.1" - ], - "after_dependencies": [ - "mqtt" - ], - "codeowners": [ - "@MartinHjelmare", - "@functionpointer" - ], + "requirements": ["pymysensors==0.21.0"], + "after_dependencies": ["mqtt"], + "codeowners": ["@MartinHjelmare", "@functionpointer"], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 112f69e817c..4ddf638867f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ pymusiccast==0.1.6 pymyq==3.0.4 # homeassistant.components.mysensors -pymysensors==0.20.1 +pymysensors==0.21.0 # homeassistant.components.nanoleaf pynanoleaf==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6f39403ac8..0f1a59fff35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -827,7 +827,7 @@ pymonoprice==0.3 pymyq==3.0.4 # homeassistant.components.mysensors -pymysensors==0.20.1 +pymysensors==0.21.0 # homeassistant.components.nuki pynuki==1.3.8 From a2e00324a8af7f78ffaa75fb5f23d9af7fcea1ed Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sat, 6 Mar 2021 23:41:43 +0100 Subject: [PATCH 07/20] Fix mysensors device tracker (#47536) --- .../components/mysensors/__init__.py | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d7f1ffab400..d0ab8ea712e 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -8,6 +8,7 @@ from mysensors import BaseAsyncGateway import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -195,24 +196,27 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] = gateway # Connect notify discovery as that integration doesn't support entry forwarding. + # Allow loading device tracker platform via discovery + # until refactor to config entry is done. - load_notify_platform = partial( - async_load_platform, - hass, - NOTIFY_DOMAIN, - DOMAIN, - hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], - ) - - await on_unload( - hass, - entry.entry_id, - async_dispatcher_connect( + for platform in (DEVICE_TRACKER_DOMAIN, NOTIFY_DOMAIN): + load_discovery_platform = partial( + async_load_platform, hass, - MYSENSORS_DISCOVERY.format(entry.entry_id, NOTIFY_DOMAIN), - load_notify_platform, - ), - ) + platform, + DOMAIN, + hass_config=hass.data[DOMAIN][DATA_HASS_CONFIG], + ) + + await on_unload( + hass, + entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_DISCOVERY.format(entry.entry_id, platform), + load_discovery_platform, + ), + ) async def finish() -> None: await asyncio.gather( From 56efae3cb51ca96499ee7bdc8bb20d6c9ebb0a31 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 7 Mar 2021 14:20:21 +0100 Subject: [PATCH 08/20] Fix mysensors unload clean up (#47541) --- .../components/mysensors/__init__.py | 21 +++--------------- homeassistant/components/mysensors/gateway.py | 18 +++++++++++---- homeassistant/components/mysensors/helpers.py | 22 ++++++++++++++++++- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d0ab8ea712e..0f8123e3a31 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -38,11 +38,11 @@ from .const import ( MYSENSORS_ON_UNLOAD, SUPPORTED_PLATFORMS_WITH_ENTRY_SUPPORT, DevId, - GatewayId, SensorType, ) from .device import MySensorsDevice, MySensorsEntity, get_mysensors_devices from .gateway import finish_setup, get_mysensors_gateway, gw_stop, setup_gateway +from .helpers import on_unload _LOGGER = logging.getLogger(__name__) @@ -253,29 +253,14 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> boo for fnct in hass.data[DOMAIN][key]: fnct() + hass.data[DOMAIN].pop(key) + del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] await gw_stop(hass, entry, gateway) return True -async def on_unload( - hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable -) -> None: - """Register a callback to be called when entry is unloaded. - - This function is used by platforms to cleanup after themselves - """ - if isinstance(entry, GatewayId): - uniqueid = entry - else: - uniqueid = entry.entry_id - key = MYSENSORS_ON_UNLOAD.format(uniqueid) - if key not in hass.data[DOMAIN]: - hass.data[DOMAIN][key] = [] - hass.data[DOMAIN][key].append(fnct) - - @callback def setup_mysensors_platform( hass: HomeAssistant, diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index b6797cafb37..a7f3a053d3f 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -31,7 +31,12 @@ from .const import ( GatewayId, ) from .handler import HANDLERS -from .helpers import discover_mysensors_platform, validate_child, validate_node +from .helpers import ( + discover_mysensors_platform, + on_unload, + validate_child, + validate_node, +) _LOGGER = logging.getLogger(__name__) @@ -260,8 +265,8 @@ async def _discover_persistent_devices( async def gw_stop(hass, entry: ConfigEntry, gateway: BaseAsyncGateway): """Stop the gateway.""" - connect_task = hass.data[DOMAIN].get( - MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id) + connect_task = hass.data[DOMAIN].pop( + MYSENSORS_GATEWAY_START_TASK.format(entry.entry_id), None ) if connect_task is not None and not connect_task.done(): connect_task.cancel() @@ -288,7 +293,12 @@ async def _gw_start( async def stop_this_gw(_: Event): await gw_stop(hass, entry, gateway) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw) + await on_unload( + hass, + entry.entry_id, + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw), + ) + if entry.data[CONF_DEVICE] == MQTT_COMPONENT: # Gatways connected via mqtt doesn't send gateway ready message. return diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index 4452dd0575b..0b8dc361158 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -2,16 +2,18 @@ from collections import defaultdict from enum import IntEnum import logging -from typing import DefaultDict, Dict, List, Optional, Set +from typing import Callable, DefaultDict, Dict, List, Optional, Set, Union from mysensors import BaseAsyncGateway, Message from mysensors.sensor import ChildSensor import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.decorator import Registry from .const import ( @@ -20,6 +22,7 @@ from .const import ( DOMAIN, FLAT_PLATFORM_TYPES, MYSENSORS_DISCOVERY, + MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, GatewayId, @@ -31,6 +34,23 @@ _LOGGER = logging.getLogger(__name__) SCHEMAS = Registry() +async def on_unload( + hass: HomeAssistantType, entry: Union[ConfigEntry, GatewayId], fnct: Callable +) -> None: + """Register a callback to be called when entry is unloaded. + + This function is used by platforms to cleanup after themselves. + """ + if isinstance(entry, GatewayId): + uniqueid = entry + else: + uniqueid = entry.entry_id + key = MYSENSORS_ON_UNLOAD.format(uniqueid) + if key not in hass.data[DOMAIN]: + hass.data[DOMAIN][key] = [] + hass.data[DOMAIN][key].append(fnct) + + @callback def discover_mysensors_platform( hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: List[DevId] From 69f63129aac564468ef61f506fe6fe2b05c20c50 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 7 Mar 2021 15:07:02 +0000 Subject: [PATCH 09/20] Correct weather entities forecast time (#47565) --- homeassistant/components/aemet/weather_update_coordinator.py | 4 ++-- .../components/openweathermap/weather_update_coordinator.py | 4 +++- tests/components/aemet/test_sensor.py | 4 +++- tests/components/aemet/test_weather.py | 5 +++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 6a06b1dd391..619429c9a5b 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -393,7 +393,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ), ATTR_FORECAST_TEMP: self._get_temperature_day(day), ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), - ATTR_FORECAST_TIME: dt_util.as_utc(date), + ATTR_FORECAST_TIME: dt_util.as_utc(date).isoformat(), ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), } @@ -412,7 +412,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): day, hour ), ATTR_FORECAST_TEMP: self._get_temperature(day, hour), - ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt), + ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), } diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 93db4ca26d8..e07a8f32608 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -139,7 +139,9 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _convert_forecast(self, entry): forecast = { - ATTR_FORECAST_TIME: dt.utc_from_timestamp(entry.reference_time("unix")), + ATTR_FORECAST_TIME: dt.utc_from_timestamp( + entry.reference_time("unix") + ).isoformat(), ATTR_FORECAST_PRECIPITATION: self._calc_precipitation( entry.rain, entry.snow ), diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 05f2d8d0b50..b265b996709 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -37,7 +37,9 @@ async def test_aemet_forecast_create_sensors(hass): assert state.state == "-4" state = hass.states.get("sensor.aemet_daily_forecast_time") - assert state.state == "2021-01-10 00:00:00+00:00" + assert ( + state.state == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() + ) state = hass.states.get("sensor.aemet_daily_forecast_wind_bearing") assert state.state == "45.0" diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index eef6107d543..43acf4c1c87 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -51,8 +51,9 @@ async def test_aemet_weather(hass): assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 30 assert forecast.get(ATTR_FORECAST_TEMP) == 4 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == -4 - assert forecast.get(ATTR_FORECAST_TIME) == dt_util.parse_datetime( - "2021-01-10 00:00:00+00:00" + assert ( + forecast.get(ATTR_FORECAST_TIME) + == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20 From 37f486941a23da977147ea5e34d3ee9411bd2af6 Mon Sep 17 00:00:00 2001 From: Tony Roman Date: Mon, 8 Mar 2021 13:26:08 -0500 Subject: [PATCH 10/20] Allow running and restarting with both ozw and zwave active (#47566) Co-authored-by: Martin Hjelmare --- homeassistant/components/ozw/manifest.json | 3 +-- script/hassfest/dependencies.py | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ozw/manifest.json b/homeassistant/components/ozw/manifest.json index 984e3f9c51a..a1409fd79a8 100644 --- a/homeassistant/components/ozw/manifest.json +++ b/homeassistant/components/ozw/manifest.json @@ -7,8 +7,7 @@ "python-openzwave-mqtt[mqtt-client]==1.4.0" ], "after_dependencies": [ - "mqtt", - "zwave" + "mqtt" ], "codeowners": [ "@cgarwood", diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 6283b2d8665..b13d7929042 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -147,6 +147,8 @@ IGNORE_VIOLATIONS = { # Demo ("demo", "manual"), ("demo", "openalpr_local"), + # Migration wizard from zwave to ozw. + "ozw", # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), From f1fc6c4b2565266055449a38899ef0a0aa1cf642 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 8 Mar 2021 03:08:17 -0500 Subject: [PATCH 11/20] Add fallback zwave_js entity name using node ID (#47582) * add fallback zwave_js entity name using node ID * add new fixture and test for name that was failing --- homeassistant/components/zwave_js/entity.py | 6 +- tests/components/zwave_js/conftest.py | 20 + tests/components/zwave_js/test_init.py | 6 + .../zwave_js/null_name_check_state.json | 414 ++++++++++++++++++ 4 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/zwave_js/null_name_check_state.json diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index c061abc4d0d..7620323d940 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -105,7 +105,11 @@ class ZWaveBaseEntity(Entity): """Generate entity name.""" if additional_info is None: additional_info = [] - name: str = self.info.node.name or self.info.node.device_config.description + name: str = ( + self.info.node.name + or self.info.node.device_config.description + or f"Node {self.info.node.node_id}" + ) if include_value_name: value_name = ( alternate_value_name diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index aa9da282635..740d14763d4 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -288,6 +288,18 @@ def aeotec_radiator_thermostat_state_fixture(): return json.loads(load_fixture("zwave_js/aeotec_radiator_thermostat_state.json")) +@pytest.fixture(name="inovelli_lzw36_state", scope="session") +def inovelli_lzw36_state_fixture(): + """Load the Inovelli LZW36 node state fixture data.""" + return json.loads(load_fixture("zwave_js/inovelli_lzw36_state.json")) + + +@pytest.fixture(name="null_name_check_state", scope="session") +def null_name_check_state_fixture(): + """Load the null name check node state fixture data.""" + return json.loads(load_fixture("zwave_js/null_name_check_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state): """Mock a client.""" @@ -484,6 +496,14 @@ def in_wall_smart_fan_control_fixture(client, in_wall_smart_fan_control_state): return node +@pytest.fixture(name="null_name_check") +def null_name_check_fixture(client, null_name_check_state): + """Mock a node with no name.""" + node = Node(client, copy.deepcopy(null_name_check_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="multiple_devices") def multiple_devices_fixture( client, climate_radio_thermostat_ct100_plus_state, lock_schlage_be469_state diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index e56db58f3cc..00574bd2d2f 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -459,6 +459,12 @@ async def test_existing_node_ready( ) +async def test_null_name(hass, client, null_name_check, integration): + """Test that node without a name gets a generic node name.""" + node = null_name_check + assert hass.states.get(f"switch.node_{node.node_id}") + + async def test_existing_node_not_ready(hass, client, multisensor_6, device_registry): """Test we handle a non ready node that exists during integration setup.""" node = multisensor_6 diff --git a/tests/fixtures/zwave_js/null_name_check_state.json b/tests/fixtures/zwave_js/null_name_check_state.json new file mode 100644 index 00000000000..fe63eaee207 --- /dev/null +++ b/tests/fixtures/zwave_js/null_name_check_state.json @@ -0,0 +1,414 @@ +{ + "nodeId": 10, + "index": 0, + "installerIcon": 3328, + "userIcon": 3328, + "status": 4, + "ready": true, + "isListening": true, + "isFrequentListening": false, + "isRouting": true, + "maxBaudRate": 40000, + "isSecure": false, + "version": 4, + "isBeaming": true, + "manufacturerId": 277, + "productId": 1, + "productType": 272, + "firmwareVersion": "2.17", + "zwavePlusVersion": 1, + "nodeType": 0, + "roleType": 1, + "neighbors": [], + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 4, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "interviewStage": 7, + "endpoints": [ + { + "nodeId": 10, + "index": 0, + "installerIcon": 3328, + "userIcon": 3328 + }, + { + "nodeId": 10, + "index": 1 + }, + { + "nodeId": 10, + "index": 2 + }, + { + "nodeId": 10, + "index": 3 + }, + { + "nodeId": 10, + "index": 4 + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "unit": "\u00b0C", + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + } + }, + "value": 2.9 + }, + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Humidity", + "propertyName": "Humidity", + "ccVersion": 7, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "unit": "%", + "label": "Humidity", + "ccSpecific": { + "sensorType": 5, + "scale": 0 + } + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Manufacturer ID" + }, + "value": 277 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product type" + }, + "value": 272 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "min": 0, + "max": 65535, + "label": "Product ID" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.38" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["2.17"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + }, + { + "endpoint": 1, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "endpoint": 1, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + } + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value" + }, + "value": true + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value" + }, + "value": true + } + ], + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + }, + "mandatorySupportedCCs": [32, 49], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 7, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 3, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + } + ] +} From 0f115f69378271f65e5c0f1c323976adc3d95bc6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Mar 2021 08:44:28 -1000 Subject: [PATCH 12/20] Ensure bond devices recover when wifi disconnects and reconnects (#47591) --- homeassistant/components/bond/entity.py | 2 +- tests/components/bond/test_entity.py | 169 ++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/components/bond/test_entity.py diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 5b2e27b94cc..ec885f454e3 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -102,7 +102,7 @@ class BondEntity(Entity): async def _async_update_if_bpup_not_alive(self, *_): """Fetch via the API if BPUP is not alive.""" - if self._bpup_subs.alive and self._initialized: + if self._bpup_subs.alive and self._initialized and self._available: return if self._update_lock.locked(): diff --git a/tests/components/bond/test_entity.py b/tests/components/bond/test_entity.py new file mode 100644 index 00000000000..e0a3f156ff5 --- /dev/null +++ b/tests/components/bond/test_entity.py @@ -0,0 +1,169 @@ +"""Tests for the Bond entities.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch + +from bond_api import BPUPSubscriptions, DeviceType + +from homeassistant import core +from homeassistant.components import fan +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.util import utcnow + +from .common import patch_bond_device_state, setup_platform + +from tests.common import async_fire_time_changed + + +def ceiling_fan(name: str): + """Create a ceiling fan with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": ["SetSpeed", "SetDirection"], + } + + +async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssistant): + """Test that push updates fail and we fallback to polling and then bpup recovers. + + The BPUP recovery is triggered by an update for the entity and + we do not fallback to polling because state is in sync. + """ + bpup_subs = BPUPSubscriptions() + with patch( + "homeassistant.components.bond.BPUPSubscriptions", + return_value=bpup_subs, + ): + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 3, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 + + bpup_subs.last_message_time = 0 + with patch_bond_device_state(side_effect=asyncio.TimeoutError): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + # Ensure we do not poll to get the state + # since bpup has recovered and we know we + # are back in sync + with patch_bond_device_state(side_effect=Exception): + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 2, "direction": 0}, + } + ) + await hass.async_block_till_done() + + state = hass.states.get("fan.name_1") + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 66 + + +async def test_bpup_goes_offline_and_recovers_different_entity( + hass: core.HomeAssistant, +): + """Test that push updates fail and we fallback to polling and then bpup recovers. + + The BPUP recovery is triggered by an update for a different entity which + forces a poll since we need to re-get the state. + """ + bpup_subs = BPUPSubscriptions() + with patch( + "homeassistant.components.bond.BPUPSubscriptions", + return_value=bpup_subs, + ): + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 3, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + + bpup_subs.notify( + { + "s": 200, + "t": "bond/test-device-id/update", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 + + bpup_subs.last_message_time = 0 + with patch_bond_device_state(side_effect=asyncio.TimeoutError): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + bpup_subs.notify( + { + "s": 200, + "t": "bond/not-this-device-id/update", + "b": {"power": 1, "speed": 2, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + with patch_bond_device_state(return_value={"power": 1, "speed": 1}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=430)) + await hass.async_block_till_done() + + state = hass.states.get("fan.name_1") + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 + + +async def test_polling_fails_and_recovers(hass: core.HomeAssistant): + """Test that polling fails and we recover.""" + await setup_platform( + hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id" + ) + + with patch_bond_device_state(side_effect=asyncio.TimeoutError): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + assert hass.states.get("fan.name_1").state == STATE_UNAVAILABLE + + with patch_bond_device_state(return_value={"power": 1, "speed": 1}): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) + await hass.async_block_till_done() + + state = hass.states.get("fan.name_1") + assert state.state == STATE_ON + assert state.attributes[fan.ATTR_PERCENTAGE] == 33 From 3c1aac10343f9ffa21060c172e1bf09a76754bb5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 8 Mar 2021 06:45:15 +0100 Subject: [PATCH 13/20] Update frontend to 20210302.6 (#47592) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8093c65d91a..694be0382f7 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210302.5" + "home-assistant-frontend==20210302.6" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0586b956f39..7642e14bda8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ defusedxml==0.6.0 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.41.0 -home-assistant-frontend==20210302.5 +home-assistant-frontend==20210302.6 httpx==0.16.1 jinja2>=2.11.3 netdisco==2.8.2 diff --git a/requirements_all.txt b/requirements_all.txt index 4ddf638867f..5eb66a3a200 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -763,7 +763,7 @@ hole==0.5.1 holidays==0.10.5.2 # homeassistant.components.frontend -home-assistant-frontend==20210302.5 +home-assistant-frontend==20210302.6 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f1a59fff35..628b62899cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -412,7 +412,7 @@ hole==0.5.1 holidays==0.10.5.2 # homeassistant.components.frontend -home-assistant-frontend==20210302.5 +home-assistant-frontend==20210302.6 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 96b266b2e800e48895c42a1551aaf18c3e01f22e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Mar 2021 08:43:22 -1000 Subject: [PATCH 14/20] Fix turn on without speed in homekit controller (#47597) --- .../components/homekit_controller/fan.py | 9 +- .../components/homekit_controller/conftest.py | 7 ++ .../components/homekit_controller/test_fan.py | 97 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index e2cdf7b3cfd..591050f5fd9 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -80,6 +80,13 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): return features + @property + def speed_count(self): + """Speed count for the fan.""" + return round( + 100 / max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0) + ) + async def async_set_direction(self, direction): """Set the direction of the fan.""" await self.async_put_characteristics( @@ -110,7 +117,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if not self.is_on: characteristics[self.on_characteristic] = True - if self.supported_features & SUPPORT_SET_SPEED: + if percentage is not None and self.supported_features & SUPPORT_SET_SPEED: characteristics[CharacteristicsTypes.ROTATION_SPEED] = percentage if characteristics: diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 26adb25df21..62382eec5eb 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -11,6 +11,13 @@ import homeassistant.util.dt as dt_util from tests.components.light.conftest import mock_light_profiles # noqa +@pytest.fixture(autouse=True) +def mock_zeroconf(): + """Mock zeroconf.""" + with mock.patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc: + yield mock_zc.return_value + + @pytest.fixture def utcnow(request): """Freeze time at a known point.""" diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index b8d42b21643..d66ce81d534 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -50,6 +50,38 @@ def create_fanv2_service(accessory): swing_mode.value = 0 +def create_fanv2_service_with_min_step(accessory): + """Define fan v2 characteristics as per HAP spec.""" + service = accessory.add_service(ServicesTypes.FAN_V2) + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 + speed.minStep = 25 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + +def create_fanv2_service_without_rotation_speed(accessory): + """Define fan v2 characteristics as per HAP spec.""" + service = accessory.add_service(ServicesTypes.FAN_V2) + + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) + cur_state.value = 0 + + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 + + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 + + async def test_fan_read_state(hass, utcnow): """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fan_service) @@ -95,6 +127,29 @@ async def test_turn_on(hass, utcnow): assert helper.characteristics[V1_ROTATION_SPEED].value == 33.0 +async def test_turn_on_off_without_rotation_speed(hass, utcnow): + """Test that we can turn a fan on.""" + helper = await setup_test_component( + hass, create_fanv2_service_without_rotation_speed + ) + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + assert helper.characteristics[V2_ACTIVE].value == 1 + + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + assert helper.characteristics[V2_ACTIVE].value == 0 + + async def test_turn_off(hass, utcnow): """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fan_service) @@ -181,6 +236,7 @@ async def test_speed_read(hass, utcnow): state = await helper.poll_and_get_state() assert state.attributes["speed"] == "high" assert state.attributes["percentage"] == 100 + assert state.attributes["percentage_step"] == 1.0 helper.characteristics[V1_ROTATION_SPEED].value = 50 state = await helper.poll_and_get_state() @@ -277,6 +333,24 @@ async def test_v2_turn_on(hass, utcnow): assert helper.characteristics[V2_ACTIVE].value == 1 assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0 + await hass.services.async_call( + "fan", + "turn_off", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + assert helper.characteristics[V2_ACTIVE].value == 0 + assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0 + + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": "fan.testdevice"}, + blocking=True, + ) + assert helper.characteristics[V2_ACTIVE].value == 1 + assert helper.characteristics[V2_ROTATION_SPEED].value == 33.0 + async def test_v2_turn_off(hass, utcnow): """Test that we can turn a fan off.""" @@ -355,6 +429,29 @@ async def test_v2_set_percentage(hass, utcnow): assert helper.characteristics[V2_ACTIVE].value == 0 +async def test_v2_set_percentage_with_min_step(hass, utcnow): + """Test that we set fan speed by percentage.""" + helper = await setup_test_component(hass, create_fanv2_service_with_min_step) + + helper.characteristics[V2_ACTIVE].value = 1 + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 66}, + blocking=True, + ) + assert helper.characteristics[V2_ROTATION_SPEED].value == 75 + + await hass.services.async_call( + "fan", + "set_percentage", + {"entity_id": "fan.testdevice", "percentage": 0}, + blocking=True, + ) + assert helper.characteristics[V2_ACTIVE].value == 0 + + async def test_v2_speed_read(hass, utcnow): """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) From 9601cb74450cf5f7a51e01a72f9fa46a6682c0f6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Mar 2021 07:34:34 -1000 Subject: [PATCH 15/20] Ensure template fan value_template always determines on state (#47598) --- homeassistant/components/template/fan.py | 3 --- tests/components/template/test_fan.py | 8 ++++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 18a7d8262e0..51dce0f8d56 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -523,7 +523,6 @@ class TemplateFan(TemplateEntity, FanEntity): speed = str(speed) if speed in self._speed_list: - self._state = STATE_OFF if speed == SPEED_OFF else STATE_ON self._speed = speed self._percentage = self.speed_to_percentage(speed) self._preset_mode = speed if speed in self.preset_modes else None @@ -552,7 +551,6 @@ class TemplateFan(TemplateEntity, FanEntity): return if 0 <= percentage <= 100: - self._state = STATE_OFF if percentage == 0 else STATE_ON self._percentage = percentage if self._speed_list: self._speed = self.percentage_to_speed(percentage) @@ -569,7 +567,6 @@ class TemplateFan(TemplateEntity, FanEntity): preset_mode = str(preset_mode) if preset_mode in self.preset_modes: - self._state = STATE_ON self._speed = preset_mode self._percentage = None self._preset_mode = preset_mode diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 2b9059017c6..34dccd7d172 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -246,6 +246,10 @@ async def test_templates_with_entities(hass, calls): await hass.async_block_till_done() _verify(hass, STATE_ON, None, 0, True, DIRECTION_FORWARD, None) + hass.states.async_set(_STATE_INPUT_BOOLEAN, False) + await hass.async_block_till_done() + _verify(hass, STATE_OFF, None, 0, True, DIRECTION_FORWARD, None) + async def test_templates_with_entities_and_invalid_percentage(hass, calls): """Test templates with values from other entities.""" @@ -274,7 +278,7 @@ async def test_templates_with_entities_and_invalid_percentage(hass, calls): await hass.async_start() await hass.async_block_till_done() - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + _verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None) hass.states.async_set("sensor.percentage", "33") await hass.async_block_till_done() @@ -299,7 +303,7 @@ async def test_templates_with_entities_and_invalid_percentage(hass, calls): hass.states.async_set("sensor.percentage", "0") await hass.async_block_till_done() - _verify(hass, STATE_OFF, SPEED_OFF, 0, None, None, None) + _verify(hass, STATE_ON, SPEED_OFF, 0, None, None, None) async def test_templates_with_entities_and_preset_modes(hass, calls): From a51ad137a19fd1b81ec30c5c331bb255409efdae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Mar 2021 12:20:21 -1000 Subject: [PATCH 16/20] Fix insteon fan speeds (#47603) --- homeassistant/components/insteon/fan.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index a641d353450..2c397188640 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -1,8 +1,6 @@ """Support for INSTEON fans via PowerLinc Modem.""" import math -from pyinsteon.constants import FanSpeed - from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, SUPPORT_SET_SPEED, @@ -19,7 +17,7 @@ from .const import SIGNAL_ADD_ENTITIES from .insteon_entity import InsteonEntity from .utils import async_add_insteon_entities -SPEED_RANGE = (1, FanSpeed.HIGH) # off is not included +SPEED_RANGE = (0x00, 0xFF) # off is not included async def async_setup_entry(hass, config_entry, async_add_entities): @@ -52,6 +50,11 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """Flag supported features.""" return SUPPORT_SET_SPEED + @property + def speed_count(self) -> int: + """Flag supported features.""" + return 3 + async def async_turn_on( self, speed: str = None, @@ -60,9 +63,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): **kwargs, ) -> None: """Turn on the fan.""" - if percentage is None: - percentage = 50 - await self.async_set_percentage(percentage) + await self.async_set_percentage(percentage or 67) async def async_turn_off(self, **kwargs) -> None: """Turn off the fan.""" @@ -71,7 +72,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" if percentage == 0: - await self._insteon_device.async_fan_off() - else: - on_level = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) - await self._insteon_device.async_fan_on(on_level=on_level) + await self.async_turn_off() + return + on_level = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + await self._insteon_device.async_on(group=2, on_level=on_level) From 58573dc74d026b203efe4ad3f966ce72a78a3556 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 8 Mar 2021 12:19:05 -1000 Subject: [PATCH 17/20] Fix turning off scene in homekit (#47604) --- homeassistant/components/homekit/type_switches.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 1ce6c364896..8ea19897420 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import callback, split_entity_id -from homeassistant.helpers.event import call_later +from homeassistant.helpers.event import async_call_later from .accessories import TYPES, HomeAccessory from .const import ( @@ -134,7 +134,7 @@ class Switch(HomeAccessory): self.async_call_service(self._domain, service, params) if self.activate_only: - call_later(self.hass, 1, self.reset_switch) + async_call_later(self.hass, 1, self.reset_switch) @callback def async_update_state(self, new_state): From 9f6007b4e2128f9e0082349bd7d2c3b1c3c4c0ed Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 8 Mar 2021 16:59:54 +0200 Subject: [PATCH 18/20] Fix Shelly logbook exception when missing COAP (#47620) --- homeassistant/components/shelly/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 0058374cfe7..27152997ef7 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -177,9 +177,9 @@ def get_device_wrapper(hass: HomeAssistant, device_id: str): return None for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry][COAP] + wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(COAP) - if wrapper.device_id == device_id: + if wrapper and wrapper.device_id == device_id: return wrapper return None From b352c5840faf8e3fe42921b3107fa6d3c872b349 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 8 Mar 2021 18:11:54 -0500 Subject: [PATCH 19/20] Update zwave_js supported features list to be static (#47623) --- homeassistant/components/zwave_js/climate.py | 22 +++++++++-------- tests/components/zwave_js/test_climate.py | 26 +++++++++++++++++++- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 325cf14b379..26e9e730283 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -162,6 +162,15 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): add_to_watched_value_ids=True, ) self._set_modes_and_presets() + self._supported_features = SUPPORT_PRESET_MODE + # If any setpoint value exists, we can assume temperature + # can be set + if any(self._setpoint_values.values()): + self._supported_features |= SUPPORT_TARGET_TEMPERATURE + if HVAC_MODE_HEAT_COOL in self.hvac_modes: + self._supported_features |= SUPPORT_TARGET_TEMPERATURE_RANGE + if self._fan_mode: + self._supported_features |= SUPPORT_FAN_MODE def _setpoint_value(self, setpoint_type: ThermostatSetpointType) -> ZwaveValue: """Optionally return a ZwaveValue for a setpoint.""" @@ -259,7 +268,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): return None try: temp = self._setpoint_value(self._current_mode_setpoint_enums[0]) - except ValueError: + except (IndexError, ValueError): return None return temp.value if temp else None @@ -271,7 +280,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): return None try: temp = self._setpoint_value(self._current_mode_setpoint_enums[1]) - except ValueError: + except (IndexError, ValueError): return None return temp.value if temp else None @@ -335,14 +344,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): @property def supported_features(self) -> int: """Return the list of supported features.""" - support = SUPPORT_PRESET_MODE - if len(self._current_mode_setpoint_enums) == 1: - support |= SUPPORT_TARGET_TEMPERATURE - if len(self._current_mode_setpoint_enums) > 1: - support |= SUPPORT_TARGET_TEMPERATURE_RANGE - if self._fan_mode: - support |= SUPPORT_FAN_MODE - return support + return self._supported_features async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index fe3e0708acc..a31aad19603 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -24,9 +24,17 @@ from homeassistant.components.climate.const import ( SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_TEMPERATURE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, ) from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, +) from .common import ( CLIMATE_DANFOSS_LC13_ENTITY, @@ -58,6 +66,13 @@ async def test_thermostat_v2( assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE assert state.attributes[ATTR_FAN_MODE] == "Auto low" assert state.attributes[ATTR_FAN_STATE] == "Idle / off" + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_PRESET_MODE + | SUPPORT_TARGET_TEMPERATURE + | SUPPORT_TARGET_TEMPERATURE_RANGE + | SUPPORT_FAN_MODE + ) # Test setting preset mode await hass.services.async_call( @@ -408,6 +423,10 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat assert state.attributes[ATTR_TEMPERATURE] == 14 assert state.attributes[ATTR_HVAC_MODES] == [HVAC_MODE_HEAT] assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + ) client.async_send_command_no_wait.reset_mock() @@ -491,6 +510,10 @@ async def test_thermostat_heatit(hass, client, climate_heatit_z_trm3, integratio assert state.attributes[ATTR_TEMPERATURE] == 22.5 assert state.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + ) async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integration): @@ -507,3 +530,4 @@ async def test_thermostat_srt321_hrt4_zw(hass, client, srt321_hrt4_zw, integrati HVAC_MODE_HEAT, ] assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_PRESET_MODE From b80c2d426c8d2da8a468830a8f9c4810dedae22e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 8 Mar 2021 23:23:04 +0000 Subject: [PATCH 20/20] Bumped version to 2021.3.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 015a347a5e3..ae748b3ccc2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 2021 MINOR_VERSION = 3 -PATCH_VERSION = "2" +PATCH_VERSION = "3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 8, 0)