Compare commits

...

23 Commits

Author SHA1 Message Date
Franck Nijhof fce17c8e6f Bump version to 2026.6.0b0 2026-05-27 16:07:37 +00:00
Franck Nijhof 51d1d4aa9e Update MDI icons from frontend for 2026.6.0 beta (#172366) 2026-05-27 18:04:08 +02:00
Alex Romanov 8184b93151 Add Tuya smart kettle select entities (#171897)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-27 17:32:01 +02:00
Bram Kragten 403cb85bc8 Bump frontend to 20260527.0 (#172355) 2026-05-27 17:16:46 +02:00
Erik Montnemery 4bf3a5b4bd Adjust behavior of numerical condition and trigger between and outside (#172335) 2026-05-27 17:03:58 +02:00
robotsnh 5a73d78c90 refactor(ads): refactor local CONF_OPTIONS constant in select.py (#171957) 2026-05-27 16:53:33 +02:00
Stefan Agner ebd9934213 Add repair to migrate away from multiprotocol/Multi-PAN (#168431)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: puddly <32534428+puddly@users.noreply.github.com>
2026-05-27 16:37:02 +02:00
Thomas D 73898c29e2 Fix weather lux unit in Qbus integration (#172326) 2026-05-27 16:29:39 +02:00
Jan-Philipp Benecke 3372bf45ec Allow counter entities as source in trend (#171132) 2026-05-27 15:24:19 +01:00
epenet 9744388a4e Fix duplicate hvac_modes in Tuya climate (#172352) 2026-05-27 16:23:24 +02:00
Petro31 75c52a382e Add missing template entity device_tracker translation (#172346) 2026-05-27 16:21:50 +02:00
Erik Montnemery f8a65a7c6f Rename trigger behavior options (#172348) 2026-05-27 16:01:11 +02:00
Matt b2d934fae1 Fix dead code and redundant assignment in isy994 integration (#171904)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com>
2026-05-27 15:56:32 +02:00
Wendelin eb72a72182 Rename automation comments to note (#172312) 2026-05-27 15:23:06 +02:00
Abílio Costa a4b9de867c Add instruction about hardcoded entity ids in tests (#172341) 2026-05-27 14:18:31 +01:00
Erik Montnemery 3a4e697414 Add entity option to associate scanner tracker with any zone (#172157) 2026-05-27 15:17:30 +02:00
epenet 00010a7508 Bump tuya-device-handlers to 0.0.21 (#172315) 2026-05-27 14:52:15 +02:00
epenet c5e4e97fa9 Ignore quirks in Tuya snapshot tests (#172329) 2026-05-27 14:22:59 +02:00
renovate[bot] 3f6e323b48 Update ruff (#172343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 13:59:20 +02:00
renovate[bot] b9639ec9f6 Update uv to 0.11.16 (#172344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-27 13:59:05 +02:00
dependabot[bot] 31bce13d16 Bump actions/stale from 10.2.0 to 10.3.0 (#172319)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 13:28:44 +02:00
Petro31 3523a26abd Add template device_tracker platform (#171732) 2026-05-27 13:27:07 +02:00
Allen Porter a6fcc9f3ff Prefer external URL in WWW-Authenticate header for RFC 9728 (#169658) 2026-05-27 12:57:02 +02:00
166 changed files with 3611 additions and 972 deletions
+1
View File
@@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
## Good practices
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -67,7 +67,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -97,7 +97,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"
+1 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.13
rev: v0.15.14
hooks:
- id: ruff-check
args:
+1
View File
@@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
## Good practices
+1 -4
View File
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
SelectEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_NAME, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -19,9 +19,6 @@ from .hub import AdsHub
DEFAULT_NAME = "ADS select"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ADS_VAR): cv.string,
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,7 +5,7 @@
fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+2 -2
View File
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
@@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
ATTR_LOCATION_NAME,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONF_CONSIDER_HOME,
CONF_NEW_DEVICE_DEFAULTS,
CONF_SCAN_INTERVAL,
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
ATTR_ATTRIBUTES: Final = "attributes"
ATTR_BATTERY: Final = "battery"
ATTR_DEV_ID: Final = "dev_id"
@@ -1,7 +1,7 @@
"""Provide functionality to keep track of devices."""
import asyncio
from typing import Any, final
from typing import TYPE_CHECKING, Any, final
from propcache.api import cached_property
@@ -16,8 +16,19 @@ from homeassistant.const import (
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.core import (
CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
State,
callback,
)
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
@@ -25,6 +36,7 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -33,6 +45,7 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
@@ -319,14 +332,120 @@ class BaseScannerEntity(BaseTrackerEntity):
addresses being used to identify the device.
"""
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
async def async_internal_added_to_hass(self) -> None:
"""Call when the scanner entity is added to hass."""
await super().async_internal_added_to_hass()
if not self.registry_entry:
return
self._async_read_entity_options()
async def async_internal_will_remove_from_hass(self) -> None:
"""Call when the scanner entity is about to be removed from hass."""
await super().async_internal_will_remove_from_hass()
if not self.registry_entry:
return
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
@callback
def async_registry_entry_updated(self) -> None:
"""Run when the entity registry entry has been updated."""
self._async_read_entity_options()
@callback
def _async_read_entity_options(self) -> None:
"""Read entity options from the entity registry.
Called when the entity registry entry has been updated and before the
scanner entity is added to the state machine.
"""
assert self.registry_entry
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
):
new_zone = associated_zone
else:
new_zone = zone.ENTITY_ID_HOME
if new_zone == self._scanner_option_associated_zone:
return
# Tear down tracking for the previous zone.
if self._scanner_option_associated_zone_unsub is not None:
self._scanner_option_associated_zone_unsub()
self._scanner_option_associated_zone_unsub = None
self._async_clear_associated_zone_issue()
self._scanner_option_associated_zone = new_zone
# zone.home is always present so no tracking or issue handling needed.
if new_zone == zone.ENTITY_ID_HOME:
return
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
self.hass, new_zone, self._async_associated_zone_state_changed
)
if self.hass.states.get(new_zone) is None:
self._async_create_associated_zone_issue()
@callback
def _async_associated_zone_state_changed(
self, event: Event[EventStateChangedData]
) -> None:
"""Open or clear the repair issue when the associated zone appears or disappears."""
if event.data["new_state"] is None:
self._async_create_associated_zone_issue()
else:
self._async_clear_associated_zone_issue()
self.async_write_ha_state()
@callback
def _async_create_associated_zone_issue(self) -> None:
"""Create a repair issue prompting the user to reconfigure the scanner."""
ir.async_create_issue(
self.hass,
DOMAIN,
self._associated_zone_issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="associated_zone_missing",
translation_placeholders={
"entity_id": self.entity_id,
"zone": self._scanner_option_associated_zone,
},
)
@callback
def _async_clear_associated_zone_issue(self) -> None:
"""Clear the associated-zone-missing repair issue if it exists."""
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
@property
def _associated_zone_issue_id(self) -> str:
"""Return the issue id for the associated-zone-missing repair."""
if TYPE_CHECKING:
assert self.registry_entry
return f"associated_zone_missing_{self.registry_entry.id}"
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
if not self.is_connected:
return STATE_NOT_HOME
associated_zone = self._scanner_option_associated_zone
if associated_zone == zone.ENTITY_ID_HOME:
return STATE_HOME
return STATE_NOT_HOME
if zone_state := self.hass.states.get(associated_zone):
return zone_state.name
# Configured zone has been removed; state is unknown.
return None
@property
def is_connected(self) -> bool | None:
@@ -343,9 +462,18 @@ class BaseScannerEntity(BaseTrackerEntity):
if not self.is_connected:
return attr
associated_zone = self._scanner_option_associated_zone
# If the configured zone has been removed, in_zones stays empty so the
# attribute does not claim membership in a zone that no longer exists.
if (
associated_zone != zone.ENTITY_ID_HOME
and self.hass.states.get(associated_zone) is None
):
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
associated_zone,
*zone.async_get_enclosing_zones(self.hass, associated_zone),
]
return attr
@@ -44,6 +44,12 @@
}
}
},
"issues": {
"associated_zone_missing": {
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
"title": "Scanner is associated with a removed zone"
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260429.4"]
"requirements": ["home-assistant-frontend==20260527.0"]
}
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -3,7 +3,7 @@
"name": "Home Assistant Hardware",
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["usb"],
"dependencies": ["repairs", "usb"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
@@ -0,0 +1,72 @@
"""Repairs for the Home Assistant Hardware integration."""
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration"
@callback
def _multi_pan_issue_id(config_entry: ConfigEntry) -> str:
"""Return the issue id for the multi-PAN migration issue of an entry."""
return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}"
@callback
def async_create_multi_pan_migration_issue(
hass: HomeAssistant,
domain: str,
config_entry: ConfigEntry,
) -> None:
"""Create a repair issue to guide migration away from Multi-PAN."""
ir.async_create_issue(
hass,
domain=domain,
issue_id=_multi_pan_issue_id(config_entry),
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key=ISSUE_MULTI_PAN_MIGRATION,
translation_placeholders={"hardware_name": config_entry.title},
data={"entry_id": config_entry.entry_id},
)
@callback
def async_delete_multi_pan_migration_issue(
hass: HomeAssistant,
domain: str,
config_entry: ConfigEntry,
) -> None:
"""Delete the multi-PAN migration repair issue for this entry."""
ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry))
class MultiPanMigrationRepairFlow(RepairsFlow):
"""Reuse the multi-PAN options flow uninstall steps as a repair flow.
Subclass this together with the hardware-specific
``MultiPanOptionsFlowHandler`` in each hardware integration's repairs
module.
The repair flow runs in the repairs flow manager where ``self.handler``
is the integration domain rather than the hardware config entry id, so
the ``config_entry`` accessor of ``OptionsFlow`` must be overridden.
"""
_repair_config_entry: ConfigEntry
@property
def config_entry(self) -> ConfigEntry:
"""Return the hardware config entry to migrate."""
return self._repair_config_entry
async def _async_step_start_migration(self) -> RepairsFlowResult:
"""Jump straight into the uninstall step of the migration flow.
The repair flow's init data is the issue context, not user form input,
so pass None to render the uninstall confirmation form.
"""
return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return]
@@ -6,6 +6,8 @@ import dataclasses
import logging
from typing import Any, Protocol
from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
import voluptuous as vol
import yarl
@@ -25,6 +27,7 @@ from homeassistant.config_entries import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
@@ -37,15 +40,18 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG
from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
from .util import (
ApplicationType,
WaitingAddonManager,
async_firmware_flashing_context,
async_flash_silabs_firmware,
)
_LOGGER = logging.getLogger(__name__)
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
DATA_FLASHER_ADDON_MANAGER = "silabs_flasher"
ADDON_STATE_POLL_INTERVAL = 3
ADDON_INFO_POLL_TIMEOUT = 15 * 60
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
CONF_ADDON_DEVICE = "device"
@@ -71,53 +77,6 @@ async def get_multiprotocol_addon_manager(
return manager
class WaitingAddonManager(AddonManager):
"""Addon manager which supports waiting operations for managing an addon."""
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
"""Poll an addon's info until it is in a specific state."""
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
while True:
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
if info is not None and info.state in states:
break
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
async def async_start_addon_waiting(self) -> None:
"""Start an add-on."""
await self.async_schedule_start_addon()
await self.async_wait_until_addon_state(AddonState.RUNNING)
async def async_install_addon_waiting(self) -> None:
"""Install an add-on."""
await self.async_schedule_install_addon()
await self.async_wait_until_addon_state(
AddonState.RUNNING,
AddonState.NOT_RUNNING,
)
async def async_uninstall_addon_waiting(self) -> None:
"""Uninstall an add-on."""
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state is AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
class MultiprotocolAddonManager(WaitingAddonManager):
"""Silicon Labs Multiprotocol add-on manager."""
@@ -265,18 +224,6 @@ class MultipanProtocol(Protocol):
"""
@singleton(DATA_FLASHER_ADDON_MANAGER)
@callback
def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
"""Get the flasher add-on manager."""
return WaitingAddonManager(
hass,
LOGGER,
"Silicon Labs Flasher",
SILABS_FLASHER_ADDON_SLUG,
)
@dataclasses.dataclass
class SerialPortSettings:
"""Serial port settings."""
@@ -339,6 +286,19 @@ class OptionsFlowHandler(OptionsFlow, ABC):
def _zha_name(self) -> str:
"""Return the ZHA name."""
@abstractmethod
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
@abstractmethod
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp')."""
@property
@abstractmethod
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
@property
def flow_manager(self) -> OptionsFlowManager:
"""Return the correct flow manager."""
@@ -686,61 +646,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
async def async_step_firmware_revert(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Install the flasher addon, if necessary."""
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
if addon_info.state is AddonState.NOT_INSTALLED:
return await self.async_step_install_flasher_addon()
if addon_info.state is AddonState.NOT_RUNNING:
return await self.async_step_configure_flasher_addon()
# If the addon is already installed and running, fail
return self.async_abort(
reason="addon_already_running",
description_placeholders={"addon_name": flasher_manager.addon_name},
)
async def async_step_install_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show progress dialog for installing flasher addon."""
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
_LOGGER.debug("Flasher addon state: %s", addon_info)
if not self.install_task:
self.install_task = self.hass.async_create_task(
flasher_manager.async_install_addon_waiting(),
"SiLabs Flasher addon install",
eager_start=False,
)
if not self.install_task.done():
return self.async_show_progress(
step_id="install_flasher_addon",
progress_action="install_addon",
description_placeholders={"addon_name": flasher_manager.addon_name},
progress_task=self.install_task,
)
try:
await self.install_task
except AddonError as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="install_failed")
finally:
self.install_task = None
return self.async_show_progress_done(next_step_id="configure_flasher_addon")
async def async_step_configure_flasher_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Perform initial backup and reconfigure ZHA."""
"""Initiate ZHA backup and start multiprotocol addon uninstall."""
# pylint: disable=home-assistant-component-root-import
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
@@ -782,17 +688,6 @@ class OptionsFlowHandler(OptionsFlow, ABC):
_LOGGER.exception("Unexpected exception during ZHA migration")
raise AbortFlow("zha_migration_failed") from err
flasher_manager = get_flasher_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(flasher_manager)
new_addon_config = {
**addon_info.options,
"device": new_settings.device,
"flow_control": new_settings.flow_control,
}
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
await self._async_set_addon_config(new_addon_config, flasher_manager)
return await self.async_step_uninstall_multiprotocol_addon()
async def async_step_uninstall_multiprotocol_addon(
@@ -821,62 +716,93 @@ class OptionsFlowHandler(OptionsFlow, ABC):
finally:
self.stop_task = None
return self.async_show_progress_done(next_step_id="start_flasher_addon")
return self.async_show_progress_done(next_step_id="install_zigbee_firmware")
async def async_step_start_flasher_addon(
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Start Silicon Labs Flasher add-on."""
flasher_manager = get_flasher_addon_manager(self.hass)
"""Flash Zigbee firmware directly onto the radio."""
if not self.install_task:
if not self.start_task:
async def _flash_firmware() -> None:
serial_port_settings = await self._async_serial_port_settings()
device = serial_port_settings.device
async def start_and_wait_until_done() -> None:
await flasher_manager.async_start_addon_waiting()
# Now that the addon is running, wait for it to finish
await flasher_manager.async_wait_until_addon_state(
AddonState.NOT_RUNNING
)
# For the duration of firmware flashing, hint to other integrations
# (i.e. ZHA) that the hardware is in use and should not be accessed.
async with async_firmware_flashing_context(self.hass, device, DOMAIN):
session = async_get_clientsession(self.hass)
client = FirmwareUpdateClient(self._firmware_update_url(), session)
self.start_task = self.hass.async_create_task(
start_and_wait_until_done(), eager_start=False
try:
manifest = await client.async_update_data()
fw_manifest = next(
fw
for fw in manifest.firmwares
if fw.filename.startswith(self._zigbee_firmware_type())
)
fw_data = await client.async_fetch_firmware(fw_manifest)
except (
StopIteration,
TimeoutError,
ClientError,
ManifestMissing,
ValueError,
) as err:
raise HomeAssistantError(
"Failed to fetch Zigbee firmware"
) from err
await async_flash_silabs_firmware(
hass=self.hass,
device=device,
fw_data=fw_data,
flasher_cls=self._flasher_cls,
expected_installed_firmware_type=ApplicationType.EZSP,
progress_callback=lambda offset, total: (
self.async_update_progress(offset / total)
),
)
self.install_task = self.hass.async_create_task(
_flash_firmware(),
"Flash Zigbee firmware",
eager_start=False,
)
if not self.start_task.done():
if not self.install_task.done():
return self.async_show_progress(
step_id="start_flasher_addon",
progress_action="start_flasher_addon",
description_placeholders={"addon_name": flasher_manager.addon_name},
progress_task=self.start_task,
step_id="install_zigbee_firmware",
progress_action="install_zigbee_firmware",
description_placeholders={
"hardware_name": self._hardware_name(),
},
progress_task=self.install_task,
)
try:
await self.start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
return self.async_show_progress_done(next_step_id="flasher_failed")
await self.install_task
except HomeAssistantError as err:
_LOGGER.error("Failed to flash Zigbee firmware: %s", err)
return self.async_show_progress_done(next_step_id="firmware_flash_failed")
finally:
self.start_task = None
self.install_task = None
return self.async_show_progress_done(next_step_id="flashing_complete")
async def async_step_flasher_failed(
async def async_step_firmware_flash_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Flasher add-on start failed."""
flasher_manager = get_flasher_addon_manager(self.hass)
"""Firmware flashing failed."""
return self.async_abort(
reason="addon_start_failed",
description_placeholders={"addon_name": flasher_manager.addon_name},
reason="fw_install_failed",
description_placeholders={"firmware_name": "Zigbee"},
)
async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Finish flashing and update the config entry."""
flasher_manager = get_flasher_addon_manager(self.hass)
await flasher_manager.async_uninstall_addon_waiting()
# Finish ZHA migration if needed
if self._zha_migration_mgr:
try:
@@ -102,7 +102,9 @@
},
"progress": {
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
"install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.",
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.",
"uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled."
},
"step": {
"addon_installed_other_device": {
@@ -37,13 +37,59 @@ from .const import (
ZIGBEE_FLASHER_ADDON_SLUG,
)
from .helpers import async_firmware_update_context
from .silabs_multiprotocol_addon import (
WaitingAddonManager,
get_multiprotocol_addon_manager,
)
_LOGGER = logging.getLogger(__name__)
ADDON_STATE_POLL_INTERVAL = 3
ADDON_INFO_POLL_TIMEOUT = 15 * 60
class WaitingAddonManager(AddonManager):
"""Addon manager which supports waiting operations for managing an addon."""
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
"""Poll an addon's info until it is in a specific state."""
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
while True:
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
if info is not None and info.state in states:
break
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
async def async_start_addon_waiting(self) -> None:
"""Start an add-on."""
await self.async_schedule_start_addon()
await self.async_wait_until_addon_state(AddonState.RUNNING)
async def async_install_addon_waiting(self) -> None:
"""Install an add-on."""
await self.async_schedule_install_addon()
await self.async_wait_until_addon_state(
AddonState.RUNNING,
AddonState.NOT_RUNNING,
)
async def async_uninstall_addon_waiting(self) -> None:
"""Uninstall an add-on."""
try:
info = await self.async_get_addon_info()
except AddonError:
info = None
# Do not try to uninstall an addon if it is already uninstalled
if info is not None and info.state == AddonState.NOT_INSTALLED:
return
await self.async_uninstall_addon()
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
class ApplicationType(StrEnum):
"""Application type running on a device."""
@@ -279,6 +325,11 @@ async def guess_hardware_owners(
assert otbr_addon_fw_info is not None
device_guesses[otbr_path].append(otbr_addon_fw_info)
# Lazy import to avoid circular dependency
from .silabs_multiprotocol_addon import ( # noqa: PLC0415
get_multiprotocol_addon_manager,
)
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
try:
@@ -7,6 +7,13 @@ import os.path
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.homeassistant_hardware.repair_helpers import (
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
multi_pan_addon_using_device,
)
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
from homeassistant.components.usb import (
USBDevice,
@@ -92,6 +99,16 @@ async def async_setup_entry(
translation_key="device_disconnected",
)
try:
uses_multi_pan = await multi_pan_addon_using_device(hass, device_path)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if uses_multi_pan:
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
else:
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
# Create and store the firmware update coordinator in runtime_data
session = async_get_clientsession(hass)
coordinator = FirmwareUpdateCoordinator(
@@ -248,6 +248,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
"""Return the name of the hardware."""
return self._hw_variant.full_name
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return NABU_CASA_FIRMWARE_RELEASES_URL
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "skyconnect_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return Zbt1Flasher # type: ignore[no-any-return]
async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -0,0 +1,48 @@
"""Repairs for the Home Assistant SkyConnect integration."""
from typing import Any, cast
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
MultiPanMigrationRepairFlow,
)
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler
class SkyConnectMultiPanMigrationRepairFlow(
MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler
):
"""Multi-PAN migration repair flow for Home Assistant SkyConnect."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize the repair flow."""
HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry)
self._repair_config_entry = config_entry
async def async_step_init( # type: ignore[override]
self, user_input: dict[str, Any] | None = None
) -> RepairsFlowResult:
"""Jump straight into the uninstall step."""
return await self._async_step_start_migration()
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a fix flow for a SkyConnect repair issue."""
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
return SkyConnectMultiPanMigrationRepairFlow(entry)
return ConfirmRepairFlow()
@@ -106,6 +106,37 @@
"message": "The device is not plugged in"
}
},
"issues": {
"multi_pan_migration": {
"fix_flow": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"uninstall_addon": {
"data": {
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
},
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
}
},
"title": "Multiprotocol support is deprecated"
}
},
"options": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
@@ -130,8 +161,10 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"addon_installed_other_device": {
@@ -7,8 +7,13 @@ from homeassistant.components.hassio import HassioNotReadyError, get_os_info
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
from homeassistant.components.homeassistant_hardware.repair_helpers import (
async_create_multi_pan_migration_issue,
async_delete_multi_pan_migration_issue,
)
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
check_multi_pan_addon,
multi_pan_addon_using_device,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
@@ -27,6 +32,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from .const import (
DOMAIN,
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
@@ -77,6 +83,16 @@ async def async_setup_entry(
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
try:
multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE)
except HomeAssistantError as err:
raise ConfigEntryNotReady from err
if multipan_using_device:
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
else:
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
if firmware is ApplicationType.EZSP:
discovery_flow.async_create_flow(
hass,
@@ -319,6 +319,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
"""Return the name of the hardware."""
return BOARD_NAME
def _firmware_update_url(self) -> str:
"""Return the firmware update manifest URL."""
return NABU_CASA_FIRMWARE_RELEASES_URL
def _zigbee_firmware_type(self) -> str:
"""Return the zigbee firmware type identifier."""
return "yellow_zigbee_ncp"
@property
def _flasher_cls(self) -> type:
"""Return the hardware-specific flasher class."""
return YellowFlasher # type: ignore[no-any-return]
async def async_step_flashing_complete(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -0,0 +1,48 @@
"""Repairs for the Home Assistant Yellow integration."""
from typing import cast
from homeassistant.components.homeassistant_hardware.repair_helpers import (
ISSUE_MULTI_PAN_MIGRATION,
MultiPanMigrationRepairFlow,
)
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler
class YellowMultiPanMigrationRepairFlow(
MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler
):
"""Multi-PAN migration repair flow for Home Assistant Yellow."""
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
"""Initialize the repair flow."""
HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry)
self._repair_config_entry = config_entry
async def async_step_main_menu( # type: ignore[override]
self, _: None = None
) -> RepairsFlowResult:
"""Jump straight into the uninstall step."""
return await self._async_step_start_migration()
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a fix flow for a Yellow repair issue."""
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
entry_id = cast(str, data["entry_id"])
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
return YellowMultiPanMigrationRepairFlow(hass, entry)
return ConfirmRepairFlow()
@@ -11,6 +11,37 @@
}
}
},
"issues": {
"multi_pan_migration": {
"fix_flow": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
},
"progress": {
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"uninstall_addon": {
"data": {
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
},
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
}
}
},
"title": "Multiprotocol support is deprecated"
}
},
"options": {
"abort": {
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
@@ -37,8 +68,10 @@
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
},
"step": {
"addon_installed_other_device": {
@@ -9,7 +9,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
@@ -18,7 +18,7 @@ from homeassistant.helpers.trigger import (
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
@@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
-1
View File
@@ -15,7 +15,6 @@ CONF_INFO = "info"
CONF_INVERTING = "inverting"
CONF_LIGHT = "light"
CONF_NODE = "node"
CONF_NOTE = "note"
CONF_OFF_ID = "off_id"
CONF_ON_ID = "on_id"
CONF_POSITION = "position"
+1 -1
View File
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
from homeassistant.const import (
CONF_ID,
CONF_NAME,
CONF_NOTE,
CONF_PASSWORD,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
@@ -25,7 +26,6 @@ from .const import (
CONF_INFO,
CONF_INVERTING,
CONF_LIGHT,
CONF_NOTE,
CONF_OFF_ID,
CONF_ON_ID,
CONF_POSITION,
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
-1
View File
@@ -81,7 +81,6 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
def async_on_update(self, event: NodeProperty) -> None:
"""Save brightness in the update event from the ISY Node."""
if self._node.status not in (0, ISY_VALUE_UNKNOWN):
self._last_brightness = self._node.status
if self._node.uom == UOM_PERCENTAGE:
self._last_brightness = round(self._node.status * 255.0 / 100.0)
else:
@@ -279,10 +279,6 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
if uom in (UOM_INDEX, UOM_ON_OFF):
return cast(str, self.target.formatted)
# Check if this is an index type and get formatted value
if uom == UOM_INDEX and hasattr(self.target, "formatted"):
return cast(str, self.target.formatted)
# Handle ISY precision and rounding
value = convert_isy_value_to_hass(value, uom, self.target.prec)
if value is None:
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+12 -1
View File
@@ -50,8 +50,10 @@ class QbusWeatherDescription(SensorEntityDescription):
"""Description for Qbus weather entities."""
property: str
scale_factor: int | None = None
# Qbus reports illuminance in klux, HA only supports lux.
_WEATHER_DESCRIPTIONS = (
QbusWeatherDescription(
key="daylight",
@@ -60,6 +62,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
),
QbusWeatherDescription(
key="light",
@@ -67,6 +70,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
),
QbusWeatherDescription(
key="light_east",
@@ -75,6 +79,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
),
QbusWeatherDescription(
key="light_south",
@@ -83,6 +88,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
),
QbusWeatherDescription(
key="light_west",
@@ -91,6 +97,7 @@ _WEATHER_DESCRIPTIONS = (
device_class=SensorDeviceClass.ILLUMINANCE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=LIGHT_LUX,
scale_factor=1000,
),
QbusWeatherDescription(
key="temperature",
@@ -400,4 +407,8 @@ class QbusWeatherSensor(QbusEntity, SensorEntity):
async def _handle_state_received(self, state: QbusMqttWeatherState) -> None:
if value := state.read_property(self.entity_description.property, None):
self.native_value = value
self.native_value = (
value * self.entity_description.scale_factor
if self.entity_description.scale_factor is not None
else value
)
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -6,7 +6,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -18,6 +18,7 @@ from homeassistant.components.blueprint import (
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN
@@ -59,6 +60,7 @@ from . import (
binary_sensor as binary_sensor_platform,
button as button_platform,
cover as cover_platform,
device_tracker as device_tracker_platform,
event as event_platform,
fan as fan_platform,
image as image_platform,
@@ -199,6 +201,9 @@ CONFIG_SECTION_SCHEMA = vol.All(
vol.Optional(COVER_DOMAIN): vol.All(
cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA]
),
vol.Optional(DEVICE_TRACKER_DOMAIN): vol.All(
cv.ensure_list, [device_tracker_platform.TRACKER_YAML_SCHEMA]
),
vol.Optional(EVENT_DOMAIN): vol.All(
cv.ensure_list, [event_platform.EVENT_YAML_SCHEMA]
),
@@ -23,6 +23,8 @@ from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
CONF_STATE,
CONF_UNIT_OF_MEASUREMENT,
@@ -74,6 +76,11 @@ from .cover import (
STOP_ACTION,
async_create_preview_cover,
)
from .device_tracker import (
CONF_IN_ZONES,
CONF_LOCATION_ACCURACY,
async_create_preview_tracker,
)
from .event import CONF_EVENT_TYPE, CONF_EVENT_TYPES, async_create_preview_event
from .fan import (
CONF_OFF_ACTION,
@@ -150,6 +157,7 @@ _SCHEMA_STATE: dict[vol.Marker, Any] = {
def generate_schema(domain: str, flow_type: str) -> vol.Schema:
"""Generate schema."""
schema: dict[vol.Marker, Any] = {}
advanced_options: dict[vol.Marker, Any] = {}
if flow_type == "config":
schema = {vol.Required(CONF_NAME): selector.TextSelector()}
@@ -226,6 +234,16 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
)
}
if domain == Platform.DEVICE_TRACKER:
schema |= {
vol.Optional(CONF_IN_ZONES): selector.TemplateSelector(),
vol.Optional(CONF_LATITUDE): selector.TemplateSelector(),
vol.Optional(CONF_LONGITUDE): selector.TemplateSelector(),
}
advanced_options |= {
vol.Optional(CONF_LOCATION_ACCURACY): selector.TemplateSelector(),
}
if domain == Platform.EVENT:
schema |= {
vol.Required(CONF_EVENT_TYPE): selector.TemplateSelector(),
@@ -431,6 +449,7 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Schema(
{
vol.Optional(CONF_AVAILABILITY): selector.TemplateSelector(),
**advanced_options,
}
),
{"collapsed": True},
@@ -540,6 +559,7 @@ TEMPLATE_TYPES = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.COVER,
Platform.DEVICE_TRACKER,
Platform.EVENT,
Platform.FAN,
Platform.IMAGE,
@@ -575,6 +595,11 @@ CONFIG_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.COVER),
),
Platform.DEVICE_TRACKER: SchemaFlowFormStep(
config_schema(Platform.DEVICE_TRACKER),
preview="template",
validate_user_input=validate_user_input(Platform.DEVICE_TRACKER),
),
Platform.EVENT: SchemaFlowFormStep(
config_schema(Platform.EVENT),
preview="template",
@@ -660,6 +685,11 @@ OPTIONS_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.COVER),
),
Platform.DEVICE_TRACKER: SchemaFlowFormStep(
options_schema(Platform.DEVICE_TRACKER),
preview="template",
validate_user_input=validate_user_input(Platform.DEVICE_TRACKER),
),
Platform.EVENT: SchemaFlowFormStep(
options_schema(Platform.EVENT),
preview="template",
@@ -730,6 +760,7 @@ CREATE_PREVIEW_ENTITY: dict[
Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel,
Platform.BINARY_SENSOR: async_create_preview_binary_sensor,
Platform.COVER: async_create_preview_cover,
Platform.DEVICE_TRACKER: async_create_preview_tracker,
Platform.EVENT: async_create_preview_event,
Platform.FAN: async_create_preview_fan,
Platform.LIGHT: async_create_preview_light,
@@ -26,6 +26,7 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.COVER,
Platform.DEVICE_TRACKER,
Platform.EVENT,
Platform.FAN,
Platform.IMAGE,
@@ -0,0 +1,250 @@
"""Support for device trackers which integrates with other components."""
from collections.abc import Callable
from typing import Any
import voluptuous as vol
from homeassistant.components import zone
from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
ENTITY_ID_FORMAT,
TrackerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator, validators as template_validators
from .entity import AbstractTemplateEntity
from .helpers import (
async_setup_template_entry,
async_setup_template_platform,
async_setup_template_preview,
)
from .schemas import (
TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA,
make_template_entity_common_modern_schema,
)
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
DEFAULT_NAME = "Template Device Tracker"
CONF_IN_ZONES = "in_zones"
CONF_LOCATION_ACCURACY = "location_accuracy"
def _validate_in_zones_or_lat_and_lon(obj: dict) -> dict:
if CONF_IN_ZONES not in obj:
if CONF_LATITUDE not in obj or CONF_LONGITUDE not in obj:
raise vol.Invalid(
f"Either '{CONF_IN_ZONES}' or both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified"
)
elif (CONF_LATITUDE in obj and CONF_LONGITUDE not in obj) or (
CONF_LATITUDE not in obj and CONF_LONGITUDE in obj
):
raise vol.Invalid(
f"Both '{CONF_LATITUDE}' and '{CONF_LONGITUDE}' must be specified"
)
return obj
def validate_in_zones(
entity: AbstractTemplateTracker,
) -> Callable[[Any], list[str] | None]:
"""Convert the result to a list of entity_ids.
This ensures the result is a list of zone entity_ids.
All other values that are not lists will result in None.
"""
def convert(result: Any) -> list[str] | None:
if template_validators.check_result_for_none(result):
return None
if not isinstance(result, list):
template_validators.log_validation_result_error(
entity,
CONF_IN_ZONES,
result,
"expected a list of zone entity_ids",
)
return None
zone_entity_ids = []
failed = []
for v in result:
try:
zone_entity_ids.append(
vol.All(cv.entity_id, cv.entity_domain(zone.DOMAIN))(v)
)
except vol.Invalid:
failed.append(v)
if failed:
template_validators.log_validation_result_error(
entity,
CONF_IN_ZONES,
failed,
"expected a list of zone entity_ids",
)
return zone_entity_ids
return convert
TRACKER_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_IN_ZONES): cv.template,
vol.Optional(CONF_LATITUDE): cv.template,
vol.Optional(CONF_LOCATION_ACCURACY): cv.template,
vol.Optional(CONF_LONGITUDE): cv.template,
}
)
TRACKER_YAML_SCHEMA = vol.All(
_validate_in_zones_or_lat_and_lon,
TRACKER_COMMON_SCHEMA.extend(
make_template_entity_common_modern_schema(
DEVICE_TRACKER_DOMAIN, DEFAULT_NAME
).schema
),
)
TRACKER_CONFIG_ENTRY_SCHEMA = vol.All(
_validate_in_zones_or_lat_and_lon,
TRACKER_COMMON_SCHEMA.extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema),
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template device trackers."""
await async_setup_template_platform(
hass,
DEVICE_TRACKER_DOMAIN,
config,
StateTrackerEntity,
TriggerTrackerEntity,
async_add_entities,
discovery_info,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await async_setup_template_entry(
hass,
config_entry,
async_add_entities,
StateTrackerEntity,
TRACKER_CONFIG_ENTRY_SCHEMA,
)
@callback
def async_create_preview_tracker(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> StateTrackerEntity:
"""Create a preview device tracker."""
return async_setup_template_preview(
hass,
name,
config,
StateTrackerEntity,
TRACKER_CONFIG_ENTRY_SCHEMA,
)
class AbstractTemplateTracker(AbstractTemplateEntity, TrackerEntity):
"""Representation of a template device tracker features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity
# and TriggerEntity will call
# AbstractTemplateEntity.__init__. This ensures that
# the __init__ on AbstractTemplateEntity is not
# called twice.
def __init__(self) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self.setup_template(
CONF_IN_ZONES,
"_attr_in_zones",
validate_in_zones(self),
)
self.setup_template(
CONF_LATITUDE,
"_attr_latitude",
template_validators.number(self, CONF_LATITUDE, -90.0, 90.0),
)
self.setup_template(
CONF_LONGITUDE,
"_attr_longitude",
template_validators.number(self, CONF_LONGITUDE, -180.0, 180.0),
)
self.setup_template(
CONF_LOCATION_ACCURACY,
"_attr_location_accuracy",
on_update=self._update_location_accuracy,
none_on_template_error=False,
)
self._location_accuracy_validator = template_validators.number(
self, CONF_LOCATION_ACCURACY, 0.0
)
def _update_location_accuracy(self, value: float | None) -> None:
"""Update the location accuracy."""
self._attr_location_accuracy = self._location_accuracy_validator(value) or 0.0
class StateTrackerEntity(TemplateEntity, AbstractTemplateTracker):
"""Representation of a Template device tracker."""
_attr_should_poll = False
def __init__(
self,
hass: HomeAssistant,
config: ConfigType,
unique_id: str | None,
) -> None:
"""Initialize the Template device tracker."""
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateTracker.__init__(self)
class TriggerTrackerEntity(TriggerEntity, AbstractTemplateTracker):
"""Tracker entity based on trigger data."""
domain = DEVICE_TRACKER_DOMAIN
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateTracker.__init__(self)
+55 -1
View File
@@ -136,6 +136,34 @@
},
"title": "Template cover"
},
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"latitude": "Latitude",
"longitude": "Longitude",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"latitude": "Defines a template to get the latitude of the device tracker. Valid values are numbers between `-90` and `90`.",
"longitude": "Defines a template to get the longitude of the device tracker. Valid values are numbers between `-180` and `180`.",
"name": "[%key:common::config_flow::data::name%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]",
"location_accuracy": "Location accuracy"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]",
"location_accuracy": "Defines a template to get the accuracy of the device tracker's location in meters. Valid values are numbers greater than or equal to `0`."
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "Template device tracker"
},
"event": {
"data": {
"device_class": "[%key:component::template::common::device_class%]",
@@ -454,6 +482,7 @@
"binary_sensor": "[%key:component::binary_sensor::title%]",
"button": "[%key:component::button::title%]",
"cover": "[%key:component::cover::title%]",
"device_tracker": "[%key:component::device_tracker::title%]",
"event": "[%key:component::event::title%]",
"fan": "[%key:component::fan::title%]",
"image": "[%key:component::image::title%]",
@@ -651,7 +680,6 @@
},
"title": "[%key:component::template::config::step::button::title%]"
},
"cover": {
"data": {
"close_cover": "[%key:component::template::config::step::cover::data::close_cover%]",
@@ -684,6 +712,32 @@
},
"title": "[%key:component::template::config::step::cover::title%]"
},
"device_tracker": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
"latitude": "[%key:component::template::config::step::device_tracker::data::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data::longitude%]"
},
"data_description": {
"device_id": "[%key:component::template::common::device_id_description%]",
"latitude": "[%key:component::template::config::step::device_tracker::data_description::latitude%]",
"longitude": "[%key:component::template::config::step::device_tracker::data_description::longitude%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]",
"location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data::location_accuracy%]"
},
"data_description": {
"availability": "[%key:component::template::common::availability_description%]",
"location_accuracy": "[%key:component::template::config::step::device_tracker::sections::advanced_options::data_description::location_accuracy%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "[%key:component::template::config::step::device_tracker::title%]"
},
"event": {
"data": {
"device_id": "[%key:common::config_flow::data::device%]",
+1 -1
View File
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,6 +5,7 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_NAME, UnitOfTime
from homeassistant.helpers import selector
@@ -27,6 +28,8 @@ from .const import (
DOMAIN,
)
ALLOWED_DOMAINS = [COUNTER_DOMAIN, SENSOR_DOMAIN]
async def get_base_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Get base options schema."""
@@ -90,7 +93,7 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): selector.TextSelector(),
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(
selector.EntitySelectorConfig(domain=SENSOR_DOMAIN, multiple=False),
selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS, multiple=False),
),
}
)
@@ -1,6 +1,7 @@
{
"domain": "trend",
"name": "Trend",
"after_dependencies": ["sensor", "counter"],
"codeowners": ["@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/trend",
+2 -1
View File
@@ -202,9 +202,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# Determine preset modes (ignore if empty options)
if definition.preset_wrapper and definition.preset_wrapper.options:
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
self._attr_preset_modes = definition.preset_wrapper.options
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
if description.switch_only_hvac_mode not in self._attr_hvac_modes:
self._attr_hvac_modes.append(description.switch_only_hvac_mode)
# Determine dpcode to use for setting the humidity
if definition.target_humidity_wrapper:
+2
View File
@@ -939,6 +939,7 @@ class DPCode(StrEnum):
TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C
TEMP_SET = "temp_set" # Set the temperature in °C
TEMP_SET_F = "temp_set_f" # Set the temperature in °F
TEMP_SETTING_QUICK_C = "temp_setting_quick_c"
TEMP_UNIT_CONVERT = "temp_unit_convert" # Temperature unit switching
TEMP_VALUE = "temp_value" # Color temperature
TEMP_VALUE_V2 = "temp_value_v2"
@@ -992,6 +993,7 @@ class DPCode(StrEnum):
WORK_POWER = "work_power"
WORK_STATE = "work_state"
WORK_STATE_E = "work_state_e"
WORK_TYPE = "work_type"
@dataclass
+1 -1
View File
@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.19",
"tuya-device-handlers==0.0.21",
"tuya-device-sharing-sdk==0.2.8"
]
}
+12
View File
@@ -19,6 +19,18 @@ from .entity import TuyaEntity
# All descriptions can be found here. Mostly the Enum data types in the
# default instructions set of each category end up being a select.
SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = {
DeviceCategory.BH: (
SelectEntityDescription(
key=DPCode.TEMP_SETTING_QUICK_C,
entity_category=EntityCategory.CONFIG,
translation_key="quick_heat_temperature",
),
SelectEntityDescription(
key=DPCode.WORK_TYPE,
entity_category=EntityCategory.CONFIG,
translation_key="kettle_work_mode",
),
),
DeviceCategory.CL: (
SelectEntityDescription(
key=DPCode.CONTROL_BACK_MODE,
@@ -478,6 +478,15 @@
"1": "Continuous working mode"
}
},
"kettle_work_mode": {
"name": "Work mode",
"state": {
"boiling_quick": "Quick boil",
"setting_quick": "Quick heat",
"temp_boiling": "Boil and keep warm",
"temp_setting": "Heat and keep warm"
}
},
"led_type": {
"name": "Light source type",
"state": {
@@ -515,6 +524,16 @@
"smart": "Smart"
}
},
"quick_heat_temperature": {
"name": "Quick heat temperature",
"state": {
"80": "80 °C",
"85": "85 °C",
"90": "90 °C",
"95": "95 °C",
"100": "100 °C"
}
},
"record_mode": {
"name": "Record mode",
"state": {
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+1 -1
View File
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
@@ -28,14 +28,16 @@ from .const import DOMAIN
CONF_OPERATION_MODE = "operation_mode"
_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_OPERATION_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [str]
),
},
}
_OPERATION_MODE_CHANGED_TRIGGER_SCHEMA = (
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_OPERATION_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [str]
),
},
}
)
)
@@ -5,7 +5,7 @@
fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -1,7 +1,7 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
+2 -2
View File
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 6
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
@@ -91,7 +91,6 @@ CONF_COMMAND_ON: Final = "command_on"
CONF_COMMAND_OPEN: Final = "command_open"
CONF_COMMAND_STATE: Final = "command_state"
CONF_COMMAND_STOP: Final = "command_stop"
CONF_COMMENT: Final = "comment"
CONF_CONDITION: Final = "condition"
CONF_CONDITIONS: Final = "conditions"
CONF_CONTINUE_ON_ERROR: Final = "continue_on_error"
@@ -171,6 +170,7 @@ CONF_MODEL_ID: Final = "model_id"
CONF_MONITORED_CONDITIONS: Final = "monitored_conditions"
CONF_MONITORED_VARIABLES: Final = "monitored_variables"
CONF_NAME: Final = "name"
CONF_NOTE: Final = "note"
CONF_OFFSET: Final = "offset"
CONF_OPTIMISTIC: Final = "optimistic"
CONF_OPTIONS: Final = "options"
+1 -1
View File
@@ -813,7 +813,7 @@ class EntityNumericalConditionBase(EntityConditionBase):
if lower_limit is None or upper_limit is None:
# Entity not found or invalid number, don't trigger
return False
between = lower_limit < value < upper_limit
between = lower_limit <= value <= upper_limit
if self._threshold_type == NumericThresholdType.BETWEEN:
return between
return not between
+4 -4
View File
@@ -38,7 +38,6 @@ from homeassistant.const import (
CONF_ATTRIBUTE,
CONF_BELOW,
CONF_CHOOSE,
CONF_COMMENT,
CONF_CONDITION,
CONF_CONDITIONS,
CONF_CONTINUE_ON_ERROR,
@@ -61,6 +60,7 @@ from homeassistant.const import (
CONF_ID,
CONF_IF,
CONF_MATCH,
CONF_NOTE,
CONF_PARALLEL,
CONF_PLATFORM,
CONF_REPEAT,
@@ -1459,7 +1459,7 @@ SCRIPT_SCHEMA = vol.All(ensure_list, [script_action])
SCRIPT_ACTION_BASE_SCHEMA: VolDictType = {
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Remove(CONF_NOTE): str, # Is only used in frontend
vol.Optional(CONF_CONTINUE_ON_ERROR): boolean,
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
}
@@ -1527,7 +1527,7 @@ NUMERIC_STATE_THRESHOLD_SCHEMA = vol.Any(
CONDITION_BASE_SCHEMA: VolDictType = {
vol.Optional(CONF_ALIAS): string,
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Remove(CONF_NOTE): str, # Is only used in frontend
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
}
@@ -1862,7 +1862,7 @@ TRIGGER_BASE_SCHEMA = vol.Schema(
vol.Optional(CONF_ID): str,
vol.Optional(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA,
vol.Optional(CONF_ENABLED): vol.Any(boolean, template),
vol.Remove(CONF_COMMENT): str, # Is only used in frontend
vol.Remove(CONF_NOTE): str, # Is only used in frontend
}
)
+6 -2
View File
@@ -59,13 +59,17 @@ def request_handler_factory(
# Import here to avoid circular dependency with network.py
from .network import NoURLAvailableError, get_url # noqa: PLC0415
# Get the current request header to include as resource metadata
# endpoint for RFC9728. We currently prefer external since this
# is likely most used by remote OAuth clients
try:
url_prefix = get_url(hass, require_current_request=True)
url_prefix = get_url(
hass, require_current_request=True, prefer_external=True
)
except NoURLAvailableError:
# Omit header to avoid leaking configured URLs
raise HTTPUnauthorized from None
raise HTTPUnauthorized(
# Include resource metadata endpoint for RFC9728
headers={
"WWW-Authenticate": (
f'Bearer resource_metadata="{url_prefix}'
+3 -3
View File
@@ -432,7 +432,7 @@ class AutomationBehavior(StrEnum):
ALL = "all"
FIRST = "first"
LAST = "last"
EACH = "each"
ANY = "any"
@@ -446,8 +446,8 @@ class AutomationBehaviorSelectorMode(StrEnum):
_AUTOMATION_BEHAVIOR_MODES: dict[AutomationBehaviorSelectorMode, list[str]] = {
AutomationBehaviorSelectorMode.TRIGGER: [
AutomationBehavior.FIRST,
AutomationBehavior.LAST,
AutomationBehavior.ANY,
AutomationBehavior.ALL,
AutomationBehavior.EACH,
],
AutomationBehaviorSelectorMode.CONDITION: [
AutomationBehavior.ALL,
+17 -17
View File
@@ -327,8 +327,8 @@ class Trigger(abc.ABC):
ATTR_BEHAVIOR: Final = "behavior"
BEHAVIOR_FIRST: Final = "first"
BEHAVIOR_LAST: Final = "last"
BEHAVIOR_ANY: Final = "any"
BEHAVIOR_ALL: Final = "all"
BEHAVIOR_EACH: Final = "each"
ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema(
{
@@ -337,11 +337,11 @@ ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema(
}
)
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_EACH): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_ALL, BEHAVIOR_EACH]
),
vol.Optional(CONF_FOR): cv.positive_time_period,
},
@@ -361,7 +361,7 @@ class EntityTriggerBase(Trigger):
# `_excluded_states`. Subclasses can override to relax the origin
# check.
_excluded_from_states: ClassVar[frozenset[str]] = _excluded_states
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR
# When True, indirect target expansion (via device/area/floor) skips
# entities with an entity_category.
_primary_entities_only: ClassVar[bool] = True
@@ -454,7 +454,7 @@ class EntityTriggerBase(Trigger):
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_ANY)
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_EACH)
unsub_track_same: dict[str, Callable[[], None]] = {}
@callback
@@ -474,10 +474,10 @@ class EntityTriggerBase(Trigger):
Called by async_track_same_state on each state change to
determine whether to cancel the timer.
For behavior any, checks the individual entity's state.
For behavior first/last, checks the combined state.
For behavior each, checks the individual entity's state.
For behavior first/all, checks the combined state.
"""
if behavior == BEHAVIOR_LAST:
if behavior == BEHAVIOR_ALL:
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
@@ -492,7 +492,7 @@ class EntityTriggerBase(Trigger):
target_state_change_data.targeted_entity_ids
)
return matches >= 1
# Behavior any: check the individual entity's state
# Behavior each: check the individual entity's state
if not to_state or to_state.state in self._excluded_states:
return False
return self.is_valid_state(to_state)
@@ -515,7 +515,7 @@ class EntityTriggerBase(Trigger):
):
return
if behavior == BEHAVIOR_LAST:
if behavior == BEHAVIOR_ALL:
matches, included = self.count_matches(
target_state_change_data.targeted_entity_ids
)
@@ -553,7 +553,7 @@ class EntityTriggerBase(Trigger):
call_action()
return
subscription_key = entity_id if behavior == BEHAVIOR_ANY else behavior
subscription_key = entity_id if behavior == BEHAVIOR_EACH else behavior
if subscription_key in unsub_track_same:
unsub_track_same.pop(subscription_key)()
unsub_track_same[subscription_key] = async_track_same_state(
@@ -563,7 +563,7 @@ class EntityTriggerBase(Trigger):
state_still_valid,
entity_ids=(
entity_id
if behavior == BEHAVIOR_ANY
if behavior == BEHAVIOR_EACH
else target_state_change_data.targeted_entity_ids
),
)
@@ -760,7 +760,7 @@ class EntityNumericalStateTriggerBase(EntityTriggerBase):
if lower_limit is None or upper_limit is None:
# Entity not found or invalid number, don't trigger
return False
between = lower_limit < current_value < upper_limit
between = lower_limit <= current_value <= upper_limit
if self._threshold_type == NumericThresholdType.BETWEEN:
return between
return not between
@@ -871,7 +871,7 @@ class EntityNumericalStateChangedTriggerWithUnitBase(
NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required("threshold"): NumericThresholdSelector(
@@ -905,7 +905,7 @@ def _make_numerical_state_crossed_threshold_with_unit_schema(
This trigger only fires when the observed attribute
changes from not within to within the defined threshold.
"""
return ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
return ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required("threshold"): NumericThresholdSelector(
+2 -2
View File
@@ -39,7 +39,7 @@ habluetooth==6.7.9
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260429.4
home-assistant-frontend==20260527.0
home-assistant-intents==2026.5.5
httpx==0.28.1
ifaddr==0.2.0
@@ -70,7 +70,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.15
uv==0.11.16
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
+1 -1
View File
@@ -5,7 +5,7 @@ To update, run python3 -m script.hassfest
from typing import Final
FRONTEND_VERSION: Final[str] = "20260429.4"
FRONTEND_VERSION: Final[str] = "20260527.0"
MDI_ICONS: Final[set[str]] = {
"ab-testing",
+3 -3
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.6.0.dev0"
version = "2026.6.0b0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -74,7 +74,7 @@ dependencies = [
"typing-extensions>=4.15.0,<5.0",
"ulid-transform==2.2.9",
"urllib3>=2.0",
"uv==0.11.15",
"uv==0.11.16",
"voluptuous==0.15.2",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.3.0",
@@ -648,7 +648,7 @@ exclude_lines = [
]
[tool.ruff]
required-version = ">=0.15.13"
required-version = ">=0.15.14"
[tool.ruff.lint]
select = [
+1 -1
View File
@@ -55,7 +55,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.15.0,<5.0
ulid-transform==2.2.9
urllib3>=2.0
uv==0.11.15
uv==0.11.16
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
+2 -2
View File
@@ -1266,7 +1266,7 @@ hole==0.9.0
holidays==0.97
# homeassistant.components.frontend
home-assistant-frontend==20260429.4
home-assistant-frontend==20260527.0
# homeassistant.components.conversation
home-assistant-intents==2026.5.5
@@ -3207,7 +3207,7 @@ ttls==1.8.3
ttn_client==1.3.0
# homeassistant.components.tuya
tuya-device-handlers==0.0.19
tuya-device-handlers==0.0.21
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.8
+1 -1
View File
@@ -1,6 +1,6 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.2
ruff==0.15.13
ruff==0.15.14
yamllint==1.38.0
zizmor==1.24.1
+11 -11
View File
@@ -21,9 +21,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
arm_trigger,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
parametrize_numerical_state_value_changed_trigger_states,
@@ -229,7 +229,7 @@ async def test_air_quality_trigger_options_validation(
),
],
)
async def test_air_quality_trigger_binary_sensor_behavior_any(
async def test_air_quality_trigger_binary_sensor_behavior_each(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -243,7 +243,7 @@ async def test_air_quality_trigger_binary_sensor_behavior_any(
Covers gas, CO, and smoke device classes.
"""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_binary_sensors,
trigger_target_config=trigger_target_config,
@@ -390,7 +390,7 @@ async def test_air_quality_trigger_binary_sensor_behavior_first(
),
],
)
async def test_air_quality_trigger_binary_sensor_behavior_last(
async def test_air_quality_trigger_binary_sensor_behavior_all(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -400,8 +400,8 @@ async def test_air_quality_trigger_binary_sensor_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test air quality trigger fires when the last binary_sensor changes state."""
await assert_trigger_behavior_last(
"""Test air quality trigger fires when all binary_sensors have changed state."""
await assert_trigger_behavior_all(
hass,
target_entities=target_binary_sensors,
trigger_target_config=trigger_target_config,
@@ -570,7 +570,7 @@ async def test_air_quality_trigger_binary_sensor_behavior_last(
),
],
)
async def test_air_quality_trigger_sensor_behavior_any(
async def test_air_quality_trigger_sensor_behavior_each(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -581,7 +581,7 @@ async def test_air_quality_trigger_sensor_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test air quality trigger fires for sensor entities."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_sensors,
trigger_target_config=trigger_target_config,
@@ -789,7 +789,7 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_first(
),
],
)
async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last(
async def test_air_quality_trigger_sensor_crossed_threshold_behavior_all(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -803,7 +803,7 @@ async def test_air_quality_trigger_sensor_crossed_threshold_behavior_last(
Fires when the last sensor changes state.
"""
await assert_trigger_behavior_last(
await assert_trigger_behavior_all(
hass,
target_entities=target_sensors,
trigger_target_config=trigger_target_config,
@@ -13,9 +13,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
other_states,
@@ -153,7 +153,7 @@ async def test_alarm_control_panel_trigger_options_validation(
),
],
)
async def test_alarm_control_panel_state_trigger_behavior_any(
async def test_alarm_control_panel_state_trigger_behavior_each(
hass: HomeAssistant,
target_alarm_control_panels: dict[str, list[str]],
trigger_target_config: dict,
@@ -168,7 +168,7 @@ async def test_alarm_control_panel_state_trigger_behavior_any(
Fires when any alarm control panel state changes to a
specific state.
"""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_alarm_control_panels,
trigger_target_config=trigger_target_config,
@@ -353,7 +353,7 @@ async def test_alarm_control_panel_state_trigger_behavior_first(
),
],
)
async def test_alarm_control_panel_state_trigger_behavior_last(
async def test_alarm_control_panel_state_trigger_behavior_all(
hass: HomeAssistant,
target_alarm_control_panels: dict[str, list[str]],
trigger_target_config: dict,
@@ -368,7 +368,7 @@ async def test_alarm_control_panel_state_trigger_behavior_last(
Fires when the last alarm_control_panel changes to a
specific state.
"""
await assert_trigger_behavior_last(
await assert_trigger_behavior_all(
hass,
target_entities=target_alarm_control_panels,
trigger_target_config=trigger_target_config,
@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
other_states,
@@ -100,7 +100,7 @@ async def test_assist_satellite_trigger_options_validation(
),
],
)
async def test_assist_satellite_state_trigger_behavior_any(
async def test_assist_satellite_state_trigger_behavior_each(
hass: HomeAssistant,
target_assist_satellites: dict[str, list[str]],
trigger_target_config: dict,
@@ -111,7 +111,7 @@ async def test_assist_satellite_state_trigger_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test assist satellite trigger fires when any satellite changes state."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_assist_satellites,
trigger_target_config=trigger_target_config,
@@ -206,7 +206,7 @@ async def test_assist_satellite_state_trigger_behavior_first(
),
],
)
async def test_assist_satellite_state_trigger_behavior_last(
async def test_assist_satellite_state_trigger_behavior_all(
hass: HomeAssistant,
target_assist_satellites: dict[str, list[str]],
trigger_target_config: dict,
@@ -217,7 +217,7 @@ async def test_assist_satellite_state_trigger_behavior_last(
states: list[TriggerStateDescription],
) -> None:
"""Test assist satellite trigger fires when last satellite changes state."""
await assert_trigger_behavior_last(
await assert_trigger_behavior_all(
hass,
target_entities=target_assist_satellites,
trigger_target_config=trigger_target_config,
+14 -14
View File
@@ -16,9 +16,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
parametrize_numerical_state_value_changed_trigger_states,
@@ -134,7 +134,7 @@ async def test_battery_trigger_options_validation(
),
],
)
async def test_battery_binary_sensor_trigger_behavior_any(
async def test_battery_binary_sensor_trigger_behavior_each(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -144,8 +144,8 @@ async def test_battery_binary_sensor_trigger_behavior_any(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test the battery binary sensor triggers with 'any' behavior."""
await assert_trigger_behavior_any(
"""Test the battery binary sensor triggers with 'each' behavior."""
await assert_trigger_behavior_each(
hass,
target_entities=target_binary_sensors,
trigger_target_config=trigger_target_config,
@@ -256,7 +256,7 @@ async def test_battery_binary_sensor_trigger_behavior_first(
),
],
)
async def test_battery_binary_sensor_trigger_behavior_last(
async def test_battery_binary_sensor_trigger_behavior_all(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -266,8 +266,8 @@ async def test_battery_binary_sensor_trigger_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test the battery binary sensor triggers with 'last' behavior."""
await assert_trigger_behavior_last(
"""Test the battery binary sensor triggers with 'all' behavior."""
await assert_trigger_behavior_all(
hass,
target_entities=target_binary_sensors,
trigger_target_config=trigger_target_config,
@@ -299,7 +299,7 @@ async def test_battery_binary_sensor_trigger_behavior_last(
),
],
)
async def test_battery_sensor_trigger_behavior_any(
async def test_battery_sensor_trigger_behavior_each(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -309,8 +309,8 @@ async def test_battery_sensor_trigger_behavior_any(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test battery sensor triggers with 'any' behavior."""
await assert_trigger_behavior_any(
"""Test battery sensor triggers with 'each' behavior."""
await assert_trigger_behavior_each(
hass,
target_entities=target_sensors,
trigger_target_config=trigger_target_config,
@@ -375,7 +375,7 @@ async def test_battery_level_crossed_threshold_sensor_behavior_first(
),
],
)
async def test_battery_level_crossed_threshold_sensor_behavior_last(
async def test_battery_level_crossed_threshold_sensor_behavior_all(
hass: HomeAssistant,
target_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -385,8 +385,8 @@ async def test_battery_level_crossed_threshold_sensor_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test trigger fires when the last sensor changes."""
await assert_trigger_behavior_last(
"""Test trigger fires when all sensors have changed."""
await assert_trigger_behavior_all(
hass,
target_entities=target_sensors,
trigger_target_config=trigger_target_config,
+12 -12
View File
@@ -25,9 +25,9 @@ from homeassistant.helpers.trigger import async_validate_trigger_config
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
other_states,
@@ -211,7 +211,7 @@ async def test_climate_trigger_validation(
),
],
)
async def test_climate_state_trigger_behavior_any(
async def test_climate_state_trigger_behavior_each(
hass: HomeAssistant,
target_climates: dict[str, list[str]],
trigger_target_config: dict,
@@ -222,7 +222,7 @@ async def test_climate_state_trigger_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test climate trigger fires on any state change."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_climates,
trigger_target_config=trigger_target_config,
@@ -285,7 +285,7 @@ async def test_climate_state_trigger_behavior_any(
),
],
)
async def test_climate_state_attribute_trigger_behavior_any(
async def test_climate_state_attribute_trigger_behavior_each(
hass: HomeAssistant,
target_climates: dict[str, list[str]],
trigger_target_config: dict,
@@ -296,7 +296,7 @@ async def test_climate_state_attribute_trigger_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test climate attribute trigger fires on any change."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_climates,
trigger_target_config=trigger_target_config,
@@ -462,7 +462,7 @@ async def test_climate_state_attribute_trigger_behavior_first(
),
],
)
async def test_climate_state_trigger_behavior_last(
async def test_climate_state_trigger_behavior_all(
hass: HomeAssistant,
target_climates: dict[str, list[str]],
trigger_target_config: dict,
@@ -472,8 +472,8 @@ async def test_climate_state_trigger_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test climate trigger fires on last state change."""
await assert_trigger_behavior_last(
"""Test climate trigger fires when all climate entities have changed state."""
await assert_trigger_behavior_all(
hass,
target_entities=target_climates,
trigger_target_config=trigger_target_config,
@@ -523,7 +523,7 @@ async def test_climate_state_trigger_behavior_last(
),
],
)
async def test_climate_state_attribute_trigger_behavior_last(
async def test_climate_state_attribute_trigger_behavior_all(
hass: HomeAssistant,
target_climates: dict[str, list[str]],
trigger_target_config: dict,
@@ -533,8 +533,8 @@ async def test_climate_state_attribute_trigger_behavior_last(
trigger_options: dict[str, Any],
states: list[tuple[tuple[str, dict], int]],
) -> None:
"""Test climate attribute trigger fires on last change."""
await assert_trigger_behavior_last(
"""Test climate attribute trigger fires when all climate entities have changed."""
await assert_trigger_behavior_all(
hass,
target_entities=target_climates,
trigger_target_config=trigger_target_config,
+29 -29
View File
@@ -1062,8 +1062,8 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
threshold_unit,
),
target_states=[
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 60 * s} | unit_attributes),
(state, {attribute: 10 * s} | unit_attributes),
(state, {attribute: 90 * s} | unit_attributes),
],
other_states=[
other_invalid_attr,
@@ -1092,8 +1092,8 @@ def parametrize_numerical_attribute_crossed_threshold_trigger_states(
],
other_states=[
other_invalid_attr,
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 60 * s} | unit_attributes),
(state, {attribute: 10 * s} | unit_attributes),
(state, {attribute: 90 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
required_filter_attributes=required_filter_attributes,
@@ -1262,7 +1262,7 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
},
threshold_unit,
),
target_states=[("50", unit_attributes), ("60", unit_attributes)],
target_states=[("10", unit_attributes), ("90", unit_attributes)],
other_states=[
("none", unit_attributes),
("0", unit_attributes),
@@ -1287,8 +1287,8 @@ def parametrize_numerical_state_value_crossed_threshold_trigger_states(
target_states=[("0", unit_attributes), ("100", unit_attributes)],
other_states=[
("none", unit_attributes),
("50", unit_attributes),
("60", unit_attributes),
("10", unit_attributes),
("90", unit_attributes),
],
required_filter_attributes=required_filter_attributes,
trigger_from_none=False,
@@ -1632,7 +1632,7 @@ async def assert_trigger_options_supported(
return {**(base_options or {}), **extra}
# Behavior
for behavior in ("any", "first", "last"):
for behavior in ("each", "first", "all"):
await _validate_trigger_options(
hass, trigger, _merge({"behavior": behavior}), valid=supports_behavior
)
@@ -1750,7 +1750,7 @@ async def assert_condition_behavior_all(
assert cond.async_check() == state["condition_true"]
async def assert_trigger_behavior_any(
async def assert_trigger_behavior_each(
hass: HomeAssistant,
*,
target_entities: dict[str, list[str]],
@@ -1761,7 +1761,7 @@ async def assert_trigger_behavior_any(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test trigger fires in mode any."""
"""Test trigger fires in mode each."""
calls: list[str] = []
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
@@ -1855,7 +1855,7 @@ async def assert_trigger_behavior_first(
assert len(calls) == 0
async def assert_trigger_behavior_last(
async def assert_trigger_behavior_all(
hass: HomeAssistant,
*,
target_entities: dict[str, list[str]],
@@ -1866,7 +1866,7 @@ async def assert_trigger_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test trigger fires in mode last."""
"""Test trigger fires in mode all."""
calls: list[str] = []
other_entity_ids = set(target_entities["included_entities"]) - {entity_id}
excluded_entity_ids = set(target_entities["excluded_entities"]) - {entity_id}
@@ -1881,7 +1881,7 @@ async def assert_trigger_behavior_last(
await arm_trigger(
hass,
trigger,
{"behavior": "last"} | trigger_options,
{"behavior": "all"} | trigger_options,
trigger_target_config,
calls,
)
@@ -2016,14 +2016,14 @@ def parametrize_numerical_condition_above_below_any(
threshold_unit,
),
target_states=[
("21", unit_attributes),
("20", unit_attributes),
("50", unit_attributes),
("79", unit_attributes),
("80", unit_attributes),
],
other_states=[
("0", unit_attributes),
("20", unit_attributes),
("80", unit_attributes),
("19", unit_attributes),
("81", unit_attributes),
("100", unit_attributes),
],
required_filter_attributes=required_filter_attributes,
@@ -2134,14 +2134,14 @@ def parametrize_numerical_condition_above_below_all(
threshold_unit,
),
target_states=[
("21", unit_attributes),
("20", unit_attributes),
("50", unit_attributes),
("79", unit_attributes),
("80", unit_attributes),
],
other_states=[
("0", unit_attributes),
("20", unit_attributes),
("80", unit_attributes),
("19", unit_attributes),
("81", unit_attributes),
("100", unit_attributes),
],
required_filter_attributes=required_filter_attributes,
@@ -2281,14 +2281,14 @@ def parametrize_numerical_attribute_condition_above_below_any(
threshold_unit,
),
target_states=[
(state, {attribute: 21 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 79 * s} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
(state, {attribute: 19 * s} | unit_attributes),
(state, {attribute: 81 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
@@ -2428,14 +2428,14 @@ def parametrize_numerical_attribute_condition_above_below_all(
threshold_unit,
),
target_states=[
(state, {attribute: 21 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
(state, {attribute: 50 * s} | unit_attributes),
(state, {attribute: 79 * s} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
],
other_states=[
(state, {attribute: 0 * s} | unit_attributes),
(state, {attribute: 20 * s} | unit_attributes),
(state, {attribute: 80 * s} | unit_attributes),
(state, {attribute: 19 * s} | unit_attributes),
(state, {attribute: 81 * s} | unit_attributes),
(state, {attribute: 100 * s} | unit_attributes),
],
extra_excluded_states=extra_excluded_states,
+8 -8
View File
@@ -98,8 +98,8 @@ async def test_counter_condition_options_validation(
"value_max": {"number": 30},
},
},
target_states=["11", "20", "29"],
other_states=["0", "10", "30", "100"],
target_states=["10", "11", "20", "29", "30"],
other_states=["0", "9", "31", "100"],
),
*parametrize_condition_states_any(
condition="counter.is_value",
@@ -110,8 +110,8 @@ async def test_counter_condition_options_validation(
"value_max": {"number": 30},
},
},
target_states=["0", "10", "30", "100"],
other_states=["11", "20", "29"],
target_states=["0", "9", "31", "100"],
other_states=["10", "11", "20", "29", "30"],
),
],
)
@@ -171,8 +171,8 @@ async def test_counter_is_value_condition_behavior_any(
"value_max": {"number": 30},
},
},
target_states=["11", "20", "29"],
other_states=["0", "10", "30", "100"],
target_states=["10", "11", "20", "29", "30"],
other_states=["0", "9", "31", "100"],
),
*parametrize_condition_states_all(
condition="counter.is_value",
@@ -183,8 +183,8 @@ async def test_counter_is_value_condition_behavior_any(
"value_max": {"number": 30},
},
},
target_states=["0", "10", "30", "100"],
other_states=["11", "20", "29"],
target_states=["0", "9", "31", "100"],
other_states=["10", "11", "20", "29", "30"],
),
],
)
+7 -7
View File
@@ -17,9 +17,9 @@ from tests.components.common import (
BasicTriggerStateDescription,
TriggerStateDescription,
arm_trigger,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
parametrize_target_entities,
@@ -191,7 +191,7 @@ async def test_counter_state_trigger(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"), BEHAVIOR_AWARE_TRIGGERS
)
async def test_counter_state_trigger_behavior_any(
async def test_counter_state_trigger_behavior_each(
hass: HomeAssistant,
target_counters: dict[str, list[str]],
trigger_target_config: dict,
@@ -202,7 +202,7 @@ async def test_counter_state_trigger_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test counter trigger fires on any state change."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_counters,
trigger_target_config=trigger_target_config,
@@ -253,7 +253,7 @@ async def test_counter_state_trigger_behavior_first(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"), BEHAVIOR_AWARE_TRIGGERS
)
async def test_counter_state_trigger_behavior_last(
async def test_counter_state_trigger_behavior_all(
hass: HomeAssistant,
target_counters: dict[str, list[str]],
trigger_target_config: dict,
@@ -263,8 +263,8 @@ async def test_counter_state_trigger_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test counter trigger fires on last state change."""
await assert_trigger_behavior_last(
"""Test counter trigger fires when all counters have changed state."""
await assert_trigger_behavior_all(
hass,
target_entities=target_counters,
trigger_target_config=trigger_target_config,
+7 -7
View File
@@ -10,9 +10,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
parametrize_target_entities,
@@ -139,7 +139,7 @@ async def test_cover_trigger_options_validation(
)
],
)
async def test_cover_trigger_behavior_any(
async def test_cover_trigger_behavior_each(
hass: HomeAssistant,
target_covers: dict[str, list[str]],
trigger_target_config: dict,
@@ -150,7 +150,7 @@ async def test_cover_trigger_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test cover trigger fires for cover entities with matching device_class."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_covers,
trigger_target_config=trigger_target_config,
@@ -283,7 +283,7 @@ async def test_cover_trigger_behavior_first(
)
],
)
async def test_cover_trigger_behavior_last(
async def test_cover_trigger_behavior_all(
hass: HomeAssistant,
target_covers: dict[str, list[str]],
trigger_target_config: dict,
@@ -293,8 +293,8 @@ async def test_cover_trigger_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test cover trigger fires when the last cover changes state."""
await assert_trigger_behavior_last(
"""Test cover trigger fires when all covers have changed state."""
await assert_trigger_behavior_all(
hass,
target_entities=target_covers,
trigger_target_config=trigger_target_config,
+328 -1
View File
@@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
BaseScannerEntity,
@@ -33,7 +34,11 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -907,6 +912,328 @@ async def test_base_scanner_entity_in_zones_when_connected(
}
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_option(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the associated_zone entity option overrides which zone in_zones reports.
The scanner reports being connected to a non-default zone; state and in_zones
must follow the configured zone, and a zone enclosing the configured one is
included in in_zones too.
"""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
# Default: no option set -> associated with zone.home.
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.home"]
# Set the option -> associated_zone replaces zone.home; zone.home now shows
# up via the enclosing-zones lookup.
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
await hass.async_block_till_done()
assert base_scanner_entity._scanner_option_associated_zone == "zone.kitchen"
entity_state = hass.states.get(entity_id)
assert entity_state
# zone.kitchen is the configured zone -> state is the zone's name.
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
# Clearing the option falls back to zone.home.
entity_registry.async_update_entity_options(entity_id, DOMAIN, None)
await hass.async_block_till_done()
assert base_scanner_entity._scanner_option_associated_zone == "zone.home"
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.home"]
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_removed_after_set(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test scanner state and repair issue when associated zone is removed.
When the user picks a zone via the associated_zone option and then deletes
that zone, the scanner falls back to ``state == "unknown"`` and a repair
issue is opened prompting the user to reconfigure.
"""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
await hass.async_block_till_done()
# Sanity check before removal.
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
issue_id = f"associated_zone_missing_{entity_entry.id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
# Remove the associated zone.
hass.states.async_remove("zone.kitchen")
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_UNKNOWN
assert entity_state.attributes[ATTR_IN_ZONES] == []
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.severity is ir.IssueSeverity.WARNING
assert issue.translation_key == "associated_zone_missing"
assert issue.translation_placeholders == {
"entity_id": entity_id,
"zone": "zone.kitchen",
}
# Restore the zone -> issue is cleared, state recovers.
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_missing_at_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test repair issue is created when the configured zone is missing at setup."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
# Pre-register the entity option pointing at a zone that does not exist.
entity_entry = entity_registry.async_get_or_create(
DOMAIN,
TEST_DOMAIN,
base_scanner_entity.unique_id,
suggested_object_id="entity1",
)
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_UNKNOWN
assert entity_state.attributes[ATTR_IN_ZONES] == []
issue_id = f"associated_zone_missing_{entity_entry.id}"
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
assert issue is not None
assert issue.translation_placeholders == {
"entity_id": entity_id,
"zone": "zone.never_existed",
}
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_issue_cleared_on_option_change(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the repair issue is cleared when the user clears the option."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
await hass.async_block_till_done()
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
issue_id = f"associated_zone_missing_{entity_entry.id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
# Clearing the option restores the default and clears the repair issue.
entity_registry.async_update_entity_options(entity_id, DOMAIN, None)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == STATE_HOME
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_issue_cleared_on_unload(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test the repair issue is cleared when the entity is removed from hass."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
await hass.async_block_till_done()
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.never_existed"},
)
await hass.async_block_till_done()
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
issue_id = f"associated_zone_missing_{entity_entry.id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
@pytest.mark.parametrize("unique_id", ["unique_scanner"])
async def test_base_scanner_entity_associated_zone_option_set_before_add(
hass: HomeAssistant,
config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
entity_id: str,
base_scanner_entity: MockBaseScannerEntity,
) -> None:
"""Test associated_zone option set before the entity is added is honored."""
hass.states.async_set(
"zone.home",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 1000},
)
hass.states.async_set(
"zone.kitchen",
"0",
{ATTR_LATITUDE: 50.0, ATTR_LONGITUDE: 60.0, ATTR_RADIUS: 50},
)
await hass.async_block_till_done()
# Pre-register the entity with the option set before the platform is set up.
entity_registry.async_get_or_create(
DOMAIN,
TEST_DOMAIN,
base_scanner_entity.unique_id,
suggested_object_id="entity1",
)
entity_registry.async_update_entity_options(
entity_id,
DOMAIN,
{CONF_ASSOCIATED_ZONE: "zone.kitchen"},
)
base_scanner_entity._connected = True
config_entry = await create_mock_platform(hass, config_entry, [base_scanner_entity])
assert config_entry.state is ConfigEntryState.LOADED
assert base_scanner_entity._scanner_option_associated_zone == "zone.kitchen"
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.state == "kitchen"
assert entity_state.attributes[ATTR_IN_ZONES] == ["zone.kitchen", "zone.home"]
@pytest.mark.parametrize(
("ip_address", "mac_address", "hostname"),
[("0.0.0.0", "ad:de:ef:be:ed:fe", "test.hostname.org")],
+12 -12
View File
@@ -11,9 +11,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
parametrize_target_entities,
@@ -101,7 +101,7 @@ async def test_door_trigger_options_validation(
),
],
)
async def test_door_trigger_binary_sensor_behavior_any(
async def test_door_trigger_binary_sensor_behavior_each(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -112,7 +112,7 @@ async def test_door_trigger_binary_sensor_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test door trigger fires for binary_sensor entities with device_class door."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_binary_sensors,
trigger_target_config=trigger_target_config,
@@ -169,7 +169,7 @@ async def test_door_trigger_binary_sensor_behavior_any(
),
],
)
async def test_door_trigger_cover_behavior_any(
async def test_door_trigger_cover_behavior_each(
hass: HomeAssistant,
target_covers: dict[str, list[str]],
trigger_target_config: dict,
@@ -180,7 +180,7 @@ async def test_door_trigger_cover_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test door trigger fires for cover entities with device_class door."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_covers,
trigger_target_config=trigger_target_config,
@@ -271,7 +271,7 @@ async def test_door_trigger_binary_sensor_behavior_first(
),
],
)
async def test_door_trigger_binary_sensor_behavior_last(
async def test_door_trigger_binary_sensor_behavior_all(
hass: HomeAssistant,
target_binary_sensors: dict[str, list[str]],
trigger_target_config: dict,
@@ -281,8 +281,8 @@ async def test_door_trigger_binary_sensor_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test door trigger fires when the last binary_sensor changes state."""
await assert_trigger_behavior_last(
"""Test door trigger fires when all binary_sensors have changed state."""
await assert_trigger_behavior_all(
hass,
target_entities=target_binary_sensors,
trigger_target_config=trigger_target_config,
@@ -407,7 +407,7 @@ async def test_door_trigger_cover_behavior_first(
),
],
)
async def test_door_trigger_cover_behavior_last(
async def test_door_trigger_cover_behavior_all(
hass: HomeAssistant,
target_covers: dict[str, list[str]],
trigger_target_config: dict,
@@ -417,8 +417,8 @@ async def test_door_trigger_cover_behavior_last(
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test door trigger fires when the last cover changes state."""
await assert_trigger_behavior_last(
"""Test door trigger fires when all covers have changed state."""
await assert_trigger_behavior_all(
hass,
target_entities=target_covers,
trigger_target_config=trigger_target_config,
+6 -6
View File
@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
from tests.components.common import (
TriggerStateDescription,
assert_trigger_behavior_any,
assert_trigger_behavior_all,
assert_trigger_behavior_each,
assert_trigger_behavior_first,
assert_trigger_behavior_last,
assert_trigger_gated_by_labs_flag,
assert_trigger_options_supported,
parametrize_target_entities,
@@ -85,7 +85,7 @@ async def test_fan_trigger_options_validation(
),
],
)
async def test_fan_state_trigger_behavior_any(
async def test_fan_state_trigger_behavior_each(
hass: HomeAssistant,
target_fans: dict[str, list[str]],
trigger_target_config: dict,
@@ -96,7 +96,7 @@ async def test_fan_state_trigger_behavior_any(
states: list[TriggerStateDescription],
) -> None:
"""Test the fan state trigger fires on any fan state change."""
await assert_trigger_behavior_any(
await assert_trigger_behavior_each(
hass,
target_entities=target_fans,
trigger_target_config=trigger_target_config,
@@ -171,7 +171,7 @@ async def test_fan_state_trigger_behavior_first(
),
],
)
async def test_fan_state_trigger_behavior_last(
async def test_fan_state_trigger_behavior_all(
hass: HomeAssistant,
target_fans: dict[str, list[str]],
trigger_target_config: dict,
@@ -182,7 +182,7 @@ async def test_fan_state_trigger_behavior_last(
states: list[TriggerStateDescription],
) -> None:
"""Test the fan trigger fires on the last fan state change."""
await assert_trigger_behavior_last(
await assert_trigger_behavior_all(
hass,
target_entities=target_fans,
trigger_target_config=trigger_target_config,

Some files were not shown because too many files have changed in this diff Show More