Compare commits

...

35 Commits

Author SHA1 Message Date
Erik 0f789a6797 Improve tests of helpers.trigger.extract_devices/entities 2026-05-28 10:06:24 +02:00
Erik Montnemery fd1a5d0c5a Add zone triggers entered/left zone (#171751) 2026-05-28 10:05:41 +02:00
Erik Montnemery 632ec39d53 Deprecate device tracker TrackerEntity location_name property (#171820) 2026-05-28 10:02:28 +02:00
Abílio Costa 67b9d28953 Fix OMIE sensors not updating on setup (#172383) 2026-05-28 08:29:53 +02:00
J. Nick Koston e3880eedb0 Bump yalexs to 9.2.1 (#172389) 2026-05-27 22:01:07 -05:00
J. Nick Koston ce64f5f902 Bump onvif-zeep-async to 4.1.1 (#172391) 2026-05-27 22:00:56 -05:00
J. Nick Koston 0da99a50fc Bump dbus-fast to 5.0.16 (#172378) 2026-05-27 17:16:36 -05:00
Arcadiy Ivanov 43f636be65 Include device identity in Matter light transition blocklist warning (#172324) 2026-05-27 23:58:37 +02:00
Simone Chemelli 262cdbfab5 Bump aioamazondevices to 13.8.1 (#172382) 2026-05-27 23:16:23 +02:00
puddly 8cbd358435 Bump ZHA to 1.4.0 (#172357) 2026-05-27 22:55:07 +02:00
torben-iometer df04b19a0a bump iometer version to 1.0.1 (#172338) 2026-05-27 22:19:20 +02:00
markvp adeb352079 Add GeneralDiagnostics sensors and fault binary sensors to Matter integration (#169830) 2026-05-27 21:07:08 +02:00
Stefan Agner 1e457600f1 Harden backup tar extraction with Python tar_filter (#172252) 2026-05-27 18:10:04 +02: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
214 changed files with 16477 additions and 1555 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
+2 -4
View File
@@ -92,8 +92,7 @@ def _extract_backup(
):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
filter="tar",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -119,8 +118,7 @@ def _extract_backup(
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
members=securetar.secure_path(istf),
filter="fully_trusted",
filter="tar",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
+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
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.0"]
"requirements": ["aioamazondevices==13.8.1"]
}
@@ -5,7 +5,7 @@
fields:
behavior:
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
}
@@ -1,7 +1,7 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
default: each
selector:
automation_behavior:
mode: trigger
@@ -20,7 +20,7 @@
"bluetooth-adapters==2.3.0",
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.15",
"dbus-fast==5.0.16",
"habluetooth==6.7.9"
]
}
+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"
@@ -12,13 +12,18 @@ from homeassistant.const import (
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TYPE,
CONF_ZONE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.trigger import (
TriggerActionType,
TriggerInfo,
_async_attach_trigger_cls,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -79,16 +84,18 @@ async def async_attach_trigger(
event = zone.EVENT_ENTER
else:
event = zone.EVENT_LEAVE
zone_config = {
CONF_PLATFORM: ZONE_DOMAIN,
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
return await zone.async_attach_trigger(
hass, zone_config, action, trigger_info, platform_type="device"
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
hass,
{
CONF_OPTIONS: {
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
CONF_ZONE: config[CONF_ZONE],
CONF_EVENT: event,
}
},
)
return await _async_attach_trigger_cls(
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
)
@@ -1,7 +1,8 @@
"""Provide functionality to keep track of devices."""
import asyncio
from typing import Any, final
import logging
from typing import TYPE_CHECKING, Any, final
from propcache.api import cached_property
@@ -16,8 +17,20 @@ 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,
async_get_hass_or_none,
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 +38,8 @@ 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.loader import async_suggest_report_issue
from homeassistant.util.hass_dict import HassKey
from .const import (
@@ -33,12 +48,15 @@ from .const import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONF_ASSOCIATED_ZONE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
)
_LOGGER = logging.getLogger(__name__)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
@@ -199,13 +217,38 @@ class TrackerEntity(
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
# If we reported setting deprecated _attr_location_name
__deprecated_attr_location_name_reported = False
__in_zones: list[str] | None = None
def __init_subclass__(cls, **kwargs: Any) -> None:
"""Post initialisation processing."""
super().__init_subclass__(**kwargs)
if "location_name" in cls.__dict__:
if cls.__module__.startswith("homeassistant.components."):
# Don't ask users to report issue for built in integrations,
# they already have issues opened on them.
return
report_issue = async_suggest_report_issue(
async_get_hass_or_none(), module=cls.__module__
)
_LOGGER.warning(
(
"%s::%s is overriding the deprecated location_name property on "
"an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
cls.__module__,
cls.__name__,
report_issue,
)
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
@@ -236,7 +279,32 @@ class TrackerEntity(
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
"""Return a location name for the current location of the device.
The property is deprecated and will be removed in Home Assistant 2027.7.
"""
if (location_name := self._attr_location_name) is not None:
if (
not self.__deprecated_attr_location_name_reported
and not self.__class__.__module__.startswith(
"homeassistant.components."
)
):
report_issue = async_suggest_report_issue(
self.hass, module=self.__class__.__module__
)
_LOGGER.warning(
(
"%s::%s is setting the deprecated _attr_location_name attribute "
"on an instance of TrackerEntity, this will be unsupported from "
"Home Assistant 2027.7, please %s"
),
self.__class__.__module__,
self.__class__.__name__,
report_issue,
)
self.__deprecated_attr_location_name_reported = True
return location_name
return self._attr_location_name
@cached_property
@@ -319,14 +387,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 +517,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
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["iometer==0.4.0"],
"requirements": ["iometer==1.0.1"],
"zeroconf": ["_iometer._tcp.local."]
}
-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
@@ -556,4 +556,48 @@ DISCOVERY_SCHEMAS = [
featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kOccupancy,
allow_multi=True,
),
# GeneralDiagnostics active fault sensors
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="GeneralDiagnosticsActiveHardwareFaults",
translation_key="active_hardware_faults",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=bool,
),
entity_class=MatterBinarySensor,
required_attributes=(
clusters.GeneralDiagnostics.Attributes.ActiveHardwareFaults,
),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="GeneralDiagnosticsActiveRadioFaults",
translation_key="active_radio_faults",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=bool,
),
entity_class=MatterBinarySensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.ActiveRadioFaults,),
),
MatterDiscoverySchema(
platform=Platform.BINARY_SENSOR,
entity_description=MatterBinarySensorEntityDescription(
key="GeneralDiagnosticsActiveNetworkFaults",
translation_key="active_network_faults",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=bool,
),
entity_class=MatterBinarySensor,
required_attributes=(
clusters.GeneralDiagnostics.Attributes.ActiveNetworkFaults,
),
),
]
+8 -1
View File
@@ -457,7 +457,14 @@ class MatterLight(MatterEntity, LightEntity):
self._transitions_disabled = True
LOGGER.warning(
"Detected a device that has been reported to have firmware issues "
"with light transitions. Transitions will be disabled for this light"
"with light transitions. Transitions will be disabled for this "
"light: %s %s (vendor_id: %s, product_id: %s, hw: %s, sw: %s)",
device_info.vendorName,
device_info.productName,
device_info.vendorID,
device_info.productID,
device_info.hardwareVersionString,
device_info.softwareVersionString,
)
+53
View File
@@ -137,6 +137,17 @@ RVC_OPERATIONAL_STATE_ERROR_MAP = {
_rvc_err.kNavigationSensorObscured: ("navigation_sensor_obscured"),
}
BOOT_REASON_MAP = {
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnspecified: "unspecified",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kPowerOnReboot: "power_on_reboot",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kBrownOutReset: "brown_out_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareWatchdogReset: "software_watchdog_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kHardwareWatchdogReset: "hardware_watchdog_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareUpdateCompleted: "software_update_completed",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareReset: "software_reset",
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnknownEnumValue: None,
}
BOOST_STATE_MAP = {
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive",
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active",
@@ -1575,4 +1586,46 @@ DISCOVERY_SCHEMAS = [
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
),
# GeneralDiagnostics cluster sensors
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="GeneralDiagnosticsRebootCount",
translation_key="reboot_count",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL_INCREASING,
),
entity_class=MatterSensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.RebootCount,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="GeneralDiagnosticsUpTime",
translation_key="uptime",
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_to_ha=lambda uptime: dt_util.utcnow() - timedelta(seconds=uptime),
),
entity_class=MatterSensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.UpTime,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="GeneralDiagnosticsBootReason",
translation_key="boot_reason",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
options=[
reason for reason in BOOT_REASON_MAP.values() if reason is not None
],
device_to_ha=BOOT_REASON_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.GeneralDiagnostics.Attributes.BootReason,),
),
]
@@ -47,6 +47,15 @@
},
"entity": {
"binary_sensor": {
"active_hardware_faults": {
"name": "Hardware faults"
},
"active_network_faults": {
"name": "Network faults"
},
"active_radio_faults": {
"name": "Radio faults"
},
"actuator": {
"name": "Actuator"
},
@@ -408,6 +417,18 @@
"battery_voltage": {
"name": "Battery voltage"
},
"boot_reason": {
"name": "Boot reason",
"state": {
"brown_out_reset": "Brownout reset",
"hardware_watchdog_reset": "Hardware watchdog reset",
"power_on_reboot": "Power-on reboot",
"software_reset": "Software reset",
"software_update_completed": "Software update completed",
"software_watchdog_reset": "Software watchdog reset",
"unspecified": "Unspecified"
}
},
"contamination_state": {
"name": "Contamination state",
"state": {
@@ -576,6 +597,9 @@
"reactive_current": {
"name": "Reactive current"
},
"reboot_count": {
"name": "Reboot count"
},
"rms_current": {
"name": "Effective current"
},
@@ -600,6 +624,9 @@
"medium": "[%key:common::state::medium%]"
}
},
"uptime": {
"name": "Uptime"
},
"valve_position": {
"name": "Valve position"
},
@@ -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
+5
View File
@@ -52,6 +52,11 @@ class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity):
self._attr_unique_id = pyomie_series_name
self._pyomie_series_name = pyomie_series_name
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
@callback
def _handle_coordinator_update(self) -> None:
"""Update this sensor's state from the coordinator results."""
+1 -1
View File
@@ -14,7 +14,7 @@
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
"onvif-zeep-async==4.1.0",
"onvif-zeep-async==4.1.1",
"onvif_parsers==2.3.0",
"WSDiscovery==2.1.2"
]
+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
+1 -1
View File
@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
}
@@ -80,31 +80,31 @@ class ZHAAlarmControlPanel(ZHAEntity, AlarmControlPanelEntity):
"""Whether the code is required for arm actions."""
return self.entity_data.entity.code_arm_required
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
await self.entity_data.entity.async_alarm_disarm(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self.entity_data.entity.async_alarm_arm_home(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.entity_data.entity.async_alarm_arm_away(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.entity_data.entity.async_alarm_arm_night(code)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command."""
await self.entity_data.entity.async_alarm_trigger(code)
+1 -1
View File
@@ -52,7 +52,7 @@ class ZHAButton(ZHAEntity, ButtonEntity):
self.entity_data.entity.info_object.device_class
)
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_press(self) -> None:
"""Send out a update command."""
await self.entity_data.entity.async_press()
+4 -4
View File
@@ -203,25 +203,25 @@ class Thermostat(ZHAEntity, ClimateEntity):
)
super()._handle_entity_events(event)
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
await self.entity_data.entity.async_set_fan_mode(fan_mode=fan_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
await self.entity_data.entity.async_set_hvac_mode(hvac_mode=hvac_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
await self.entity_data.entity.async_set_temperature(
+4
View File
@@ -75,3 +75,7 @@ MFG_CLUSTER_ID_START = 0xFC00
ZHA_ALARM_OPTIONS = "zha_alarm_options"
ZHA_OPTIONS = "zha_options"
# Dispatcher signal carrying device reconfigure progress events (bind result,
# attribute reporting result, configure complete) to the websocket subscriber.
SIGNAL_DEVICE_RECONFIGURE_EVENT = "zha_device_reconfigure_event"
+8 -8
View File
@@ -122,31 +122,31 @@ class ZhaCover(ZHAEntity, CoverEntity):
"""Return the current tilt position of the cover."""
return self.entity_data.entity.current_cover_tilt_position
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.entity_data.entity.async_open_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
await self.entity_data.entity.async_open_cover_tilt()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.entity_data.entity.async_close_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
await self.entity_data.entity.async_close_cover_tilt()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
await self.entity_data.entity.async_set_cover_position(
@@ -154,7 +154,7 @@ class ZhaCover(ZHAEntity, CoverEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
await self.entity_data.entity.async_set_cover_tilt_position(
@@ -162,13 +162,13 @@ class ZhaCover(ZHAEntity, CoverEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.entity_data.entity.async_stop_cover()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover tilt."""
await self.entity_data.entity.async_stop_cover_tilt()
+77 -93
View File
@@ -3,36 +3,26 @@
from typing import Any
import voluptuous as vol
from zha.exceptions import ZHAException
from zha.zigbee.cluster_handlers.const import (
CLUSTER_HANDLER_IAS_WD,
CLUSTER_HANDLER_INOVELLI,
)
from zha.zigbee.cluster_handlers.manufacturerspecific import (
AllLEDEffectType,
SingleLEDEffectType,
)
from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType
from zigpy.zcl.clusters.security import IasWd
from homeassistant.components.device_automation import InvalidDeviceAutomationConfig
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import DOMAIN
from .helpers import async_get_zha_device_proxy
from .helpers import async_get_zha_device_proxy, convert_zha_error_to_ha_error
from .websocket_api import SERVICE_WARNING_DEVICE_SQUAWK, SERVICE_WARNING_DEVICE_WARN
# mypy: disallow-any-generics
INOVELLI_CLUSTER_ID = 0xFC31
ACTION_SQUAWK = "squawk"
ACTION_WARN = "warn"
ATTR_DATA = "data"
ATTR_IEEE = "ieee"
CONF_ZHA_ACTION_TYPE = "zha_action_type"
ZHA_ACTION_TYPE_SERVICE_CALL = "service_call"
ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND = "cluster_handler_command"
INOVELLI_ALL_LED_EFFECT = "issue_all_led_effect"
INOVELLI_INDIVIDUAL_LED_EFFECT = "issue_individual_led_effect"
@@ -73,24 +63,18 @@ ACTION_SCHEMA = vol.Any(
DEFAULT_ACTION_SCHEMA,
)
DEVICE_ACTIONS = {
CLUSTER_HANDLER_IAS_WD: [
# Maps a cluster_id the device must expose to the available actions.
DEVICE_ACTIONS_BY_CLUSTER_ID: dict[int, list[dict[str, str]]] = {
IasWd.cluster_id: [
{CONF_TYPE: ACTION_SQUAWK, CONF_DOMAIN: DOMAIN},
{CONF_TYPE: ACTION_WARN, CONF_DOMAIN: DOMAIN},
],
CLUSTER_HANDLER_INOVELLI: [
INOVELLI_CLUSTER_ID: [
{CONF_TYPE: INOVELLI_ALL_LED_EFFECT, CONF_DOMAIN: DOMAIN},
{CONF_TYPE: INOVELLI_INDIVIDUAL_LED_EFFECT, CONF_DOMAIN: DOMAIN},
],
}
DEVICE_ACTION_TYPES = {
ACTION_SQUAWK: ZHA_ACTION_TYPE_SERVICE_CALL,
ACTION_WARN: ZHA_ACTION_TYPE_SERVICE_CALL,
INOVELLI_ALL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND,
INOVELLI_INDIVIDUAL_LED_EFFECT: ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND,
}
DEVICE_ACTION_SCHEMAS = {
INOVELLI_ALL_LED_EFFECT: vol.Schema(
{
@@ -116,11 +100,6 @@ SERVICE_NAMES = {
ACTION_WARN: SERVICE_WARNING_DEVICE_WARN,
}
CLUSTER_HANDLER_MAPPINGS = {
INOVELLI_ALL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI,
INOVELLI_INDIVIDUAL_LED_EFFECT: CLUSTER_HANDLER_INOVELLI,
}
async def async_call_action_from_config(
hass: HomeAssistant,
@@ -129,9 +108,9 @@ async def async_call_action_from_config(
context: Context | None,
) -> None:
"""Perform an action based on configuration."""
await ZHA_ACTION_TYPES[DEVICE_ACTION_TYPES[config[CONF_TYPE]]](
hass, config, variables, context
)
action_type = config[CONF_TYPE]
handler = ACTION_HANDLERS[action_type]
await handler(hass, config, context)
async def async_validate_action_config(
@@ -150,19 +129,18 @@ async def async_get_actions(
zha_device = async_get_zha_device_proxy(hass, device_id).device
except KeyError, AttributeError:
return []
cluster_handlers = [
ch.name
for endpoint in zha_device.endpoints.values()
for ch in endpoint.claimed_cluster_handlers.values()
]
actions = [
action
for cluster_handler, cluster_handler_actions in DEVICE_ACTIONS.items()
for action in cluster_handler_actions
if cluster_handler in cluster_handlers
]
for action in actions:
action[CONF_DEVICE_ID] = device_id
cluster_ids = {
cluster_id
for ep_id, endpoint in zha_device.device.endpoints.items()
if ep_id != 0
for cluster_id in endpoint.in_clusters
}
actions: list[dict[str, str]] = []
for required_cluster_id, cluster_actions in DEVICE_ACTIONS_BY_CLUSTER_ID.items():
if required_cluster_id in cluster_ids:
actions.extend(
{**action, CONF_DEVICE_ID: device_id} for action in cluster_actions
)
return actions
@@ -175,69 +153,75 @@ async def async_get_action_capabilities(
return {"extra_fields": fields}
async def _execute_service_based_action(
async def _execute_siren_service(
hass: HomeAssistant,
config: dict[str, Any],
variables: TemplateVarsType,
context: Context | None,
) -> None:
action_type = config[CONF_TYPE]
service_name = SERVICE_NAMES[action_type]
try:
zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device
except KeyError, AttributeError:
return
service_data = {ATTR_IEEE: str(zha_device.ieee)}
await hass.services.async_call(
DOMAIN, service_name, service_data, blocking=True, context=context
DOMAIN,
SERVICE_NAMES[config[CONF_TYPE]],
{ATTR_IEEE: str(zha_device.ieee)},
blocking=True,
context=context,
)
async def _execute_cluster_handler_command_based_action(
hass: HomeAssistant,
config: dict[str, Any],
variables: TemplateVarsType,
context: Context | None,
) -> None:
action_type = config[CONF_TYPE]
cluster_handler_name = CLUSTER_HANDLER_MAPPINGS[action_type]
def _find_inovelli_cluster(hass: HomeAssistant, config: dict[str, Any]) -> Any:
try:
zha_device = async_get_zha_device_proxy(hass, config[CONF_DEVICE_ID]).device
except KeyError, AttributeError:
return
action_cluster_handler = None
for endpoint in zha_device.endpoints.values():
for cluster_handler in endpoint.all_cluster_handlers.values():
if cluster_handler.name == cluster_handler_name:
action_cluster_handler = cluster_handler
break
if action_cluster_handler is None:
except (KeyError, AttributeError) as err:
raise InvalidDeviceAutomationConfig(
f"Unable to execute cluster handler action -"
f" cluster handler: {cluster_handler_name} action:"
f" {action_type}"
)
if not hasattr(action_cluster_handler, action_type):
raise InvalidDeviceAutomationConfig(
f"Unable to execute cluster handler -"
f" cluster handler: {cluster_handler_name} action:"
f" {action_type}"
)
f"ZHA device {config[CONF_DEVICE_ID]} not found"
) from err
try:
await getattr(action_cluster_handler, action_type)(**config)
except ZHAException as err:
raise HomeAssistantError(err) from err
return zha_device.device.find_cluster(cluster_id=INOVELLI_CLUSTER_ID)
except ValueError as err:
raise InvalidDeviceAutomationConfig(
f"Device does not expose Inovelli cluster 0x{INOVELLI_CLUSTER_ID:04x}"
) from err
ZHA_ACTION_TYPES = {
ZHA_ACTION_TYPE_SERVICE_CALL: _execute_service_based_action,
ZHA_ACTION_TYPE_CLUSTER_HANDLER_COMMAND: (
_execute_cluster_handler_command_based_action
),
async def _execute_inovelli_all_led_effect(
hass: HomeAssistant,
config: dict[str, Any],
context: Context | None,
) -> None:
cluster = _find_inovelli_cluster(hass, config)
async with convert_zha_error_to_ha_error():
await cluster.led_effect(
led_effect=config["effect_type"],
led_color=config["color"],
led_level=config["level"],
led_duration=config["duration"],
)
async def _execute_inovelli_individual_led_effect(
hass: HomeAssistant,
config: dict[str, Any],
context: Context | None,
) -> None:
cluster = _find_inovelli_cluster(hass, config)
async with convert_zha_error_to_ha_error():
await cluster.individual_led_effect(
led_effect=config["effect_type"],
led_color=config["color"],
led_level=config["level"],
led_duration=config["duration"],
led_number=config["led_number"],
)
ACTION_HANDLERS = {
ACTION_SQUAWK: _execute_siren_service,
ACTION_WARN: _execute_siren_service,
INOVELLI_ALL_LED_EFFECT: _execute_inovelli_all_led_effect,
INOVELLI_INDIVIDUAL_LED_EFFECT: _execute_inovelli_individual_led_effect,
}
+1 -1
View File
@@ -206,7 +206,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
await super().async_will_remove_from_hass()
self.remove_future.set_result(True)
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_update(self) -> None:
"""Update the entity."""
await self.entity_data.entity.async_update()
+4 -4
View File
@@ -92,7 +92,7 @@ class ZhaFan(FanEntity, ZHAEntity):
"""Return the number of speeds the fan supports."""
return self.entity_data.entity.speed_count
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_on(
self,
percentage: int | None = None,
@@ -105,19 +105,19 @@ class ZhaFan(FanEntity, ZHAEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_data.entity.async_turn_off()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
await self.entity_data.entity.async_set_percentage(percentage=percentage)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode for the fan."""
await self.entity_data.entity.async_set_preset_mode(preset_mode=preset_mode)
+63 -56
View File
@@ -2,23 +2,23 @@
import asyncio
import collections
from collections.abc import Awaitable, Callable, Coroutine, Mapping
from collections.abc import AsyncGenerator, Callable, Mapping
from contextlib import asynccontextmanager
import copy
import dataclasses
import enum
import functools
import itertools
import logging
import queue
import re
import time
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast
from typing import TYPE_CHECKING, Any, NamedTuple, cast
from zoneinfo import ZoneInfo
import voluptuous as vol
from zha.application import Platform as ZhaPlatform
from zha.application.const import (
ATTR_CLUSTER_ID,
ATTR_DEVICE_IEEE,
ATTR_TYPE,
ATTR_UNIQUE_ID,
@@ -28,11 +28,6 @@ from zha.application.const import (
CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
ZHA_CLUSTER_HANDLER_CFG_DONE,
ZHA_CLUSTER_HANDLER_MSG,
ZHA_CLUSTER_HANDLER_MSG_BIND,
ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
ZHA_CLUSTER_HANDLER_MSG_DATA,
ZHA_EVENT,
ZHA_GW_MSG,
ZHA_GW_MSG_DEVICE_FULL_INIT,
@@ -71,10 +66,11 @@ from zha.application.platforms import GroupEntity, PlatformEntity
from zha.event import EventBase
from zha.exceptions import ZHAException
from zha.mixins import LogMixin
from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReportingEvent
from zha.zigbee.device import (
ClusterHandlerConfigurationComplete,
ClusterBindEvent,
ClusterConfigureReportingEvent,
Device,
DeviceConfiguredEvent,
DeviceEntityAddedEvent,
DeviceEntityRemovedEvent,
DeviceFirmwareInfoUpdatedEvent,
@@ -126,9 +122,7 @@ from homeassistant.util.logging import HomeAssistantQueueHandler
from .const import (
ATTR_ACTIVE_COORDINATOR,
ATTR_ATTRIBUTES,
ATTR_AVAILABLE,
ATTR_CLUSTER_NAME,
ATTR_DEVICE_TYPE,
ATTR_ENDPOINT_NAMES,
ATTR_EXPOSES_FEATURES,
@@ -144,7 +138,6 @@ from .const import (
ATTR_ROUTES,
ATTR_RSSI,
ATTR_SIGNATURE,
ATTR_SUCCESS,
CONF_ALARM_ARM_REQUIRES_CODE,
CONF_ALARM_FAILED_TRIES,
CONF_ALARM_MASTER_CODE,
@@ -168,6 +161,7 @@ from .const import (
DEFAULT_DATABASE_NAME,
DEVICE_PAIRING_STATUS,
DOMAIN,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
ZHA_ALARM_OPTIONS,
ZHA_OPTIONS,
)
@@ -450,50 +444,46 @@ class ZHADeviceProxy(EventBase):
)
@callback
def handle_zha_channel_configure_reporting(
def handle_zha_cluster_bind(self, event: ClusterBindEvent) -> None:
"""Forward a cluster bind result to the reconfigure websocket."""
async_dispatcher_send(
self.gateway_proxy.hass,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
{
"type": "zha_channel_bind",
"zha_channel_msg_data": {
"cluster_name": event.cluster_name,
"cluster_id": event.cluster_id,
"success": event.success,
},
},
)
@callback
def handle_zha_cluster_configure_reporting(
self, event: ClusterConfigureReportingEvent
) -> None:
"""Handle a ZHA cluster configure reporting event."""
"""Forward a cluster reporting-configured result to the reconfigure websocket."""
async_dispatcher_send(
self.gateway_proxy.hass,
ZHA_CLUSTER_HANDLER_MSG,
SIGNAL_DEVICE_RECONFIGURE_EVENT,
{
ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT,
ZHA_CLUSTER_HANDLER_MSG_DATA: {
ATTR_CLUSTER_NAME: event.cluster_name,
ATTR_CLUSTER_ID: event.cluster_id,
ATTR_ATTRIBUTES: event.attributes,
"type": "zha_channel_configure_reporting",
"zha_channel_msg_data": {
"cluster_name": event.cluster_name,
"cluster_id": event.cluster_id,
"attributes": event.attributes,
},
},
)
@callback
def handle_zha_channel_cfg_done(
self, event: ClusterHandlerConfigurationComplete
) -> None:
"""Handle a ZHA cluster configure reporting event."""
def handle_zha_device_configured(self, event: DeviceConfiguredEvent) -> None:
"""Forward the device configuration-complete signal to the reconfigure websocket."""
async_dispatcher_send(
self.gateway_proxy.hass,
ZHA_CLUSTER_HANDLER_MSG,
{
ATTR_TYPE: ZHA_CLUSTER_HANDLER_CFG_DONE,
},
)
@callback
def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None:
"""Handle a ZHA cluster bind event."""
async_dispatcher_send(
self.gateway_proxy.hass,
ZHA_CLUSTER_HANDLER_MSG,
{
ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND,
ZHA_CLUSTER_HANDLER_MSG_DATA: {
ATTR_CLUSTER_NAME: event.cluster_name,
ATTR_CLUSTER_ID: event.cluster_id,
ATTR_SUCCESS: event.success,
},
},
SIGNAL_DEVICE_RECONFIGURE_EVENT,
{"type": "zha_channel_cfg_done"},
)
@callback
@@ -501,6 +491,9 @@ class ZHADeviceProxy(EventBase):
self, event: DeviceEntityAddedEvent
) -> None:
"""Handle a new entity being added to a device at runtime."""
if event.platform is ZhaPlatform.VIRTUAL:
return
key = (event.platform, event.unique_id)
if (entity := self.device.platform_entities.get(key)) is None:
return
@@ -515,6 +508,9 @@ class ZHADeviceProxy(EventBase):
self, event: DeviceEntityRemovedEvent
) -> None:
"""Handle an entity being removed from a device at runtime."""
if event.platform is ZhaPlatform.VIRTUAL:
return
if not event.remove:
# Soft remove: signal the entity to unload; registry entry stays
async_dispatcher_send(
@@ -911,6 +907,9 @@ class ZHAGatewayProxy(EventBase):
if isinstance(proxy_object, ZHADeviceProxy):
for entity in proxy_object.device.platform_entities.values():
if entity.PLATFORM is ZhaPlatform.VIRTUAL:
continue
ha_zha_data.platforms[Platform(entity.PLATFORM)].append(
EntityData(
entity=entity, device_proxy=proxy_object, group_proxy=None
@@ -918,6 +917,9 @@ class ZHAGatewayProxy(EventBase):
)
else:
for entity in proxy_object.group.group_entities.values():
if entity.PLATFORM is ZhaPlatform.VIRTUAL:
continue
ha_zha_data.platforms[Platform(entity.PLATFORM)].append(
EntityData(
entity=entity,
@@ -1386,19 +1388,24 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData:
)
def convert_zha_error_to_ha_error[**_P, _EntityT: ZHAEntity](
func: Callable[Concatenate[_EntityT, _P], Awaitable[None]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
@asynccontextmanager
async def convert_zha_error_to_ha_error() -> AsyncGenerator[None]:
"""Decorate ZHA commands and re-raises ZHAException as HomeAssistantError."""
try:
yield
except TimeoutError as exc:
raise HomeAssistantError(
"Failed to send request: device did not respond"
) from exc
except zigpy.exceptions.ZigbeeException as exc:
message = "Failed to send request"
@functools.wraps(func)
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
return await func(self, *args, **kwargs)
except ZHAException as err:
raise HomeAssistantError(err) from err
if str(exc):
message = f"{message}: {exc}"
return handler
raise HomeAssistantError(message) from exc
except ZHAException as err:
raise HomeAssistantError(err) from err
def exclude_none_values(obj: Mapping[str, Any]) -> dict[str, Any]:
+2 -2
View File
@@ -171,7 +171,7 @@ class Light(LightEntity, ZHAEntity):
"""Return the current effect."""
return self.entity_data.entity.effect
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
color_temp = (
@@ -189,7 +189,7 @@ class Light(LightEntity, ZHAEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_data.entity.async_turn_off(
+6 -6
View File
@@ -94,19 +94,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity):
"""Return true if entity is locked."""
return self.entity_data.entity.is_locked
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
await self.entity_data.entity.async_lock()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
await self.entity_data.entity.async_unlock()
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None:
"""Set the user_code to index X on the lock."""
await self.entity_data.entity.async_set_lock_user_code(
@@ -114,19 +114,19 @@ class ZhaDoorLock(ZHAEntity, LockEntity):
)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_enable_lock_user_code(self, code_slot: int) -> None:
"""Enable user_code at index X on the lock."""
await self.entity_data.entity.async_enable_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_disable_lock_user_code(self, code_slot: int) -> None:
"""Disable user_code at index X on the lock."""
await self.entity_data.entity.async_disable_lock_user_code(code_slot=code_slot)
self.async_write_ha_state()
@convert_zha_error_to_ha_error
@convert_zha_error_to_ha_error()
async def async_clear_lock_user_code(self, code_slot: int) -> None:
"""Clear the user_code at index X on the lock."""
await self.entity_data.entity.async_clear_lock_user_code(code_slot=code_slot)

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