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
231 changed files with 16477 additions and 2542 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
Generated
-2
View File
@@ -453,8 +453,6 @@ CLAUDE.md @home-assistant/core
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
/homeassistant/components/edifier_infrared/ @abmantis
/tests/components/edifier_infrared/ @abmantis
/homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt
+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,18 +0,0 @@
"""Edifier infrared integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Edifier IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Edifier IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,82 +0,0 @@
"""Config flow for Edifier infrared integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_MODEL
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
MODEL_TO_COMMAND_SET,
EdifierModel,
)
class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Edifier IR."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - select IR entity and speaker model."""
emitter_entity_ids = async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID]
model = EdifierModel(user_input[CONF_MODEL])
command_set = MODEL_TO_COMMAND_SET[model]
await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}")
self._abort_if_unique_id_configured()
entity_name = infrared_entity_id
if state := self.hass.states.get(infrared_entity_id):
entity_name = state.name or infrared_entity_id
return self.async_create_entry(
title=f"Edifier {model.value} via {entity_name}",
data={
CONF_INFRARED_ENTITY_ID: infrared_entity_id,
CONF_MODEL: model.value,
CONF_COMMAND_SET: command_set.value,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids
)
),
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=[model.value for model in EdifierModel],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
)
@@ -1,76 +0,0 @@
"""Constants for the Edifier infrared integration."""
from enum import StrEnum
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
DOMAIN = "edifier_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_COMMAND_SET = "command_set"
type EdifierCode = (
EdifierR1700BTCode
| EdifierR1280DBCode
| EdifierR1280TCode
| EdifierS360DBCode
| EdifierRC20GCode
)
class EdifierCommandSets(StrEnum):
"""Edifier command set groupings."""
R1700BT = "r1700bt"
R1280DB = "r1280db"
R1280T = "r1280t"
S360DB = "s360db"
RC20G = "rc20g"
class EdifierModel(StrEnum):
"""Edifier speaker models."""
# R1700BT command set
R1700BT = "R1700BT"
R1700BTS = "R1700BTs"
RC17A = "RC17A"
RC80B = "RC80B"
R1855DB = "R1855DB"
# R1280DB command set
R1280DB = "R1280DB"
R2730DB = "R2730DB"
RC10D1 = "RC10D1"
R2000DB = "R2000DB"
# R1280T command set (basic)
R1280T = "R1280T"
# S360DB command set
S360DB = "S360DB"
RC31A = "RC31A"
# RC20G command set (unique left/right volume controls)
RC20G = "RC20G"
MODEL_TO_COMMAND_SET: dict[EdifierModel, EdifierCommandSets] = {
# R1700BT command set
EdifierModel.R1700BT: EdifierCommandSets.R1700BT,
EdifierModel.R1700BTS: EdifierCommandSets.R1700BT,
EdifierModel.RC17A: EdifierCommandSets.R1700BT,
EdifierModel.RC80B: EdifierCommandSets.R1700BT,
EdifierModel.R1855DB: EdifierCommandSets.R1700BT,
# R1280DB command set
EdifierModel.R1280DB: EdifierCommandSets.R1280DB,
EdifierModel.R2730DB: EdifierCommandSets.R1280DB,
EdifierModel.RC10D1: EdifierCommandSets.R1280DB,
EdifierModel.R2000DB: EdifierCommandSets.R1280DB,
# R1280T command set
EdifierModel.R1280T: EdifierCommandSets.R1280T,
# S360DB command set
EdifierModel.S360DB: EdifierCommandSets.S360DB,
EdifierModel.RC31A: EdifierCommandSets.S360DB,
# RC20G command set
EdifierModel.RC20G: EdifierCommandSets.RC20G,
}
@@ -1,25 +0,0 @@
"""Common entity for Edifier infrared integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, EdifierModel
class EdifierIrEntity(Entity):
"""Edifier IR base entity providing common device info."""
_attr_has_entity_name = True
def __init__(
self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str
) -> None:
"""Initialize Edifier IR entity."""
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"Edifier {model.value}",
manufacturer="Edifier",
model=model.value,
)
@@ -1,11 +0,0 @@
{
"domain": "edifier_infrared",
"name": "Edifier Infrared",
"codeowners": ["@abmantis"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/edifier_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -1,179 +0,0 @@
"""Media player platform for Edifier infrared integration."""
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
EdifierCode,
EdifierCommandSets,
EdifierModel,
)
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
COMMAND_SET_COMMANDS: dict[
EdifierCommandSets,
dict[
MediaPlayerEntityFeature,
tuple[EdifierCode | tuple[EdifierCode, ...], ...],
],
] = {
EdifierCommandSets.R1700BT: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1700BTCode.VOLUME_UP,),
(EdifierR1700BTCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,),
},
EdifierCommandSets.R1280DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280DBCode.VOLUME_UP,),
(EdifierR1280DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,),
},
EdifierCommandSets.R1280T: {
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280TCode.VOLUME_UP,),
(EdifierR1280TCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,),
},
EdifierCommandSets.S360DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierS360DBCode.VOLUME_UP,),
(EdifierS360DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,),
},
EdifierCommandSets.RC20G: {
MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
},
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR media player."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSets(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
[EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)]
)
class EdifierIrMediaPlayer(
EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity
):
"""Edifier IR media player entity."""
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
command_set: EdifierCommandSets,
) -> None:
"""Initialize Edifier IR media player."""
super().__init__(entry, model, unique_id_suffix="media_player")
self._infrared_emitter_entity_id = infrared_entity_id
self._commands = COMMAND_SET_COMMANDS[command_set]
self._attr_state = MediaPlayerState.ON
self._attr_supported_features = MediaPlayerEntityFeature(0)
for feature in self._commands:
self._attr_supported_features |= feature
async def _send_codes(self, *codes: EdifierCode) -> None:
"""Send one or more IR commands."""
for code in codes:
await self._send_command(code.to_command())
async def async_turn_on(self) -> None:
"""Turn on the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON])
async def async_turn_off(self) -> None:
"""Turn off the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF])
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0])
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1])
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE])
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY])
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE])
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK])
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK])
@@ -1,114 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure: done
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category:
status: exempt
comment: |
The media player entity is the primary entity and does not need a category.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
The media player entity is the primary entity and should be enabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration depends on infrared_protocols which provides only code
definitions with no I/O, so async dependency does not apply.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo
@@ -1,22 +0,0 @@
{
"config": {
"abort": {
"already_configured": "This Edifier device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"infrared_entity_id": "IR transmitter",
"model": "Speaker model"
},
"data_description": {
"infrared_entity_id": "Select the infrared transmitter entity to use.",
"model": "Choose your Edifier speaker model from the list."
},
"description": "Configure your Edifier speaker for IR control.",
"title": "Set up Edifier IR speaker"
}
}
}
}
+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()

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