diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7eba0203f7e696..fc6f4a537242e5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1073,7 +1073,11 @@ async def test_flow_connection_error(hass, mock_api_error): ### Entity Testing Patterns ```python -@pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True) +@pytest.fixture +def platforms() -> list[Platform]: + """Overridden fixture to specify platforms to test.""" + return [Platform.SENSOR] # Or another specific platform as needed. + @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_entities( hass: HomeAssistant, @@ -1120,16 +1124,25 @@ def mock_device_api() -> Generator[MagicMock]: ) yield api +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return PLATFORMS + @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_device_api: MagicMock, + platforms: list[Platform], ) -> MockConfigEntry: """Set up the integration for testing.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + + with patch("homeassistant.components.my_integration.PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry ``` diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 9efbd321123f1b..d29a2cd417ae32 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "quality_scale": "internal", "requirements": [ "bleak==1.0.1", - "bleak-retry-connector==4.3.0", + "bleak-retry-connector==4.4.1", "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index df321395b9e440..f4a68acc322aaa 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -3,9 +3,10 @@ import logging from typing import Any -from ccm15 import CCM15DeviceState +from ccm15 import CCM15DeviceState, CCM15SlaveDevice from homeassistant.components.climate import ( + ATTR_HVAC_MODE, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -88,7 +89,7 @@ def __init__( ) @property - def data(self) -> CCM15DeviceState | None: + def data(self) -> CCM15SlaveDevice | None: """Return device data.""" return self.coordinator.get_ac_data(self._ac_index) @@ -144,15 +145,17 @@ def extra_state_attributes(self) -> dict[str, Any]: async def async_set_temperature(self, **kwargs: Any) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - await self.coordinator.async_set_temperature(self._ac_index, temperature) + await self.coordinator.async_set_temperature( + self._ac_index, self.data, temperature, kwargs.get(ATTR_HVAC_MODE) + ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the hvac mode.""" - await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode) + await self.coordinator.async_set_hvac_mode(self._ac_index, self.data, hvac_mode) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the fan mode.""" - await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode) + await self.coordinator.async_set_fan_mode(self._ac_index, self.data, fan_mode) async def async_turn_off(self) -> None: """Turn off.""" diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py index 03a59aa3f246c1..ad3bbc41a06cb8 100644 --- a/homeassistant/components/ccm15/coordinator.py +++ b/homeassistant/components/ccm15/coordinator.py @@ -55,9 +55,9 @@ async def _fetch_data(self) -> CCM15DeviceState: """Get the current status of all AC devices.""" return await self._ccm15.get_status_async() - async def async_set_state(self, ac_index: int, state: str, value: int) -> None: + async def async_set_state(self, ac_index: int, data) -> None: """Set new target states.""" - if await self._ccm15.async_set_state(ac_index, state, value): + if await self._ccm15.async_set_state(ac_index, data): await self.async_request_refresh() def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None: @@ -67,17 +67,32 @@ def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None: return None return self.data.devices[ac_index] - async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None: - """Set the hvac mode.""" + async def async_set_hvac_mode( + self, ac_index: int, data: CCM15SlaveDevice, hvac_mode: HVACMode + ) -> None: + """Set the HVAC mode.""" _LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode)) - await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode]) + data.ac_mode = CONST_STATE_CMD_MAP[hvac_mode] + await self.async_set_state(ac_index, data) - async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None: + async def async_set_fan_mode( + self, ac_index: int, data: CCM15SlaveDevice, fan_mode: str + ) -> None: """Set the fan mode.""" _LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode) - await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode]) - - async def async_set_temperature(self, ac_index, temp) -> None: + data.fan_mode = CONST_FAN_CMD_MAP[fan_mode] + await self.async_set_state(ac_index, data) + + async def async_set_temperature( + self, + ac_index: int, + data: CCM15SlaveDevice, + temp: int, + hvac_mode: HVACMode | None, + ) -> None: """Set the target temperature mode.""" _LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp) - await self.async_set_state(ac_index, "temp", temp) + data.temperature_setpoint = temp + if hvac_mode is not None: + data.ac_mode = CONST_STATE_CMD_MAP[hvac_mode] + await self.async_set_state(ac_index, data) diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json index 2d985d6148aaac..23cd5547963451 100644 --- a/homeassistant/components/ccm15/manifest.json +++ b/homeassistant/components/ccm15/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ccm15", "iot_class": "local_polling", - "requirements": ["py-ccm15==0.0.9"] + "requirements": ["py_ccm15==0.1.2"] } diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 91c1e619d0b079..9932aaacb6559a 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -62,6 +62,7 @@ def __init__( self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] self.pong: datetime | None = None self.websocket_alive: bool = False + self.websocket_callbacks: list[Callable[[bool], None]] = [] self._watchdog_task: asyncio.Task | None = None @override @@ -198,12 +199,17 @@ def _should_poll(self) -> bool: ) async def _pong_watchdog(self) -> None: + """Watchdog to check for pong messages.""" _LOGGER.debug("Watchdog started") try: while True: _LOGGER.debug("Sending ping") - self.websocket_alive = await self.api.send_empty_message() - _LOGGER.debug("Ping result: %s", self.websocket_alive) + is_alive = await self.api.send_empty_message() + _LOGGER.debug("Ping result: %s", is_alive) + if self.websocket_alive != is_alive: + self.websocket_alive = is_alive + for ws_callback in self.websocket_callbacks: + ws_callback(is_alive) await asyncio.sleep(PING_INTERVAL) _LOGGER.debug("Websocket alive %s", self.websocket_alive) diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py index 8e2e48b940d5d5..7fe8bae8c2de63 100644 --- a/homeassistant/components/husqvarna_automower/event.py +++ b/homeassistant/components/husqvarna_automower/event.py @@ -1,6 +1,7 @@ """Creates the event entities for supported mowers.""" from collections.abc import Callable +import logging from aioautomower.model import SingleMessageData @@ -18,6 +19,7 @@ from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 ATTR_SEVERITY = "severity" @@ -80,6 +82,12 @@ def __init__( """Initialize Automower message event entity.""" super().__init__(mower_id, coordinator) self._attr_unique_id = f"{mower_id}_message" + self.websocket_alive: bool = coordinator.websocket_alive + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return self.websocket_alive and self.mower_id in self.coordinator.data @callback def _handle(self, msg: SingleMessageData) -> None: @@ -102,7 +110,17 @@ async def async_added_to_hass(self) -> None: """Register callback when entity is added to hass.""" await super().async_added_to_hass() self.coordinator.api.register_single_message_callback(self._handle) + self.coordinator.websocket_callbacks.append(self._handle_websocket_update) async def async_will_remove_from_hass(self) -> None: """Unregister WebSocket callback when entity is removed.""" self.coordinator.api.unregister_single_message_callback(self._handle) + self.coordinator.websocket_callbacks.remove(self._handle_websocket_update) + + def _handle_websocket_update(self, is_alive: bool) -> None: + """Handle websocket status changes.""" + if self.websocket_alive == is_alive: + return + self.websocket_alive = is_alive + _LOGGER.debug("WebSocket status changed to %s, updating entity state", is_alive) + self.async_write_ha_state() diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 49eb364858fa9f..60ac9fe4fa5531 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.1.2"] + "requirements": ["aioautomower==2.2.0"] } diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index 4537dec0e287d9..89de3336440383 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -9,11 +9,11 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, Platform +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import HusqvarnaCoordinator type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] @@ -26,10 +26,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: """Set up Husqvarna Autoconnect Bluetooth from a config entry.""" + if CONF_PIN not in entry.data: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="pin_required", + translation_placeholders={"domain_name": "Husqvarna Automower BLE"}, + ) + address = entry.data[CONF_ADDRESS] + pin = int(entry.data[CONF_PIN]) channel_id = entry.data[CONF_CLIENT_ID] - mower = Mower(channel_id, address) + mower = Mower(channel_id, address, pin) await close_stale_connections_by_address(address) @@ -39,6 +47,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> hass, address, connectable=True ) or await get_device(address) response_result = await mower.connect(device) + if response_result == ResponseResult.INVALID_PIN: + raise ConfigEntryAuthFailed( + f"Unable to connect to device {address} due to wrong PIN" + ) if response_result != ResponseResult.OK: raise ConfigEntryNotReady( f"Unable to connect to device {address}, mower returned {response_result}" diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index 72835c223341df..c8f1cfaf630234 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -2,17 +2,20 @@ from __future__ import annotations +from collections.abc import Mapping import random from typing import Any from automower_ble.mower import Mower +from automower_ble.protocol import ResponseResult from bleak import BleakError +from bleak_retry_connector import get_device import voluptuous as vol from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.config_entries import SOURCE_BLUETOOTH, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from .const import DOMAIN, LOGGER @@ -31,12 +34,17 @@ def _is_supported(discovery_info: BluetoothServiceInfo): service == "98bd0001-0b0e-421a-84e5-ddbf75dc6de4" for service in discovery_info.service_uuids ) - service_generic = any( - service == "00001800-0000-1000-8000-00805f9b34fb" - for service in discovery_info.service_uuids - ) - return manufacturer and service_husqvarna and service_generic + return manufacturer and service_husqvarna + + +def _pin_valid(pin: str) -> bool: + """Check if the pin is valid.""" + try: + int(pin) + except (TypeError, ValueError): + return False + return True class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): @@ -44,9 +52,9 @@ class HusqvarnaAutomowerBleConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize the config flow.""" - self.address: str | None + address: str | None = None + mower_name: str = "" + pin: str | None = None async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo @@ -60,62 +68,244 @@ async def async_step_bluetooth( self.address = discovery_info.address await self.async_set_unique_id(self.address) self._abort_if_unique_id_configured() - return await self.async_step_confirm() + return await self.async_step_bluetooth_confirm() - async def async_step_confirm( + async def async_step_bluetooth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm discovery.""" + """Confirm Bluetooth discovery.""" assert self.address + errors: dict[str, str] = {} - device = bluetooth.async_ble_device_from_address( - self.hass, self.address, connectable=True + if user_input is not None: + if not _pin_valid(user_input[CONF_PIN]): + errors["base"] = "invalid_pin" + else: + self.pin = user_input[CONF_PIN] + return await self.check_mower(user_input) + + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_PIN): str, + }, + ), + user_input, + ), + description_placeholders={"name": self.mower_name or self.address}, + errors=errors, ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial manual step.""" + errors: dict[str, str] = {} + + if user_input is not None: + if not _pin_valid(user_input[CONF_PIN]): + errors["base"] = "invalid_pin" + else: + self.address = user_input[CONF_ADDRESS] + self.pin = user_input[CONF_PIN] + await self.async_set_unique_id(self.address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return await self.check_mower(user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_PIN): str, + }, + ), + user_input, + ), + errors=errors, + ) + + async def probe_mower(self, device) -> str | None: + """Probe the mower to see if it exists.""" channel_id = random.randint(1, 0xFFFFFFFF) + assert self.address + try: (manufacturer, device_type, model) = await Mower( channel_id, self.address ).probe_gatts(device) except (BleakError, TimeoutError) as exception: - LOGGER.exception("Failed to connect to device: %s", exception) - return self.async_abort(reason="cannot_connect") + LOGGER.exception("Failed to probe device (%s): %s", self.address, exception) + return None title = manufacturer + " " + device_type LOGGER.debug("Found device: %s", title) - if user_input is not None: - return self.async_create_entry( - title=title, - data={CONF_ADDRESS: self.address, CONF_CLIENT_ID: channel_id}, - ) + return title - self.context["title_placeholders"] = { - "name": title, - } + async def connect_mower(self, device) -> tuple[int, Mower]: + """Connect to the Mower.""" + assert self.address + assert self.pin is not None - self._set_confirm_only() - return self.async_show_form( - step_id="confirm", - description_placeholders=self.context["title_placeholders"], + channel_id = random.randint(1, 0xFFFFFFFF) + mower = Mower(channel_id, self.address, int(self.pin)) + + return (channel_id, mower) + + async def check_mower( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Check that the mower exists and is setup.""" + assert self.address + + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True ) - async def async_step_user( + title = await self.probe_mower(device) + if title is None: + return self.async_abort(reason="cannot_connect") + self.mower_name = title + + try: + errors: dict[str, str] = {} + + (channel_id, mower) = await self.connect_mower(device) + + response_result = await mower.connect(device) + await mower.disconnect() + + if response_result is not ResponseResult.OK: + LOGGER.debug("cannot connect, response: %s", response_result) + + if ( + response_result is ResponseResult.INVALID_PIN + or response_result is ResponseResult.NOT_ALLOWED + ): + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + + if self.source == SOURCE_BLUETOOTH: + return self.async_show_form( + step_id="bluetooth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PIN): str, + }, + ), + description_placeholders={ + "name": self.mower_name or self.address + }, + errors=errors, + ) + + suggested_values = {} + + if self.address: + suggested_values[CONF_ADDRESS] = self.address + if self.pin: + suggested_values[CONF_PIN] = self.pin + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_ADDRESS): str, + vol.Required(CONF_PIN): str, + }, + ), + suggested_values, + ), + errors=errors, + ) + except (TimeoutError, BleakError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry( + title=title, + data={ + CONF_ADDRESS: self.address, + CONF_CLIENT_ID: channel_id, + CONF_PIN: self.pin, + }, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + reauth_entry = self._get_reauth_entry() + self.address = reauth_entry.data[CONF_ADDRESS] + self.mower_name = reauth_entry.title + self.pin = reauth_entry.data.get(CONF_PIN, "") + + self.context["title_placeholders"] = { + "name": self.mower_name, + "address": self.address, + } + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" - if user_input is not None: - self.address = user_input[CONF_ADDRESS] - await self.async_set_unique_id(self.address, raise_on_progress=False) - self._abort_if_unique_id_configured() - return await self.async_step_confirm() + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + if user_input is not None and not _pin_valid(user_input[CONF_PIN]): + errors["base"] = "invalid_pin" + elif user_input is not None: + reauth_entry = self._get_reauth_entry() + self.pin = user_input[CONF_PIN] + + try: + assert self.address + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) or await get_device(self.address) + + mower = Mower( + reauth_entry.data[CONF_CLIENT_ID], self.address, int(self.pin) + ) + + response_result = await mower.connect(device) + await mower.disconnect() + if ( + response_result is ResponseResult.INVALID_PIN + or response_result is ResponseResult.NOT_ALLOWED + ): + errors["base"] = "invalid_auth" + elif response_result is not ResponseResult.OK: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data=reauth_entry.data | {CONF_PIN: self.pin}, + ) + + except (TimeoutError, BleakError): + # We don't want to abort a reauth flow when we can't connect, so + # we just show the form again with an error. + errors["base"] = "cannot_connect" return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_ADDRESS): str, - }, + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_PIN): str, + }, + ), + {CONF_PIN: self.pin}, ), + description_placeholders={"name": self.mower_name}, + errors=errors, ) diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py index 78d39ddd96a629..ffe05bac8a8887 100644 --- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py @@ -73,6 +73,10 @@ def _get_activity(self) -> LawnMowerActivity | None: if state in (MowerState.STOPPED, MowerState.OFF, MowerState.WAIT_FOR_SAFETYPIN): # This is actually stopped, but that isn't an option return LawnMowerActivity.ERROR + if state == MowerState.PENDING_START and activity == MowerActivity.NONE: + # This happens when the mower is safety stopped and we try to send a + # command to start it. + return LawnMowerActivity.ERROR if state in ( MowerState.RESTRICTED, MowerState.IN_OPERATION, diff --git a/homeassistant/components/husqvarna_automower_ble/strings.json b/homeassistant/components/husqvarna_automower_ble/strings.json index de0a140933acd9..64ae632330cee8 100644 --- a/homeassistant/components/husqvarna_automower_ble/strings.json +++ b/homeassistant/components/husqvarna_automower_ble/strings.json @@ -4,18 +4,49 @@ "step": { "user": { "data": { - "address": "Device BLE address" + "address": "Device BLE address", + "pin": "Mower PIN" + }, + "data_description": { + "pin": "The PIN used to secure the mower" } }, - "confirm": { - "description": "Do you want to set up {name}? Make sure the mower is in pairing mode" + "bluetooth_confirm": { + "description": "Do you want to set up {name}?\nMake sure the mower is in pairing mode.", + "data": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data_description::pin%]" + } + }, + "reauth_confirm": { + "description": "Please confirm the PIN for {name}.", + "data": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data::pin%]" + }, + "data_description": { + "pin": "[%key:component::husqvarna_automower_ble::config::step::user::data_description::pin%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "Ensure the mower is in pairing mode and try again. It can take a few attempts.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "not_allowed": "Unable to read data from the mower, this usually means it is not paired", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Unable to pair with device, ensure the PIN is correct and the mower is in pairing mode", + "invalid_pin": "The PIN must be a number" + } + }, + "exceptions": { + "pin_required": { + "message": "PIN is required for {domain_name}" } } } diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 64b1a6b05fa29a..72b92cdcb9d61b 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -7,10 +7,7 @@ import voluptuous as vol from homeassistant.components import alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanelEntityFeature, - AlarmControlPanelState, -) +from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant, callback @@ -21,12 +18,33 @@ from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( + ALARM_CONTROL_PANEL_SUPPORTED_FEATURES, + CONF_CODE_ARM_REQUIRED, + CONF_CODE_DISARM_REQUIRED, + CONF_CODE_TRIGGER_REQUIRED, CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, + CONF_PAYLOAD_ARM_AWAY, + CONF_PAYLOAD_ARM_CUSTOM_BYPASS, + CONF_PAYLOAD_ARM_HOME, + CONF_PAYLOAD_ARM_NIGHT, + CONF_PAYLOAD_ARM_VACATION, + CONF_PAYLOAD_DISARM, + CONF_PAYLOAD_TRIGGER, CONF_RETAIN, CONF_STATE_TOPIC, CONF_SUPPORTED_FEATURES, + DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE, + DEFAULT_PAYLOAD_ARM_AWAY, + DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS, + DEFAULT_PAYLOAD_ARM_HOME, + DEFAULT_PAYLOAD_ARM_NIGHT, + DEFAULT_PAYLOAD_ARM_VACATION, + DEFAULT_PAYLOAD_DISARM, + DEFAULT_PAYLOAD_TRIGGER, PAYLOAD_NONE, + REMOTE_CODE, + REMOTE_CODE_TEXT, ) from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -37,26 +55,6 @@ PARALLEL_UPDATES = 0 -_SUPPORTED_FEATURES = { - "arm_home": AlarmControlPanelEntityFeature.ARM_HOME, - "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY, - "arm_night": AlarmControlPanelEntityFeature.ARM_NIGHT, - "arm_vacation": AlarmControlPanelEntityFeature.ARM_VACATION, - "arm_custom_bypass": AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, - "trigger": AlarmControlPanelEntityFeature.TRIGGER, -} - -CONF_CODE_ARM_REQUIRED = "code_arm_required" -CONF_CODE_DISARM_REQUIRED = "code_disarm_required" -CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" -CONF_PAYLOAD_DISARM = "payload_disarm" -CONF_PAYLOAD_ARM_HOME = "payload_arm_home" -CONF_PAYLOAD_ARM_AWAY = "payload_arm_away" -CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night" -CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation" -CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass" -CONF_PAYLOAD_TRIGGER = "payload_trigger" - MQTT_ALARM_ATTRIBUTES_BLOCKED = frozenset( { alarm.ATTR_CHANGED_BY, @@ -65,44 +63,40 @@ } ) -DEFAULT_COMMAND_TEMPLATE = "{{action}}" -DEFAULT_ARM_NIGHT = "ARM_NIGHT" -DEFAULT_ARM_VACATION = "ARM_VACATION" -DEFAULT_ARM_AWAY = "ARM_AWAY" -DEFAULT_ARM_HOME = "ARM_HOME" -DEFAULT_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS" -DEFAULT_DISARM = "DISARM" -DEFAULT_TRIGGER = "TRIGGER" DEFAULT_NAME = "MQTT Alarm" -REMOTE_CODE = "REMOTE_CODE" -REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT" - PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_SUPPORTED_FEATURES, default=list(_SUPPORTED_FEATURES)): [ - vol.In(_SUPPORTED_FEATURES) - ], + vol.Optional( + CONF_SUPPORTED_FEATURES, + default=list(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES), + ): [vol.In(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES)], vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean, vol.Optional(CONF_CODE_TRIGGER_REQUIRED, default=True): cv.boolean, vol.Optional( - CONF_COMMAND_TEMPLATE, default=DEFAULT_COMMAND_TEMPLATE + CONF_COMMAND_TEMPLATE, default=DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE ): cv.template, vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_NAME): vol.Any(cv.string, None), - vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, - vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, - vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, vol.Optional( - CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_ARM_VACATION + CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_PAYLOAD_ARM_AWAY + ): cv.string, + vol.Optional( + CONF_PAYLOAD_ARM_HOME, default=DEFAULT_PAYLOAD_ARM_HOME + ): cv.string, + vol.Optional( + CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_PAYLOAD_ARM_NIGHT + ): cv.string, + vol.Optional( + CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_PAYLOAD_ARM_VACATION ): cv.string, vol.Optional( - CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_ARM_CUSTOM_BYPASS + CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS ): cv.string, - vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, - vol.Optional(CONF_PAYLOAD_TRIGGER, default=DEFAULT_TRIGGER): cv.string, + vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_PAYLOAD_DISARM): cv.string, + vol.Optional(CONF_PAYLOAD_TRIGGER, default=DEFAULT_PAYLOAD_TRIGGER): cv.string, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Required(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -152,7 +146,9 @@ def _setup_from_config(self, config: ConfigType) -> None: ).async_render for feature in self._config[CONF_SUPPORTED_FEATURES]: - self._attr_supported_features |= _SUPPORTED_FEATURES[feature] + self._attr_supported_features |= ALARM_CONTROL_PANEL_SUPPORTED_FEATURES[ + feature + ] if (code := self._config.get(CONF_CODE)) is None: self._attr_code_format = None diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a8a4c2e95384ce..b85b01f92c384f 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -71,6 +71,7 @@ ATTR_SW_VERSION, CONF_BRIGHTNESS, CONF_CLIENT_ID, + CONF_CODE, CONF_DEVICE, CONF_DEVICE_CLASS, CONF_DISCOVERY, @@ -129,6 +130,7 @@ from .addon import get_addon_manager from .client import MqttClientSetup from .const import ( + ALARM_CONTROL_PANEL_SUPPORTED_FEATURES, ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, @@ -149,7 +151,10 @@ CONF_CERTIFICATE, CONF_CLIENT_CERT, CONF_CLIENT_KEY, + CONF_CODE_ARM_REQUIRED, + CONF_CODE_DISARM_REQUIRED, CONF_CODE_FORMAT, + CONF_CODE_TRIGGER_REQUIRED, CONF_COLOR_MODE_STATE_TOPIC, CONF_COLOR_MODE_VALUE_TEMPLATE, CONF_COLOR_TEMP_COMMAND_TEMPLATE, @@ -216,6 +221,11 @@ CONF_OSCILLATION_COMMAND_TOPIC, CONF_OSCILLATION_STATE_TOPIC, CONF_OSCILLATION_VALUE_TEMPLATE, + CONF_PAYLOAD_ARM_AWAY, + CONF_PAYLOAD_ARM_CUSTOM_BYPASS, + CONF_PAYLOAD_ARM_HOME, + CONF_PAYLOAD_ARM_NIGHT, + CONF_PAYLOAD_ARM_VACATION, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_CLOSE, CONF_PAYLOAD_LOCK, @@ -229,6 +239,7 @@ CONF_PAYLOAD_RESET_PRESET_MODE, CONF_PAYLOAD_STOP, CONF_PAYLOAD_STOP_TILT, + CONF_PAYLOAD_TRIGGER, CONF_PAYLOAD_UNLOCK, CONF_PERCENTAGE_COMMAND_TEMPLATE, CONF_PERCENTAGE_COMMAND_TOPIC, @@ -280,6 +291,7 @@ CONF_STATE_VALUE_TEMPLATE, CONF_SUGGESTED_DISPLAY_PRECISION, CONF_SUPPORTED_COLOR_MODES, + CONF_SUPPORTED_FEATURES, CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE, CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, CONF_SWING_HORIZONTAL_MODE_LIST, @@ -329,12 +341,18 @@ CONF_XY_VALUE_TEMPLATE, CONFIG_ENTRY_MINOR_VERSION, CONFIG_ENTRY_VERSION, + DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE, DEFAULT_BIRTH, DEFAULT_CLIMATE_INITIAL_TEMPERATURE, DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_KEEPALIVE, DEFAULT_ON_COMMAND_TYPE, + DEFAULT_PAYLOAD_ARM_AWAY, + DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS, + DEFAULT_PAYLOAD_ARM_HOME, + DEFAULT_PAYLOAD_ARM_NIGHT, + DEFAULT_PAYLOAD_ARM_VACATION, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_LOCK, @@ -347,6 +365,7 @@ DEFAULT_PAYLOAD_PRESS, DEFAULT_PAYLOAD_RESET, DEFAULT_PAYLOAD_STOP, + DEFAULT_PAYLOAD_TRIGGER, DEFAULT_PAYLOAD_UNLOCK, DEFAULT_PORT, DEFAULT_POSITION_CLOSED, @@ -370,6 +389,8 @@ DEFAULT_WILL, DEFAULT_WS_PATH, DOMAIN, + REMOTE_CODE, + REMOTE_CODE_TEXT, SUPPORTED_PROTOCOLS, TRANSPORT_TCP, TRANSPORT_WEBSOCKETS, @@ -468,6 +489,7 @@ # Subentry selectors SUBENTRY_PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, @@ -573,6 +595,21 @@ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) ) +# Alarm control panel selectors +ALARM_CONTROL_PANEL_FEATURES_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=list(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES), + multiple=True, + translation_key="alarm_control_panel_features", + ) +) +ALARM_CONTROL_PANEL_CODE_MODE = SelectSelector( + SelectSelectorConfig( + options=["local_code", "remote_code", "remote_code_text"], + translation_key="alarm_control_panel_code_mode", + ) +) + # Climate specific selectors CLIMATE_MODE_SELECTOR = SelectSelector( SelectSelectorConfig( @@ -729,6 +766,25 @@ def configured_target_temperature_feature(config: dict[str, Any]) -> str: vol.Coerce(int), ) +_CODE_VALIDATION_MODE = { + "remote_code": REMOTE_CODE, + "remote_code_text": REMOTE_CODE_TEXT, +} + + +@callback +def default_alarm_control_panel_code(config: dict[str, Any]) -> str: + """Return alarm control panel code based on the stored code and code mode.""" + code: str + if config["alarm_control_panel_code_mode"] in _CODE_VALIDATION_MODE: + # Return magic value for remote code validation + return _CODE_VALIDATION_MODE[config["alarm_control_panel_code_mode"]] + if (code := config.get(CONF_CODE, "")) in _CODE_VALIDATION_MODE.values(): + # Remove magic value for remote code validation + return "" + + return code + @callback def temperature_default_from_celsius_to_system_default( @@ -925,6 +981,7 @@ class PlatformField: vol.UNDEFINED ) is_schema_default: bool = False + include_in_config: bool = False exclude_from_reconfig: bool = False exclude_from_config: bool = False conditions: tuple[dict[str, Any], ...] | None = None @@ -995,6 +1052,23 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: } PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { + Platform.ALARM_CONTROL_PANEL.value: { + CONF_SUPPORTED_FEATURES: PlatformField( + selector=ALARM_CONTROL_PANEL_FEATURES_SELECTOR, + required=True, + default=lambda config: config.get( + CONF_SUPPORTED_FEATURES, list(ALARM_CONTROL_PANEL_SUPPORTED_FEATURES) + ), + ), + "alarm_control_panel_code_mode": PlatformField( + selector=ALARM_CONTROL_PANEL_CODE_MODE, + required=True, + exclude_from_config=True, + default=lambda config: config[CONF_CODE].lower() + if config.get(CONF_CODE) in (REMOTE_CODE, REMOTE_CODE_TEXT) + else "local_code", + ), + }, Platform.BINARY_SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR, @@ -1168,6 +1242,92 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: Platform.LOCK.value: {}, } PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = { + Platform.ALARM_CONTROL_PANEL: { + CONF_COMMAND_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_publish_topic, + error="invalid_publish_topic", + ), + CONF_COMMAND_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + default=DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_STATE_TOPIC: PlatformField( + selector=TEXT_SELECTOR, + required=True, + validator=valid_subscribe_topic, + error="invalid_subscribe_topic", + ), + CONF_VALUE_TEMPLATE: PlatformField( + selector=TEMPLATE_SELECTOR, + required=False, + validator=validate(cv.template), + error="invalid_template", + ), + CONF_CODE: PlatformField( + selector=PASSWORD_SELECTOR, + required=True, + include_in_config=True, + default=default_alarm_control_panel_code, + conditions=({"alarm_control_panel_code_mode": "local_code"},), + ), + CONF_CODE_ARM_REQUIRED: PlatformField( + selector=BOOLEAN_SELECTOR, + required=True, + default=True, + ), + CONF_CODE_DISARM_REQUIRED: PlatformField( + selector=BOOLEAN_SELECTOR, + required=True, + default=True, + ), + CONF_CODE_TRIGGER_REQUIRED: PlatformField( + selector=BOOLEAN_SELECTOR, + required=True, + default=True, + ), + CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False), + CONF_PAYLOAD_ARM_HOME: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ARM_HOME, + section="alarm_control_panel_payload_settings", + ), + CONF_PAYLOAD_ARM_AWAY: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ARM_AWAY, + section="alarm_control_panel_payload_settings", + ), + CONF_PAYLOAD_ARM_NIGHT: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ARM_NIGHT, + section="alarm_control_panel_payload_settings", + ), + CONF_PAYLOAD_ARM_VACATION: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ARM_VACATION, + section="alarm_control_panel_payload_settings", + ), + CONF_PAYLOAD_ARM_CUSTOM_BYPASS: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS, + section="alarm_control_panel_payload_settings", + ), + CONF_PAYLOAD_TRIGGER: PlatformField( + selector=TEXT_SELECTOR, + required=False, + default=DEFAULT_PAYLOAD_TRIGGER, + section="alarm_control_panel_payload_settings", + ), + }, Platform.BINARY_SENSOR.value: { CONF_STATE_TOPIC: PlatformField( selector=TEXT_SELECTOR, @@ -2774,6 +2934,7 @@ def validate_light_platform_config(user_data: dict[str, Any]) -> dict[str, str]: str, Callable[[dict[str, Any]], dict[str, str]] | None, ] = { + Platform.ALARM_CONTROL_PANEL: None, Platform.BINARY_SENSOR.value: None, Platform.BUTTON.value: None, Platform.CLIMATE.value: validate_climate_platform_config, @@ -2969,13 +3130,24 @@ def get_default(field_details: PlatformField) -> Any: data_schema: dict[Any, Any] = {} all_data_element_options: set[Any] = set() no_reconfig_options: set[Any] = set() + + defaults: dict[str, Any] = {} + for field_name, field_details in data_schema_fields.items(): + default = defaults[field_name] = get_default(field_details) + if not field_details.include_in_config or component_data is None: + continue + component_data[field_name] = default + for schema_section in sections: + # Always calculate the default values + # Getting the default value may update the subentry data, + # even when and option is filtered out data_schema_element = { - vol.Required(field_name, default=get_default(field_details)) + vol.Required(field_name, default=defaults[field_name]) if field_details.required else vol.Optional( field_name, - default=get_default(field_details) + default=defaults[field_name] if field_details.default is not None else vol.UNDEFINED, ): field_details.selector(component_data_with_user_input or {}) @@ -3024,12 +3196,16 @@ def get_default(field_details: PlatformField) -> Any: ) # Reset all fields from the component_data not in the schema + # except for options that should stay included if component_data: filtered_fields = ( set(data_schema_fields) - all_data_element_options - no_reconfig_options ) for field in filtered_fields: - if field in component_data: + if ( + field in component_data + and not data_schema_fields[field].include_in_config + ): del component_data[field] return vol.Schema(data_schema) @@ -3591,6 +3767,7 @@ def update_component_fields( for field, platform_field in data_schema_fields.items() if field in (set(component_data) - set(config)) and not platform_field.exclude_from_reconfig + and not platform_field.include_in_config ): component_data.pop(field) component_data.update(merged_user_input) @@ -3906,7 +4083,10 @@ def _async_update_component_data_defaults(self) -> None: ) component_data.update(subentry_default_data) for key, platform_field in platform_fields.items(): - if not platform_field.exclude_from_config: + if ( + not platform_field.exclude_from_config + or platform_field.include_in_config + ): continue if key in component_data: component_data.pop(key) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 2128b55c4b0a78..d1feb25b281399 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,6 +4,7 @@ import jinja2 +from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, Platform from homeassistant.exceptions import TemplateError @@ -31,7 +32,10 @@ CONF_AVAILABILITY_TOPIC = "availability_topic" CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" +CONF_CODE_ARM_REQUIRED = "code_arm_required" +CONF_CODE_DISARM_REQUIRED = "code_disarm_required" CONF_CODE_FORMAT = "code_format" +CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required" CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TOPIC = "command_topic" CONF_DISCOVERY_PREFIX = "discovery_prefix" @@ -127,7 +131,13 @@ CONF_OSCILLATION_COMMAND_TEMPLATE = "oscillation_command_template" CONF_OSCILLATION_STATE_TOPIC = "oscillation_state_topic" CONF_OSCILLATION_VALUE_TEMPLATE = "oscillation_value_template" +CONF_PAYLOAD_ARM_AWAY = "payload_arm_away" +CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass" +CONF_PAYLOAD_ARM_HOME = "payload_arm_home" +CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night" +CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation" CONF_PAYLOAD_CLOSE = "payload_close" +CONF_PAYLOAD_DISARM = "payload_disarm" CONF_PAYLOAD_LOCK = "payload_lock" CONF_PAYLOAD_OPEN = "payload_open" CONF_PAYLOAD_OSCILLATION_OFF = "payload_oscillation_off" @@ -137,6 +147,7 @@ CONF_PAYLOAD_RESET_PRESET_MODE = "payload_reset_preset_mode" CONF_PAYLOAD_STOP = "payload_stop" CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt" +CONF_PAYLOAD_TRIGGER = "payload_trigger" CONF_PAYLOAD_UNLOCK = "payload_unlock" CONF_PERCENTAGE_COMMAND_TEMPLATE = "percentage_command_template" CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" @@ -247,6 +258,7 @@ CONF_OBJECT_ID = "object_id" CONF_SUPPORT_URL = "support_url" +DEFAULT_ALARM_CONTROL_PANEL_COMMAND_TEMPLATE = "{{action}}" DEFAULT_BRIGHTNESS = False DEFAULT_BRIGHTNESS_SCALE = 255 DEFAULT_CLIMATE_INITIAL_TEMPERATURE = 21.0 @@ -260,8 +272,15 @@ DEFAULT_OPTIMISTIC = False DEFAULT_ON_COMMAND_TYPE = "last" DEFAULT_QOS = 0 + +DEFAULT_PAYLOAD_ARM_AWAY = "ARM_AWAY" +DEFAULT_PAYLOAD_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS" +DEFAULT_PAYLOAD_ARM_HOME = "ARM_HOME" +DEFAULT_PAYLOAD_ARM_NIGHT = "ARM_NIGHT" +DEFAULT_PAYLOAD_ARM_VACATION = "ARM_VACATION" DEFAULT_PAYLOAD_AVAILABLE = "online" DEFAULT_PAYLOAD_CLOSE = "CLOSE" +DEFAULT_PAYLOAD_DISARM = "DISARM" DEFAULT_PAYLOAD_LOCK = "LOCK" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" DEFAULT_PAYLOAD_OFF = "OFF" @@ -270,10 +289,10 @@ DEFAULT_PAYLOAD_OSCILLATE_OFF = "oscillate_off" DEFAULT_PAYLOAD_OSCILLATE_ON = "oscillate_on" DEFAULT_PAYLOAD_PRESS = "PRESS" -DEFAULT_PAYLOAD_STOP = "STOP" DEFAULT_PAYLOAD_RESET = "None" +DEFAULT_PAYLOAD_STOP = "STOP" +DEFAULT_PAYLOAD_TRIGGER = "TRIGGER" DEFAULT_PAYLOAD_UNLOCK = "UNLOCK" - DEFAULT_PORT = 1883 DEFAULT_RETAIN = False DEFAULT_TILT_CLOSED_POSITION = 0 @@ -303,6 +322,17 @@ VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"] +ALARM_CONTROL_PANEL_SUPPORTED_FEATURES = { + "arm_home": AlarmControlPanelEntityFeature.ARM_HOME, + "arm_away": AlarmControlPanelEntityFeature.ARM_AWAY, + "arm_night": AlarmControlPanelEntityFeature.ARM_NIGHT, + "arm_vacation": AlarmControlPanelEntityFeature.ARM_VACATION, + "arm_custom_bypass": AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + "trigger": AlarmControlPanelEntityFeature.TRIGGER, +} +REMOTE_CODE = "REMOTE_CODE" +REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT" + PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" PROTOCOL_5 = "5" diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 3844cf8d669f3b..fa615ed1f9194c 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -243,6 +243,7 @@ "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { + "alarm_control_panel_code_mode": "Alarm code validation mode", "climate_feature_action": "Current action support", "climate_feature_current_humidity": "Current humidity support", "climate_feature_current_temperature": "Current temperature support", @@ -263,10 +264,12 @@ "schema": "Schema", "state_class": "State class", "suggested_display_precision": "Suggested display precision", + "supported_features": "Supported features", "temperature_unit": "Temperature unit", "unit_of_measurement": "Unit of measurement" }, "data_description": { + "alarm_control_panel_code_mode": "Configures how the alarm control panel validates the code. A local code is configured with the entity and is validated by Home Assistant. A remote code is sent to the device and validated remotely. [Learn more.]({url}#code)", "climate_feature_action": "The climate supports reporting the current action.", "climate_feature_current_humidity": "The climate supports reporting the current humidity.", "climate_feature_current_temperature": "The climate supports reporting the current temperature.", @@ -287,6 +290,7 @@ "schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)", "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)", + "supported_features": "The features that the entity supports.", "temperature_unit": "This determines the native unit of measurement the MQTT climate device works with.", "unit_of_measurement": "Defines the unit of measurement of the sensor, if any." }, @@ -308,7 +312,11 @@ "data": { "blue_template": "Blue template", "brightness_template": "Brightness template", + "code": "Alarm code", "code_format": "Code format", + "code_arm_required": "Code arm required", + "code_disarm_required": "Code disarm required", + "code_trigger_required": "Code trigger required", "command_template": "Command template", "command_topic": "Command topic", "command_off_template": "Command \"off\" template", @@ -341,10 +349,14 @@ "data_description": { "blue_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract blue color from the state payload value. Expected result of the template is an integer from 0-255 range.", "brightness_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract brightness from the state payload value. Expected result of the template is an integer from 0-255 range.", + "code": "Specifies a code to enable or disable the alarm in the frontend. Note that this blocks sending MQTT message commands to the remote device if the code validation fails. [Learn more.]({url}#code)", "code_format": "A regular expression to validate a supplied code when it is set during the action to open, lock or unlock the MQTT lock. [Learn more.]({url}#code_format)", + "code_arm_required": "If set, the code is required to arm the alarm. If not set, the code is not validated.", + "code_disarm_required": "If set, the code is required to disarm the alarm. If not set, the code is not validated.", + "code_trigger_required": "If set, the code is required to manually trigger the alarm. If not set, the code is not validated.", "command_off_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"off\" state changes. Available variables are: `state` and `transition`.", "command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.", - "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", + "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)", "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", "color_temp_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract color temperature in Kelvin from the state payload value. Expected result of the template is an integer.", "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", @@ -394,6 +406,27 @@ "transition": "Enable the transition feature for this light" } }, + "alarm_control_panel_payload_settings": { + "name": "Alarm control panel payload settings", + "data": { + "payload_arm_away": "Payload \"arm away\"", + "payload_arm_custom_bypass": "Payload \"arm custom bypass\"", + "payload_arm_disarm": "Payload \"disarm\"", + "payload_arm_home": "Payload \"arm home\"", + "payload_arm_night": "Payload \"arm night\"", + "payload_arm_vacation": "Payload \"arm vacation\"", + "payload_trigger": "Payload \"trigger alarm\"" + }, + "data_description": { + "payload_arm_away": "The payload sent when an \"arm away\" command is issued.", + "payload_arm_custom_bypass": "The payload sent when an \"arm custom bypass\" command is issued.", + "payload_arm_disarm": "The payload sent when a \"disarm\" command is issued.", + "payload_arm_home": "The payload sent when an \"arm home\" command is issued.", + "payload_arm_night": "The payload sent when an \"arm night\" command is issued.", + "payload_arm_vacation": "The payload sent when an \"arm vacation\" command is issued.", + "payload_trigger": "The payload sent when a \"trigger alarm\" command is issued." + } + }, "climate_action_settings": { "name": "Current action settings", "data": { @@ -1070,6 +1103,23 @@ } }, "selector": { + "alarm_control_panel_code_mode": { + "options": { + "local_code": "Local code validation", + "remote_code": "Numeric remote code validation", + "remote_code_text": "Text remote code validation" + } + }, + "alarm_control_panel_features": { + "options": { + "arm_away": "[%key:component::alarm_control_panel::services::alarm_arm_away::name%]", + "arm_custom_bypass": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::name%]", + "arm_home": "[%key:component::alarm_control_panel::services::alarm_arm_home::name%]", + "arm_night": "[%key:component::alarm_control_panel::services::alarm_arm_night::name%]", + "arm_vacation": "[%key:component::alarm_control_panel::services::alarm_arm_vacation::name%]", + "trigger": "[%key:component::alarm_control_panel::services::alarm_trigger::name%]" + } + }, "climate_modes": { "options": { "off": "[%key:common::state::off%]", @@ -1223,6 +1273,7 @@ }, "platform": { "options": { + "alarm_control_panel": "[%key:component::alarm_control_panel::title%]", "binary_sensor": "[%key:component::binary_sensor::title%]", "button": "[%key:component::button::title%]", "climate": "[%key:component::climate::title%]", diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 0dd8646b17ed05..46e9a121649c3b 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -15,6 +15,7 @@ DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, ) +from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.const import ( ATTR_EDITABLE, ATTR_GPS_ACCURACY, @@ -464,7 +465,7 @@ async def async_added_to_hass(self) -> None: """Register device trackers.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): - self._parse_source_state(state) + self._parse_source_state(state, state) if self.hass.is_running: # Update person now if hass is already running. @@ -514,7 +515,7 @@ def _async_handle_tracker_update(self, event: Event[EventStateChangedData]) -> N @callback def _update_state(self) -> None: """Update the state.""" - latest_non_gps_home = latest_not_home = latest_gps = latest = None + latest_non_gps_home = latest_not_home = latest_gps = latest = coordinates = None for entity_id in self._config[CONF_DEVICE_TRACKERS]: state = self.hass.states.get(entity_id) @@ -530,13 +531,23 @@ def _update_state(self) -> None: if latest_non_gps_home: latest = latest_non_gps_home + if ( + latest_non_gps_home.attributes.get(ATTR_LATITUDE) is None + and latest_non_gps_home.attributes.get(ATTR_LONGITUDE) is None + and (home_zone := self.hass.states.get(ENTITY_ID_HOME)) + ): + coordinates = home_zone + else: + coordinates = latest_non_gps_home elif latest_gps: latest = latest_gps + coordinates = latest_gps else: latest = latest_not_home + coordinates = latest_not_home - if latest: - self._parse_source_state(latest) + if latest and coordinates: + self._parse_source_state(latest, coordinates) else: self._attr_state = None self._source = None @@ -548,15 +559,15 @@ def _update_state(self) -> None: self.async_write_ha_state() @callback - def _parse_source_state(self, state: State) -> None: + def _parse_source_state(self, state: State, coordinates: State) -> None: """Parse source state and set person attributes. This is a device tracker state or the restored person state. """ self._attr_state = state.state self._source = state.entity_id - self._latitude = state.attributes.get(ATTR_LATITUDE) - self._longitude = state.attributes.get(ATTR_LONGITUDE) + self._latitude = coordinates.attributes.get(ATTR_LATITUDE) + self._longitude = coordinates.attributes.get(ATTR_LONGITUDE) self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) @callback diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json index 0c1792e9277394..46ccf85db4a69e 100644 --- a/homeassistant/components/person/manifest.json +++ b/homeassistant/components/person/manifest.json @@ -2,7 +2,7 @@ "domain": "person", "name": "Person", "codeowners": [], - "dependencies": ["image_upload", "http"], + "dependencies": ["image_upload", "http", "zone"], "documentation": "https://www.home-assistant.io/integrations/person", "integration_type": "system", "iot_class": "calculated", diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index bb6a0669a99767..a3bed652876837 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -47,7 +47,7 @@ class SIAAlarmControlPanelEntityDescription( "CP": AlarmControlPanelState.ARMED_AWAY, "CQ": AlarmControlPanelState.ARMED_AWAY, "CS": AlarmControlPanelState.ARMED_AWAY, - "CF": AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + "CF": AlarmControlPanelState.ARMED_AWAY, "NP": AlarmControlPanelState.DISARMED, "NO": AlarmControlPanelState.DISARMED, "OA": AlarmControlPanelState.DISARMED, diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 5ff2c0137ac41a..23b3608d5e066b 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -26,6 +26,7 @@ vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_OBJECT_ID): cv.string, } ) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 6ed8f0253ab476..fc408531a386ff 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -154,8 +154,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool device_registry = dr.async_get(hass) for device in manager.device_map.values(): LOGGER.debug( - "Register device %s: %s (function: %s, status range: %s)", + "Register device %s (online: %s): %s (function: %s, status range: %s)", device.id, + device.online, device.status, device.function, device.status_range, @@ -231,9 +232,10 @@ def update_device( ) -> None: """Update device status.""" LOGGER.debug( - "Received update for device %s: %s (updated properties: %s)", + "Received update for device %s (online: %s): %s (updated properties: %s)", device.id, - self.manager.device_map[device.id].status, + device.online, + device.status, updated_status_properties, ) dispatcher_send( @@ -248,8 +250,9 @@ def add_device(self, device: CustomerDevice) -> None: self.hass.add_job(self.async_remove_device, device.id) LOGGER.debug( - "Add device %s: %s (function: %s, status range: %s)", + "Add device %s (online: %s): %s (function: %s, status range: %s)", device.id, + device.online, device.status, device.function, device.status_range, diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index e08d4bcf545be3..15d5d2c89add34 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -1,4 +1,4 @@ -"""Support for rain sensors build into some velux windows.""" +"""Support for rain sensors built into some Velux windows.""" from __future__ import annotations @@ -44,12 +44,12 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity): _attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices _attr_entity_registry_enabled_default = False _attr_device_class = BinarySensorDeviceClass.MOISTURE + _attr_translation_key = "rain_sensor" def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: """Initialize VeluxRainSensor.""" super().__init__(node, config_entry_id) self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor" - self._attr_name = f"{node.name} Rain sensor" async def async_update(self) -> None: """Fetch the latest state from the device.""" diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index d6bf8905d91cb8..32be29c3c91425 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -5,7 +5,7 @@ from typing import Any, cast from pyvlx import OpeningDevice, Position -from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window +from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter from homeassistant.components.cover import ( ATTR_POSITION, @@ -44,9 +44,13 @@ class VeluxCover(VeluxEntity, CoverEntity): _is_blind = False node: OpeningDevice + # Do not name the "main" feature of the device (position control) + _attr_name = None + def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: """Initialize VeluxCover.""" super().__init__(node, config_entry_id) + # Window is the default device class for covers self._attr_device_class = CoverDeviceClass.WINDOW if isinstance(node, Awning): self._attr_device_class = CoverDeviceClass.AWNING @@ -59,8 +63,6 @@ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: self._attr_device_class = CoverDeviceClass.GATE if isinstance(node, RollerShutter): self._attr_device_class = CoverDeviceClass.SHUTTER - if isinstance(node, Window): - self._attr_device_class = CoverDeviceClass.WINDOW @property def supported_features(self) -> CoverEntityFeature: diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index 1231a98e0a83cd..fa06598f97958a 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -3,13 +3,17 @@ from pyvlx import Node from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from .const import DOMAIN + class VeluxEntity(Entity): - """Abstraction for al Velux entities.""" + """Abstraction for all Velux entities.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, node: Node, config_entry_id: str) -> None: """Initialize the Velux device.""" @@ -19,7 +23,18 @@ def __init__(self, node: Node, config_entry_id: str) -> None: if node.serial_number else f"{config_entry_id}_{node.node_id}" ) - self._attr_name = node.name if node.name else f"#{node.node_id}" + self._attr_device_info = DeviceInfo( + identifiers={ + ( + DOMAIN, + node.serial_number + if node.serial_number + else f"{config_entry_id}_{node.node_id}", + ) + }, + name=node.name if node.name else f"#{node.node_id}", + serial_number=node.serial_number, + ) @callback def async_register_callbacks(self): diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 0cf578732fbae0..5123c59fe4330b 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -27,5 +27,12 @@ "name": "Reboot gateway", "description": "Reboots the KLF200 Gateway." } + }, + "entity": { + "binary_sensor": { + "rain_sensor": { + "name": "Rain sensor" + } + } } } diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index e25ca11e08330f..5e5f50c96fc5ae 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -985,7 +985,7 @@ def async_get_or_create( via_device_id=via_device_id, ) - # This is safe because _async_update_device will always return a device + # This is safe because async_update_device will always return a device # in this use case. assert device return device diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 9b619385d8ccc1..571f914e9d3074 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1187,7 +1187,7 @@ def async_device_modified( return # Ignore device disabled by config entry, this is handled by - # async_config_entry_disabled + # async_config_entry_disabled_by_changed if device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY: return diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 12a59a97903cab..70f121d8c98eb4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 bcrypt==4.3.0 -bleak-retry-connector==4.3.0 +bleak-retry-connector==4.4.1 bleak==1.0.1 bluetooth-adapters==2.0.0 bluetooth-auto-recovery==1.5.2 diff --git a/requirements_all.txt b/requirements_all.txt index 14c6d29ecca714..4d702582df7538 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -204,7 +204,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.2 +aioautomower==2.2.0 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -628,7 +628,7 @@ bizkaibus==0.1.1 bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==4.3.0 +bleak-retry-connector==4.4.1 # homeassistant.components.bluetooth bleak==1.0.1 @@ -1748,9 +1748,6 @@ py-aosmith==1.0.12 # homeassistant.components.canary py-canary==0.5.4 -# homeassistant.components.ccm15 -py-ccm15==0.0.9 - # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 @@ -1823,6 +1820,9 @@ pyW215==0.8.0 # homeassistant.components.w800rf32 pyW800rf32==0.4 +# homeassistant.components.ccm15 +py_ccm15==0.1.2 + # homeassistant.components.ads pyads==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46a7cba3f5aa5d..37a929bb8a06ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2.1.2 +aioautomower==2.2.0 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -562,7 +562,7 @@ bimmer-connected[china]==0.17.2 bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==4.3.0 +bleak-retry-connector==4.4.1 # homeassistant.components.bluetooth bleak==1.0.1 @@ -1474,9 +1474,6 @@ py-aosmith==1.0.12 # homeassistant.components.canary py-canary==0.5.4 -# homeassistant.components.ccm15 -py-ccm15==0.0.9 - # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 @@ -1531,6 +1528,9 @@ pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 +# homeassistant.components.ccm15 +py_ccm15==0.1.2 + # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 1cd6f9b393ec81..02b9b2715a1eac 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -1,7 +1,7 @@ """Test helpers for Husqvarna Automower.""" import asyncio -from collections.abc import Generator +from collections.abc import Callable, Generator import time from unittest.mock import AsyncMock, create_autospec, patch @@ -16,7 +16,7 @@ async_import_client_credential, ) from homeassistant.components.husqvarna_automower.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -137,3 +137,21 @@ async def listen() -> None: spec_set=True, ) yield mock_instance + + +@pytest.fixture +def automower_ws_ready(mock_automower_client: AsyncMock) -> list[Callable[[], None]]: + """Fixture to capture ws_ready_callbacks.""" + + ws_ready_callbacks: list[Callable[[], None]] = [] + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + ws_ready_callbacks.append(cb) + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + mock_automower_client.send_empty_message.return_value = True + + return ws_ready_callbacks diff --git a/tests/components/husqvarna_automower/test_event.py b/tests/components/husqvarna_automower/test_event.py index 6cbfa10297607b..c4121c1cfb87db 100644 --- a/tests/components/husqvarna_automower/test_event.py +++ b/tests/components/husqvarna_automower/test_event.py @@ -33,6 +33,7 @@ async def test_event( mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + automower_ws_ready: list[Callable[[], None]], ) -> None: """Test that a new message arriving over the websocket creates and updates the sensor.""" callbacks: list[Callable[[SingleMessageData], None]] = [] @@ -46,11 +47,17 @@ def fake_register_websocket_response( mock_automower_client.register_single_message_callback.side_effect = ( fake_register_websocket_response ) + mock_automower_client.send_empty_message.return_value = True # Set up integration await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() + # Start the watchdog and let it run once to set websocket_alive=True + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + # Ensure callback was registered for the test mower assert mock_automower_client.register_single_message_callback.called @@ -76,6 +83,7 @@ def fake_register_websocket_response( for cb in callbacks: cb(message) await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") assert state is not None assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" @@ -84,6 +92,12 @@ def fake_register_websocket_response( await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED + + # Start the new watchdog and let it run + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") assert state is not None assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" @@ -129,6 +143,7 @@ def fake_register_websocket_response( for cb in callbacks: cb(message) await hass.async_block_till_done() + entry = entity_registry.async_get("event.test_mower_1_message") assert entry is not None assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" @@ -154,9 +169,9 @@ async def test_event_snapshot( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + automower_ws_ready: list[Callable[[], None]], ) -> None: """Test that a new message arriving over the websocket updates the sensor.""" with patch( @@ -179,6 +194,11 @@ def fake_register_websocket_response( await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() + # Start the watchdog and let it run once + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + # Ensure callback was registered for the test mower assert mock_automower_client.register_single_message_callback.called diff --git a/tests/components/husqvarna_automower_ble/__init__.py b/tests/components/husqvarna_automower_ble/__init__.py index 7ca5aea121d98f..841b6f65516af2 100644 --- a/tests/components/husqvarna_automower_ble/__init__.py +++ b/tests/components/husqvarna_automower_ble/__init__.py @@ -17,7 +17,6 @@ manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, service_uuids=[ "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - "00001800-0000-1000-8000-00805f9b34fb", ], source="local", ) @@ -30,7 +29,6 @@ manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, service_uuids=[ "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - "00001800-0000-1000-8000-00805f9b34fb", ], source="local", ) @@ -43,7 +41,6 @@ manufacturer_data={}, service_uuids=[ "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - "00001800-0000-1000-8000-00805f9b34fb", ], source="local", ) diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 1081db014e3fe6..820edb29059ab6 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from . import AUTOMOWER_SERVICE_INFO @@ -58,6 +58,7 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address, CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", }, unique_id=AUTOMOWER_SERVICE_INFO.address, ) diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index e053a28b7dd473..7b47063975e14f 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -2,19 +2,20 @@ from unittest.mock import Mock, patch +from automower_ble.protocol import ResponseResult from bleak import BleakError import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, AUTOMOWER_SERVICE_INFO, AUTOMOWER_UNNAMED_SERVICE_INFO, - AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO, ) from tests.common import MockConfigEntry @@ -36,8 +37,6 @@ def mock_random() -> Mock: async def test_user_selection(hass: HomeAssistant) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( @@ -48,22 +47,77 @@ async def test_user_selection(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000001"}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Husqvarna Automower" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000001" + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +async def test_user_selection_incorrect_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "ABCD", + }, ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_pin"} + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Husqvarna Automower" - assert result["result"].unique_id == "00000000-0000-0000-0000-000000000001" assert result["data"] == { CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } @@ -74,13 +128,13 @@ async def test_bluetooth(hass: HomeAssistant) -> None: await hass.async_block_till_done(wait_background_tasks=True) result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] - assert result["step_id"] == "confirm" - assert result["context"]["unique_id"] == "00000000-0000-0000-0000-000000000003" + assert result["step_id"] == "bluetooth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={CONF_PIN: "1234"}, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Husqvarna Automower" assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" @@ -88,64 +142,156 @@ async def test_bluetooth(hass: HomeAssistant) -> None: assert result["data"] == { CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } -async def test_bluetooth_invalid(hass: HomeAssistant) -> None: - """Test bluetooth device discovery with invalid data.""" +async def test_bluetooth_incorrect_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "ABCD", + }, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "invalid_pin"} -async def test_failed_connect( + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "1234"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Husqvarna Automower" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" + + assert result["data"] == { + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", + } + + +async def test_bluetooth_unknown_error( hass: HomeAssistant, mock_automower_client: Mock, ) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) - inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) - mock_automower_client.connect.side_effect = False + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PIN: "5678"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + +async def test_bluetooth_not_paired( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + + await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_SERVICE_INFO, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.NOT_ALLOWED result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000001"}, + user_input={CONF_PIN: "5678"}, ) + assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" + assert result["step_id"] == "bluetooth_confirm" + + mock_automower_client.connect.return_value = ResponseResult.OK result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={CONF_PIN: "1234"}, ) + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Husqvarna Automower" - assert result["result"].unique_id == "00000000-0000-0000-0000-000000000001" + assert result["result"].unique_id == "00000000-0000-0000-0000-000000000003" assert result["data"] == { - CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", CONF_CLIENT_ID: 1197489078, + CONF_PIN: "1234", } -async def test_duplicate_entry( +async def test_bluetooth_invalid(hass: HomeAssistant) -> None: + """Test bluetooth device discovery with invalid data.""" + + inject_bluetooth_service_info( + hass, AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO + ) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_successful_reauth( hass: HomeAssistant, mock_automower_client: Mock, mock_config_entry: MockConfigEntry, @@ -154,7 +300,135 @@ async def test_duplicate_entry( mock_config_entry.add_to_hass(hass) - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + # Try non numeric pin + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "ABCD", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_pin"} + + # Try connection error + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Try wrong PIN + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + mock_automower_client.connect.return_value = ResponseResult.OK + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "1234", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries("husqvarna_automower_ble")) == 1 + + assert ( + mock_config_entry.data[CONF_ADDRESS] == "00000000-0000-0000-0000-000000000003" + ) + assert mock_config_entry.data[CONF_CLIENT_ID] == 1197489078 + assert mock_config_entry.data[CONF_PIN] == "1234" + + +async def test_user_unable_to_connect( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test we can select a device.""" + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_failed_reauth( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PIN: "5678", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) await hass.async_block_till_done(wait_background_tasks=True) @@ -169,30 +443,63 @@ async def test_duplicate_entry( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_ADDRESS: "00000000-0000-0000-0000-000000000003"}, + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_PIN: "1234", + }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" -async def test_exception_connect( +async def test_exception_probe( hass: HomeAssistant, mock_automower_client: Mock, ) -> None: """Test we can select a device.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) inject_bluetooth_service_info(hass, AUTOMOWER_UNNAMED_SERVICE_INFO) await hass.async_block_till_done(wait_background_tasks=True) mock_automower_client.probe_gatts.side_effect = BleakError result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] - assert result["step_id"] == "confirm" + assert result["step_id"] == "bluetooth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={}, + user_input={CONF_PIN: "1234"}, ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" + + +async def test_exception_connect( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can select a device.""" + + mock_config_entry.add_to_hass(hass) + + await hass.async_block_till_done(wait_background_tasks=True) + + mock_automower_client.connect.side_effect = BleakError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000001", + CONF_PIN: "1234", + }, + ) + + assert result["type"] is FlowResultType.ABORT diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index 95a0a1f203710e..341cc3c282fe84 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -39,7 +40,38 @@ async def test_setup( assert device_entry == snapshot -async def test_setup_retry_connect( +async def test_setup_missing_pin( + hass: HomeAssistant, + mock_automower_client: Mock, +) -> None: + """Test a setup that was created before PIN support.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + title="My home", + unique_id="397678e5-9995-4a39-9d9f-ae6ba310236c", + data={ + CONF_ADDRESS: "00000000-0000-0000-0000-000000000003", + CONF_CLIENT_ID: "1197489078", + }, + ) + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + hass.config_entries.async_update_entry( + mock_config_entry, + data={**mock_config_entry.data, CONF_PIN: 1234}, + ) + + assert len(hass.config_entries.flow.async_progress()) == 1 + await hass.async_block_till_done() + + +async def test_setup_failed_connect( hass: HomeAssistant, mock_automower_client: Mock, mock_config_entry: MockConfigEntry, @@ -68,3 +100,18 @@ async def test_setup_unknown_error( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_invalid_pin( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unable to connect due to incorrect PIN.""" + mock_automower_client.connect.return_value = ResponseResult.INVALID_PIN + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/husqvarna_automower_ble/test_lawn_mower.py b/tests/components/husqvarna_automower_ble/test_lawn_mower.py index 2a127c785d9726..25e02a43acc84c 100644 --- a/tests/components/husqvarna_automower_ble/test_lawn_mower.py +++ b/tests/components/husqvarna_automower_ble/test_lawn_mower.py @@ -156,7 +156,7 @@ async def test_bleak_error_data_update( # Operational states are mapped according to the activity ( OPERATIONAL_STATES, - [MowerActivity.CHARGING, MowerActivity.NONE, MowerActivity.PARKED], + [MowerActivity.CHARGING, MowerActivity.PARKED], LawnMowerActivity.DOCKED, ), ( @@ -174,6 +174,17 @@ async def test_bleak_error_data_update( [MowerActivity.STOPPED_IN_GARDEN], LawnMowerActivity.ERROR, ), + # Special case for MowerActivity.NONE + ( + [MowerState.IN_OPERATION, MowerState.RESTRICTED], + [MowerActivity.NONE], + LawnMowerActivity.DOCKED, + ), + ( + [MowerState.PENDING_START], + [MowerActivity.NONE], + LawnMowerActivity.ERROR, + ), ], ) async def test_mower_activity_mapping( diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index b3a93ec0cf201e..417b1465aa3176 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -70,6 +70,78 @@ "configuration_url": "http://example.com", } +MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_LOCAL_CODE = { + "4b06357ef8654e8d9c54cee5bb0e9391": { + "platform": "alarm_control_panel", + "name": "Alarm", + "entity_category": "config", + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "1234", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + "supported_features": ["arm_home", "arm_away", "trigger"], + "retain": False, + "entity_picture": "https://example.com/4b06357ef8654e8d9c54cee5bb0e9391", + }, +} +MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE = { + "4b06357ef8654e8d9c54cee5bb0e9392": { + "platform": "alarm_control_panel", + "name": "Alarm", + "entity_category": None, + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "REMOTE_CODE", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + "retain": False, + "entity_picture": "https://example.com/4b06357ef8654e8d9c54cee5bb0e9392", + }, +} +MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE_TEXT = { + "4b06357ef8654e8d9c54cee5bb0e9393": { + "platform": "alarm_control_panel", + "name": "Alarm", + "entity_category": None, + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "REMOTE_CODE_TEXT", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + "supported_features": ["arm_home", "arm_away", "arm_vacation"], + "retain": False, + "entity_picture": "https://example.com/4b06357ef8654e8d9c54cee5bb0e9393", + }, +} MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = { "5b06357ef8654e8d9c54cee5bb0e939b": { "platform": "binary_sensor", @@ -444,6 +516,18 @@ "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2, } | MOCK_SUBENTRY_AVAILABILITY_DATA +MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}}, + "components": MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_LOCAL_CODE, +} +MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_TEXT_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE_TEXT, +} +MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE = { + "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 1}}, + "components": MOCK_SUBENTRY_ALARM_CONTROL_PANEL_COMPONENT_REMOTE_CODE, +} MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE = { "device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}}, "components": MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 1c99d9da45fccf..b46b1557aee5ce 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -33,6 +33,9 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( + MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE, + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE, + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_TEXT_SUBENTRY_DATA_SINGLE, MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_BUTTON_SUBENTRY_DATA_SINGLE, MOCK_CLIMATE_HIGH_LOW_SUBENTRY_DATA_SINGLE, @@ -2665,6 +2668,113 @@ async def test_migrate_of_incompatible_config_entry( "entity_name", ), [ + ( + MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, + {"name": "Alarm"}, + { + "entity_category": "config", + "supported_features": ["arm_home", "arm_away", "trigger"], + "alarm_control_panel_code_mode": "local_code", + }, + (), + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code": "1234", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "retain": False, + "alarm_control_panel_payload_settings": { + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + }, + }, + ( + ( + { + "state_topic": "test-topic", + "command_topic": "test-topic#invalid", + }, + {"command_topic": "invalid_publish_topic"}, + ), + ( + { + "command_topic": "test-topic", + "state_topic": "test-topic#invalid", + }, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Milk notifier Alarm", + ), + ( + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, + {"name": "Alarm"}, + { + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + "alarm_control_panel_code_mode": "remote_code", + }, + (), + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "retain": False, + "alarm_control_panel_payload_settings": { + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + }, + }, + (), + "Milk notifier Alarm", + ), + ( + MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_TEXT_SUBENTRY_DATA_SINGLE, + {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, + {"name": "Alarm"}, + { + "supported_features": ["arm_home", "arm_away", "arm_vacation"], + "alarm_control_panel_code_mode": "remote_code_text", + }, + (), + { + "command_topic": "test-topic", + "state_topic": "test-topic", + "command_template": "{{action}}", + "value_template": "{{ value_json.value }}", + "code_arm_required": True, + "code_disarm_required": True, + "code_trigger_required": True, + "retain": False, + "alarm_control_panel_payload_settings": { + "payload_arm_away": "ARM_AWAY", + "payload_arm_custom_bypass": "ARM_CUSTOM_BYPASS", + "payload_arm_home": "ARM_HOME", + "payload_arm_night": "ARM_NIGHT", + "payload_arm_vacation": "ARM_VACATION", + "payload_trigger": "TRIGGER", + }, + }, + (), + "Milk notifier Alarm", + ), ( MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, @@ -3399,6 +3509,9 @@ async def test_migrate_of_incompatible_config_entry( # MOCK_LOCK_SUBENTRY_DATA_SINGLE ], ids=[ + "alarm_control_panel_local_code", + "alarm_control_panel_remote_code", + "alarm_control_panel_remote_code_text", "binary_sensor", "button", "climate_single", @@ -3830,6 +3943,67 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "removed_options", ), [ + ( + ( + ConfigSubentryData( + data=MOCK_ALARM_CONTROL_PANEL_LOCAL_CODE_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "alarm_control_panel_code_mode": "remote_code", + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "retain": True, + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "retain": True, + "code": "REMOTE_CODE", + }, + {"entity_picture"}, + ), + ( + ( + ConfigSubentryData( + data=MOCK_ALARM_CONTROL_PANEL_REMOTE_CODE_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + (), + { + "alarm_control_panel_code_mode": "local_code", + "supported_features": ["arm_home", "arm_away", "arm_custom_bypass"], + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "code": "1234", + "retain": True, + }, + { + "command_topic": "test-topic1-updated", + "command_template": "{{ value }}", + "state_topic": "test-topic1-updated", + "value_template": "{{ value }}", + "code": "1234", + "retain": True, + }, + {"entity_picture"}, + ), ( ( ConfigSubentryData( @@ -4053,7 +4227,15 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( {"entity_picture"}, ), ], - ids=["notify", "sensor", "light_basic", "climate_single", "climate_high_low"], + ids=[ + "alarm_control_panel_local_code", + "alarm_control_panel_remote_code", + "notify", + "sensor", + "light_basic", + "climate_single", + "climate_high_low", + ], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, @@ -4123,7 +4305,6 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "entity_platform_config" # entity platform config flow step assert result["step_id"] == "entity_platform_config" diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index c001da86adb959..81b38f59a3d62d 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -14,7 +14,9 @@ DOMAIN, ) from homeassistant.const import ( + ATTR_EDITABLE, ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, ATTR_GPS_ACCURACY, ATTR_ID, ATTR_LATITUDE, @@ -112,14 +114,19 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> } assert await async_setup_component(hass, DOMAIN, config) + expected_attributes = { + ATTR_DEVICE_TRACKERS: [DEVICE_TRACKER], + ATTR_EDITABLE: False, + ATTR_FRIENDLY_NAME: "tracked person", + ATTR_ID: "1234", + ATTR_USER_ID: user_id, + } + state = hass.states.get("person.tracked_person") assert state.state == STATE_UNKNOWN - assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None - assert state.attributes.get(ATTR_SOURCE) is None - assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes == expected_attributes + # Test home without coordinates hass.states.async_set(DEVICE_TRACKER, "home") await hass.async_block_till_done() @@ -131,13 +138,41 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> state = hass.states.get("person.tracked_person") assert state.state == "home" - assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None - assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER - assert state.attributes.get(ATTR_USER_ID) == user_id - assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [DEVICE_TRACKER] + assert state.attributes == expected_attributes | { + ATTR_LATITUDE: 32.87336, + ATTR_LONGITUDE: -117.22743, + ATTR_SOURCE: DEVICE_TRACKER, + } + # Test home with coordinates + hass.states.async_set( + DEVICE_TRACKER, + "home", + {ATTR_LATITUDE: 10.123456, ATTR_LONGITUDE: 11.123456, ATTR_GPS_ACCURACY: 10}, + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "home" + assert state.attributes == expected_attributes | { + ATTR_GPS_ACCURACY: 10, + ATTR_LATITUDE: 10.123456, + ATTR_LONGITUDE: 11.123456, + ATTR_SOURCE: DEVICE_TRACKER, + } + + # Test not_home without coordinates + hass.states.async_set( + DEVICE_TRACKER, + "not_home", + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "not_home" + assert state.attributes == expected_attributes | {ATTR_SOURCE: DEVICE_TRACKER} + + # Test not_home with coordinates hass.states.async_set( DEVICE_TRACKER, "not_home", @@ -147,13 +182,12 @@ async def test_setup_tracker(hass: HomeAssistant, hass_admin_user: MockUser) -> state = hass.states.get("person.tracked_person") assert state.state == "not_home" - assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) == 10.123456 - assert state.attributes.get(ATTR_LONGITUDE) == 11.123456 - assert state.attributes.get(ATTR_GPS_ACCURACY) == 10 - assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER - assert state.attributes.get(ATTR_USER_ID) == user_id - assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [DEVICE_TRACKER] + assert state.attributes == expected_attributes | { + ATTR_GPS_ACCURACY: 10, + ATTR_LATITUDE: 10.123456, + ATTR_LONGITUDE: 11.123456, + ATTR_SOURCE: DEVICE_TRACKER, + } async def test_setup_two_trackers( @@ -188,8 +222,8 @@ async def test_setup_two_trackers( state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_LATITUDE) == 32.87336 + assert state.attributes.get(ATTR_LONGITUDE) == -117.22743 assert state.attributes.get(ATTR_GPS_ACCURACY) is None assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == user_id @@ -453,8 +487,8 @@ async def test_load_person_storage( state = hass.states.get("person.tracked_person") assert state.state == "home" assert state.attributes.get(ATTR_ID) == "1234" - assert state.attributes.get(ATTR_LATITUDE) is None - assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_LATITUDE) == 32.87336 + assert state.attributes.get(ATTR_LONGITUDE) == -117.22743 assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id @@ -817,7 +851,7 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: }, ) - assert len(hass.states.async_entity_ids()) == 2 + assert len(hass.states.async_entity_ids()) == 3 # Person1, Person2, zone.home state_1 = hass.states.get("person.person_1") state_2 = hass.states.get("person.person_2") @@ -847,7 +881,7 @@ async def test_reload(hass: HomeAssistant, hass_admin_user: MockUser) -> None: ) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 2 + assert len(hass.states.async_entity_ids()) == 3 # Person1, Person2, zone.home state_1 = hass.states.get("person.person_1") state_2 = hass.states.get("person.person_2") diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index 7fe3870ae1e43a..f9dd18a486686a 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -18,3 +18,13 @@ async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: entity.add_template_attribute("_hello", tpl_with_hass) assert len(entity._template_attrs.get(tpl_with_hass, [])) == 1 + + +async def test_object_id(hass: HomeAssistant) -> None: + """Test template entity creates suggested entity_id from the object_id.""" + + class TemplateTest(template_entity.TemplateEntity): + _entity_id_format = "test.{}" + + entity = TemplateTest(hass, {"object_id": "test"}, "a") + assert entity.entity_id == "test.test" diff --git a/tests/components/template/test_trigger_entity.py b/tests/components/template/test_trigger_entity.py index 65db69fa2b9125..000206c0788c37 100644 --- a/tests/components/template/test_trigger_entity.py +++ b/tests/components/template/test_trigger_entity.py @@ -17,6 +17,7 @@ class TestEntity(trigger_entity.TriggerEntity): """Test entity class.""" __test__ = False + _entity_id_format = "test.{}" extra_template_keys = (CONF_STATE,) @property @@ -134,3 +135,10 @@ async def test_script_variables_from_coordinator(hass: HomeAssistant) -> None: coordinator._execute_update({"value": STATE_ON}) assert entity._render_script_variables() == {"value": STATE_ON} + + +async def test_object_id(hass: HomeAssistant) -> None: + """Test template entity creates suggested entity_id from the object_id.""" + coordinator = TriggerUpdateCoordinator(hass, {}) + entity = TestEntity(hass, coordinator, {"object_id": "test"}) + assert entity.entity_id == "test.test" diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index 8eb065a5a46f83..dfe994b6fa2e36 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -8,6 +8,8 @@ from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry, async_fire_time_changed @@ -21,17 +23,15 @@ async def test_rain_sensor_state( freezer: FrozenDateTimeFactory, ) -> None: """Test the rain sensor.""" - mock_config_entry.add_to_hass(hass) - - test_entity_id = "binary_sensor.test_window_rain_sensor" - with ( - patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]), - ): + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]): # setup config entry assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() + test_entity_id = "binary_sensor.test_window_rain_sensor" + # simulate no rain detected freezer.tick(timedelta(minutes=5)) async_fire_time_changed(hass) @@ -48,3 +48,39 @@ async def test_rain_sensor_state( state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_ON + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("mock_module") +async def test_rain_sensor_device_association( + hass: HomeAssistant, + mock_window: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, +) -> None: + """Test the rain sensor is properly associated with its device.""" + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + test_entity_id = "binary_sensor.test_window_rain_sensor" + + # Verify entity exists + state = hass.states.get(test_entity_id) + assert state is not None + + # Get entity entry + entity_entry = entity_registry.async_get(test_entity_id) + assert entity_entry is not None + assert entity_entry.device_id is not None + + # Get device entry + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + + # Verify device has correct identifiers + assert ("velux", mock_window.serial_number) in device_entry.identifiers + assert device_entry.name == mock_window.name