Merge branch 'input_button_trigger' of github.com:home-assistant/core into button_being_held_trigger

This commit is contained in:
abmantis
2026-08-02 22:51:22 +01:00
65 changed files with 721 additions and 204 deletions
+3 -11
View File
@@ -168,17 +168,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry)
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
# Devices left in both the config entry and its subentry by Home Assistant Core
# 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry
# migration, so there's nothing to correct here.
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
@@ -86,6 +86,8 @@ class CoolmasterConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA)
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
errors = {}
host = user_input[CONF_HOST]
@@ -1,5 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_units": "Could not find any HVAC units in CoolMasterNet host."
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["energieleser==0.1.5"],
"requirements": ["energieleser==0.1.6"],
"zeroconf": [
{
"type": "_stromleser._tcp.local."
@@ -18,7 +18,7 @@
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==45.6.1",
"esphome-dashboard-api==1.3.0",
"esphome-dashboard-api==1.4.0",
"bleak-esphome==3.9.7"
],
"zeroconf": ["_esphomelib._tcp.local."]
@@ -230,17 +230,9 @@ async def async_migrate_entry(
),
)
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
# Devices left in both the config entry and its subentry by Home Assistant Core
# 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry
# migration, so there's nothing to correct here.
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
@@ -123,6 +123,7 @@ async def async_setup_entry(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Google",
entry_type=dr.DeviceEntryType.SERVICE,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
@@ -25,6 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceEntryType,
DeviceInfo,
async_get_device_id_by_identifier,
)
@@ -356,6 +357,7 @@ class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]](
self.entity_description = description
self._attr_unique_id = f"{entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry_id)},
manufacturer="Google",
)
@@ -19,9 +19,11 @@ from aiohue.v2.controllers.sensors import (
)
from aiohue.v2.models.camera_motion import CameraMotion
from aiohue.v2.models.contact import Contact, ContactState
from aiohue.v2.models.convenience_area_motion import ConvenienceAreaMotion
from aiohue.v2.models.entertainment_configuration import EntertainmentStatus
from aiohue.v2.models.grouped_motion import GroupedMotion
from aiohue.v2.models.motion import Motion
from aiohue.v2.models.motion_area_configuration import MotionAreaHealth
from aiohue.v2.models.resource import ResourceTypes
from aiohue.v2.models.security_area_motion import SecurityAreaMotion
from aiohue.v2.models.tamper import Tamper, TamperState
@@ -181,11 +183,10 @@ class HueGroupedMotionSensor(HueMotionSensor):
class HueMotionAwareSensor(HueMotionSensor):
"""Representation of a Motion sensor based on Hue Motion Aware.
Note that we only create sensors for the SecurityAreaMotion resource
and not for the ConvenienceAreaMotion resource, because the latter
does not have a state when it's not directly controlling lights.
The SecurityAreaMotion resource is always available with a state, allowing
Home Assistant users to actually use it as a motion sensor in their HA automations.
A MotionAware zone owns both a ConvenienceAreaMotion and a SecurityAreaMotion
service, and which of the two carries the motion state depends on how the zone
is set up in the Hue app. The source is therefore resolved from the zone, and
the state is unknown while neither service reports a valid one.
"""
controller: SecurityAreaMotionController
@@ -201,6 +202,26 @@ class HueMotionAwareSensor(HueMotionSensor):
"""Return sensor name."""
return self.controller.get_motion_area_configuration(self.resource.id).name
@property
@override
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
zone = self._motion_area_configuration
# switching a zone off leaves the `enabled` flag of its services untouched,
# so the zone is the only reliable signal that it stopped reporting motion
if not zone.enabled or zone.health == MotionAreaHealth.NOT_RUNNING:
return None
# either service can be the one reporting a zone, with the other left holding
# a reading that no longer changes, so take the first that has a valid one.
# The convenience service comes first because a zone that is bound to lights
# reports on that service.
for source in (self._convenience_service, self.resource):
if source is None or not source.enabled or source.motion is None:
continue
if (value := source.motion.value) is not None:
return value
return None
def __init__(
self,
bridge: HueBridge,
@@ -229,6 +250,32 @@ class HueMotionAwareSensor(HueMotionSensor):
self._handle_event, self._motion_area_configuration.id
)
)
# zones that are bound to lights report their motion on the convenience service
if (service_id := self._convenience_service_id) is not None:
self.async_on_remove(
self.bridge.api.sensors.convenience_area_motion.subscribe(
self._handle_event, service_id
)
)
@property
def _convenience_service_id(self) -> str | None:
"""Return the id of this zone's ConvenienceAreaMotion service, if it has one."""
return next(
(
service.rid
for service in self._motion_area_configuration.services
if service.rtype == ResourceTypes.CONVENIENCE_AREA_MOTION
),
None,
)
@property
def _convenience_service(self) -> ConvenienceAreaMotion | None:
"""Return this zone's ConvenienceAreaMotion resource, if the bridge has it."""
if (service_id := self._convenience_service_id) is None:
return None
return self.bridge.api.sensors.convenience_area_motion.get(service_id)
# pylint: disable-next=home-assistant-enforce-class-module
+8 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, override
from pyinsteon import devices
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -88,6 +89,8 @@ class InsteonEntity(Entity):
@override
def device_info(self) -> DeviceInfo:
"""Return device information."""
config_entry = self.platform.config_entry
assert config_entry
return DeviceInfo(
identifiers={(DOMAIN, str(self._insteon_device.address))},
manufacturer="SmartLabs, Inc",
@@ -100,7 +103,11 @@ class InsteonEntity(Entity):
f"{self._insteon_device.firmware:02x} Engine Version:"
f" {self._insteon_device.engine_version}"
),
via_device=(DOMAIN, str(devices.modem.address)),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, str(devices.modem.address)),
config_entry_id=config_entry.entry_id,
),
configuration_url=f"homeassistant://insteon/device/config/{self._insteon_device.id}",
)
+6 -1
View File
@@ -1,5 +1,6 @@
"""Base entity for Iskra devices."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -26,7 +27,11 @@ class IskraEntity(CoordinatorEntity[IskraDataUpdateCoordinator]):
name=self.device.model,
sw_version=self.device.fw_version,
serial_number=self.device.serial,
via_device=(DOMAIN, gateway.serial),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, gateway.serial),
config_entry_id=coordinator.config_entry.entry_id,
),
)
else:
self._attr_device_info = DeviceInfo(
+6 -1
View File
@@ -2,6 +2,7 @@
from typing import Any, override
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -50,7 +51,11 @@ class JellyfinClientEntity(JellyfinEntity):
model=self.client_name,
name=self.device_name,
sw_version=self.app_version,
via_device=(DOMAIN, coordinator.server_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, coordinator.server_id),
config_entry_id=coordinator.config_entry.entry_id,
),
)
self._attr_name = None
else:
@@ -8,6 +8,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEGREE, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType
@@ -31,6 +32,10 @@ async def async_setup_entry(
"2_ch_power_strip",
)
via_device_id = dr.async_get_device_id_by_identifier(
hass, (DOMAIN, "2_ch_power_strip"), config_entry_id=config_entry.entry_id
)
async_add_entities(
[
DemoSensor(
@@ -42,7 +47,7 @@ async def async_setup_entry(
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
unit_of_measurement=UnitOfPower.WATT,
via_device="2_ch_power_strip",
via_device_id=via_device_id,
),
DemoSensor(
device_unique_id="outlet_2",
@@ -53,7 +58,7 @@ async def async_setup_entry(
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
unit_of_measurement=UnitOfPower.WATT,
via_device="2_ch_power_strip",
via_device_id=via_device_id,
),
DemoSensor(
device_unique_id="statistics_issues",
@@ -135,7 +140,7 @@ class DemoSensor(SensorEntity):
device_class: SensorDeviceClass | None,
state_class: SensorStateClass | None,
unit_of_measurement: str | None,
via_device: str | None = None,
via_device_id: str | None = None,
) -> None:
"""Initialize the sensor."""
self._attr_device_class = device_class
@@ -150,5 +155,5 @@ class DemoSensor(SensorEntity):
identifiers={(DOMAIN, device_unique_id)},
name=device_name,
)
if via_device:
self._attr_device_info["via_device"] = (DOMAIN, via_device)
if via_device_id:
self._attr_device_info["via_device_id"] = via_device_id
@@ -5,6 +5,7 @@ from typing import Any, override
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,6 +28,10 @@ async def async_setup_entry(
"2_ch_power_strip",
)
via_device_id = dr.async_get_device_id_by_identifier(
hass, (DOMAIN, "2_ch_power_strip"), config_entry_id=config_entry.entry_id
)
async_add_entities(
[
DemoSwitch(
@@ -35,7 +40,7 @@ async def async_setup_entry(
entity_name=None,
state=False,
assumed=False,
via_device="2_ch_power_strip",
via_device_id=via_device_id,
),
DemoSwitch(
unique_id="outlet_2",
@@ -43,7 +48,7 @@ async def async_setup_entry(
entity_name=None,
state=True,
assumed=False,
via_device="2_ch_power_strip",
via_device_id=via_device_id,
),
]
)
@@ -65,7 +70,7 @@ class DemoSwitch(SwitchEntity):
assumed: bool,
translation_key: str | None = None,
device_class: SwitchDeviceClass | None = None,
via_device: str | None = None,
via_device_id: str | None = None,
) -> None:
"""Initialize the Demo switch."""
self._attr_assumed_state = assumed
@@ -77,8 +82,8 @@ class DemoSwitch(SwitchEntity):
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
if via_device:
self._attr_device_info["via_device"] = (DOMAIN, via_device)
if via_device_id:
self._attr_device_info["via_device_id"] = via_device_id
self._attr_name = entity_name
@override
+3 -3
View File
@@ -11,10 +11,10 @@
"loggers": ["xknx", "xknxproject"],
"quality_scale": "platinum",
"requirements": [
"xknx==3.17.0",
"xknxproject==3.9.0",
"xknx==3.18.0",
"xknxproject==3.10.0",
"knx-frontend==2026.7.23.145751",
"knx-telegram-store[sqlite,postgres]==0.11.1"
"knx-telegram-store[sqlite,postgres]==0.11.2"
],
"single_config_entry": true
}
+4 -2
View File
@@ -211,7 +211,9 @@ def register_lcn_address_devices(
"""
device_registry = dr.async_get(hass)
host_identifiers = (DOMAIN, config_entry.entry_id)
host_device_id = dr.async_get_device_id_by_identifier(
hass, (DOMAIN, config_entry.entry_id), config_entry_id=config_entry.entry_id
)
for device_config in config_entry.data[CONF_DEVICES]:
address = device_config[CONF_ADDRESS]
@@ -233,7 +235,7 @@ def register_lcn_address_devices(
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers=identifiers,
via_device=host_identifiers,
via_device_id=host_device_id,
manufacturer="Issendorff",
sw_version=sw_version,
name=device_name,
+4 -4
View File
@@ -117,12 +117,12 @@ class LifXConfigFlow(ConfigFlow, domain=DOMAIN):
if not (legacy_entry := async_get_legacy_entry(self.hass)):
return False
device_registry = dr.async_get(self.hass)
existing_device = device_registry.async_get_device(
existing_devices = device_registry.async_get_devices(
identifiers={(DOMAIN, self.unique_id)}
)
return bool(
existing_device is not None
and legacy_entry.entry_id in existing_device.config_entries
return any(
device.config_entry_id == legacy_entry.entry_id
for device in existing_devices
)
async def async_step_discovery_confirm(
+6 -1
View File
@@ -6,6 +6,7 @@ from typing import Any, override
from livisi.const import CAPABILITY_MAP
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -59,7 +60,11 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]):
model=device["type"],
name=device_name,
suggested_area=room_name,
via_device=(DOMAIN, config_entry.entry_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, config_entry.entry_id),
config_entry_id=config_entry.entry_id,
),
)
super().__init__(coordinator)
+13 -4
View File
@@ -15,6 +15,7 @@ from homeassistant.components.light import (
brightness_supported,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -97,9 +98,13 @@ class LunatoneLight(
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=self._device.name,
via_device=(
DOMAIN,
f"{self._config_entry_unique_id}-line{self._device.data.line}",
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(
DOMAIN,
f"{self._config_entry_unique_id}-line{self._device.data.line}",
),
config_entry_id=self.coordinator.config_entry.entry_id,
),
)
@@ -261,7 +266,11 @@ class LunatoneLineBroadcastLight(
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=f"DALI Line {line}",
via_device=(DOMAIN, config_entry_unique_id),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, config_entry_unique_id),
config_entry_id=self.coordinator.config_entry.entry_id,
),
**extra_info,
)
+6 -1
View File
@@ -18,6 +18,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -135,7 +136,11 @@ class LunatoneSensor(
f"DALI Line {self.sensor.data.dali_sensor_address.line}"
f" - A{self.sensor.data.dali_sensor_address.address}\u00b2"
),
via_device=(DOMAIN, str(self._config_entry_unique_id)),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, str(self._config_entry_unique_id)),
config_entry_id=self.coordinator.config_entry.entry_id,
),
)
self._attr_device_info = device_info
@@ -11,6 +11,7 @@ from pymelcloud.atw_device import Zone
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -38,6 +39,8 @@ type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDeviceUpdateCoordi
class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]):
"""Per-device coordinator for MELCloud data updates."""
config_entry: MelCloudConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -110,7 +113,11 @@ class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]):
manufacturer="Mitsubishi Electric",
model="ATW zone device",
name=f"{self.device.name} {zone.name}",
via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, f"{dev.mac}-{dev.serial}"),
config_entry_id=self.config_entry.entry_id,
),
)
@override
+1 -18
View File
@@ -4,14 +4,8 @@ import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import (
CONF_TRACK_HOME,
DEFAULT_HOME_LATITUDE,
DEFAULT_HOME_LONGITUDE,
DOMAIN,
)
from .const import CONF_TRACK_HOME, DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE
from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry
PLATFORMS = [Platform.WEATHER]
@@ -49,8 +43,6 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
await cleanup_old_device(hass)
return True
@@ -59,12 +51,3 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
async def cleanup_old_device(hass: HomeAssistant) -> None:
"""Cleanup device without proper device identifier."""
device_reg = dr.async_get(hass)
device = device_reg.async_get_device(identifiers={(DOMAIN,)}) # type: ignore[arg-type]
if device:
_LOGGER.debug("Removing improper device %s", device.name)
device_reg.async_remove_device(device.id)
@@ -88,14 +88,15 @@ class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN):
changed IP.
"""
mac = dr.format_mac(discovery_info.macaddress)
device = dr.async_get(self.hass).async_get_device(
devices = dr.async_get(self.hass).async_get_devices(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
device_entry_ids = {device.config_entry_id for device in devices}
entry = next(
(
entry
for entry in self._async_current_entries(include_ignore=False)
if device is not None and entry.entry_id in device.config_entries
if entry.entry_id in device_entry_ids
),
None,
)
+2 -2
View File
@@ -1319,8 +1319,8 @@ def ensure_via_device_exists(
if (
device_info is None
or CONF_VIA_DEVICE not in device_info
or (device_registry := dr.async_get(hass)).async_get_device(
identifiers={device_info["via_device"]}
or (device_registry := dr.async_get(hass)).async_get_device_by_identifier(
device_info["via_device"], config_entry.entry_id
)
):
return
+2 -2
View File
@@ -36,8 +36,8 @@ class MQTTDeviceEntryMigration(RepairsFlow):
"""Handle the confirm step of a fix flow."""
if user_input is not None:
device_registry = dr.async_get(self.hass)
subentry_device = device_registry.async_get_device(
identifiers={(DOMAIN, self.subentry_id)}
subentry_device = device_registry.async_get_device_by_identifier(
(DOMAIN, self.subentry_id), self.entry_id
)
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["nibe==2.22.0"]
"requirements": ["nibe==2.24.0"]
}
+6 -1
View File
@@ -4,6 +4,7 @@ from typing import override
from pynuki.device import NukiDevice
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -38,6 +39,10 @@ class NukiEntity[_NukiDeviceT: NukiDevice](CoordinatorEntity[NukiCoordinator]):
manufacturer="Nuki Home Solutions GmbH",
model=self._nuki_device.device_model_str.capitalize(),
sw_version=self._nuki_device.firmware_version,
via_device=(DOMAIN, self.coordinator.bridge_id),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.bridge_id),
config_entry_id=self.coordinator.config_entry.entry_id,
),
serial_number=parse_id(self._nuki_device.nuki_id),
)
+3 -11
View File
@@ -226,17 +226,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) ->
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
# Devices left in both the config entry and its subentry by Home Assistant Core
# 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry
# migration, so there's nothing to correct here.
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
@@ -418,17 +418,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
if entry.version == 2 and entry.minor_version == 1:
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
device_registry = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
# Devices left in both the config entry and its subentry by Home Assistant Core
# 2025.7.0b0-2025.7.0b1 are collapsed onto the subentry by the device registry
# migration, so there's nothing to correct here.
hass.config_entries.async_update_entry(entry, minor_version=2)
if entry.version == 2 and entry.minor_version == 2:
+6 -1
View File
@@ -17,6 +17,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -99,7 +100,11 @@ class OpenRGBLight(CoordinatorEntity[OpenRGBCoordinator], LightEntity):
model=f"{self.device.metadata.description} ({self.device.type.name})",
sw_version=self.device.metadata.version,
serial_number=self.device.metadata.serial,
via_device=(DOMAIN, coordinator.entry_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, coordinator.entry_id),
config_entry_id=coordinator.entry_id,
),
)
modes = [mode.name for mode in self.device.modes]
@@ -7,4 +7,7 @@ CONF_ALLOW_NOTIFY = "allow_notify"
CONST_APP_ID = "homeassistant.io"
CONST_APP_NAME = "Home Assistant"
TV_STATE_OFF = "Off"
TV_STATE_ON = "On"
TRIGGER_TYPE_TURN_ON = "turn_on"
@@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.trigger import PluggableAction
from . import LOGGER as _LOGGER
from .const import TV_STATE_OFF
from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator
from .entity import PhilipsJsEntity
from .helpers import async_get_turn_on_trigger
@@ -458,7 +459,9 @@ class PhilipsTVMediaPlayer(PhilipsJsEntity, MediaPlayerEntity):
@callback
def _update_from_coordinator(self):
if self._tv.on:
if self._tv.powerstate in ("Standby", "StandbyKeep"):
if self._tv.powerstate in ("Standby", "StandbyKeep") or (
self._tv.powerstate is None and self._tv.screenstate == TV_STATE_OFF
):
self._attr_state = MediaPlayerState.OFF
else:
self._attr_state = MediaPlayerState.ON
+9 -11
View File
@@ -6,12 +6,10 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TV_STATE_OFF, TV_STATE_ON
from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator
from .entity import PhilipsJsEntity
HUE_POWER_OFF = "Off"
HUE_POWER_ON = "On"
async def async_setup_entry(
hass: HomeAssistant,
@@ -50,23 +48,23 @@ class PhilipsTVScreenSwitch(PhilipsJsEntity, SwitchEntity):
return False
if not self.coordinator.api.on:
return False
return self.coordinator.api.powerstate == "On"
return self.coordinator.api.powerstate in (TV_STATE_ON, None)
@property
@override
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.coordinator.api.screenstate == "On"
return self.coordinator.api.screenstate == TV_STATE_ON
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.api.setScreenState("On")
await self.coordinator.api.setScreenState(TV_STATE_ON)
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.api.setScreenState("Off")
await self.coordinator.api.setScreenState(TV_STATE_OFF)
class PhilipsTVAmbilightHueSwitch(PhilipsJsEntity, SwitchEntity):
@@ -92,22 +90,22 @@ class PhilipsTVAmbilightHueSwitch(PhilipsJsEntity, SwitchEntity):
return False
if not self.coordinator.api.on:
return False
return self.coordinator.api.powerstate == "On"
return self.coordinator.api.powerstate in (TV_STATE_ON, None)
@property
@override
def is_on(self) -> bool:
"""Return True if entity is on."""
return self.coordinator.api.huelamp_power == HUE_POWER_ON
return self.coordinator.api.huelamp_power == TV_STATE_ON
@override
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.coordinator.api.setHueLampPower(HUE_POWER_ON)
await self.coordinator.api.setHueLampPower(TV_STATE_ON)
self.async_write_ha_state()
@override
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.coordinator.api.setHueLampPower(HUE_POWER_OFF)
await self.coordinator.api.setHueLampPower(TV_STATE_OFF)
self.async_write_ha_state()
+10 -3
View File
@@ -3,7 +3,7 @@
from collections.abc import Callable
from functools import wraps
import logging
from typing import Any, Concatenate, cast, override
from typing import TYPE_CHECKING, Any, Concatenate, cast, override
from plexapi.client import PlexClient
import plexapi.exceptions
@@ -20,7 +20,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -557,6 +557,9 @@ class PlexMediaPlayer(MediaPlayerEntity):
entry_type=DeviceEntryType.SERVICE,
)
config_entry = self.platform.config_entry
if TYPE_CHECKING:
assert config_entry
return DeviceInfo(
identifiers={(DOMAIN, self.machine_identifier)},
manufacturer=self.device_platform or "Plex",
@@ -566,7 +569,11 @@ class PlexMediaPlayer(MediaPlayerEntity):
# name to None
name=cast(str | None, self.name),
sw_version=self.device_version,
via_device=(DOMAIN, self.plex_server.machine_identifier),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, self.plex_server.machine_identifier),
config_entry_id=config_entry.entry_id,
),
)
@override
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.21.7"]
"requirements": ["reolink-aio==0.21.8"]
}
+6 -1
View File
@@ -5,6 +5,7 @@ from typing import cast, override
from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -70,7 +71,11 @@ class RoonEventEntity(EventEntity):
name=cast(str | None, self.name),
manufacturer="RoonLabs",
model=dev_model,
via_device=(DOMAIN, self._entry_id),
via_device_id=dr.async_get_device_id_by_identifier(
self._server.hass,
(DOMAIN, self._entry_id),
config_entry_id=self._entry_id,
),
)
def _roonapi_volume_callback(
@@ -15,6 +15,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -154,7 +155,9 @@ class RoonDevice(MediaPlayerEntity):
name=cast(str | None, self.name),
manufacturer="RoonLabs",
model=dev_model,
via_device=(DOMAIN, self._entry_id),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass, (DOMAIN, self._entry_id), config_entry_id=self._entry_id
),
)
def update_data(self, player_data=None):
@@ -6,6 +6,7 @@ from satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_NAME
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -60,7 +61,11 @@ class SatelIntegraEntity[_CoordinatorT: SatelIntegraBaseCoordinator](
self._attr_device_info = DeviceInfo(
name=subentry.data[CONF_NAME],
identifiers={(DOMAIN, self._attr_unique_id)},
via_device=(DOMAIN, config_entry_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, config_entry_id),
config_entry_id=config_entry_id,
),
)
@property
@@ -17,6 +17,7 @@ from simplipy.websocket import (
)
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -101,7 +102,11 @@ class SimpliSafeEntity(CoordinatorEntity[SimpliSafeDataUpdateCoordinator]):
manufacturer="SimpliSafe",
model=model,
name=device_name,
via_device=(DOMAIN, str(system.system_id)),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, str(system.system_id)),
config_entry_id=self.coordinator.config_entry.entry_id,
),
)
self._attr_unique_id = serial
@@ -162,7 +162,7 @@ async def async_setup_entry(
model_id=model_id,
hw_version=str(player.firmware) if player.firmware is not None else None,
sw_version=sw_version,
via_device=(DOMAIN, coordinator.server_uuid),
via_device_id=server_device.id if server_device else None,
)
_LOGGER.debug("Creating / Updating player device %s", device)
async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)])
@@ -93,10 +93,12 @@ async def coordinator_for_device(
manageable_by_webhook: bool = False,
) -> SwitchBotCoordinator:
"""Instantiate coordinator and adds to list for gathering."""
coordinator = coordinators_by_id.setdefault(
device.device_id,
SwitchBotCoordinator(hass, entry, api, device, manageable_by_webhook),
)
coordinator = coordinators_by_id.get(device.device_id)
if coordinator is None:
coordinator = SwitchBotCoordinator(
hass, entry, api, device, manageable_by_webhook
)
coordinators_by_id[device.device_id] = coordinator
if coordinator.data is None:
await coordinator.async_config_entry_first_refresh()
@@ -26,6 +26,8 @@ type TailwindConfigEntry = ConfigEntry[TailwindDataUpdateCoordinator]
class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]):
"""Class to manage fetching Tailwind data."""
config_entry: TailwindConfigEntry
def __init__(self, hass: HomeAssistant, entry: TailwindConfigEntry) -> None:
"""Initialize the coordinator."""
self.tailwind = Tailwind(
+6 -1
View File
@@ -1,5 +1,6 @@
"""Base entity for the Tailwind integration."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -55,7 +56,11 @@ class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")},
via_device=(DOMAIN, coordinator.data.device_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, coordinator.data.device_id),
config_entry_id=coordinator.config_entry.entry_id,
),
name=f"Door {coordinator.data.doors[door_id].index + 1}",
manufacturer="Tailwind",
model=coordinator.data.product,
+6 -1
View File
@@ -5,6 +5,7 @@ from typing import override
from aiotedee.models import TedeeLock
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -35,7 +36,11 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]):
manufacturer="Tedee",
model=lock.type_name,
model_id=lock.type_name,
via_device=(DOMAIN, coordinator.bridge.serial),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, coordinator.bridge.serial),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@property
@@ -8,6 +8,7 @@ from tesla_fleet_api.tesla.energysite import EnergySite
from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -205,7 +206,11 @@ class TeslaFleetWallConnectorEntity(
identifiers={(DOMAIN, din)},
manufacturer="Tesla",
name="Wall Connector",
via_device=(DOMAIN, str(data.id)),
via_device_id=dr.async_get_device_id_by_identifier(
data.live_coordinator.hass,
(DOMAIN, str(data.id)),
config_entry_id=data.live_coordinator.config_entry.entry_id,
),
serial_number=din.rsplit("-", maxsplit=1)[-1],
model=model,
)
@@ -7,6 +7,7 @@ from tesla_fleet_api.const import Scope
from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import StateType
@@ -224,7 +225,11 @@ class TeslemetryWallConnectorEntity(TeslemetryPollingEntity):
manufacturer="Tesla",
configuration_url="https://teslemetry.com/console",
name="Wall Connector",
via_device=(DOMAIN, str(data.id)),
via_device_id=dr.async_get_device_id_by_identifier(
data.live_coordinator.hass,
(DOMAIN, str(data.id)),
config_entry_id=data.live_coordinator.config_entry.entry_id,
),
serial_number=din.rsplit("-", maxsplit=1)[-1],
model=model,
)
@@ -107,7 +107,11 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
"probe_number": str(probe_number),
},
identifiers={(DOMAIN, f"{self.address}_{probe_number}")},
via_device=(DOMAIN, self.address),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, self.address),
config_entry_id=self.config_entry.entry_id,
),
)
@callback
@@ -4,6 +4,7 @@ from typing import override
from pytouchlinesl import Zone
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -24,7 +25,11 @@ class TouchlineSLZoneEntity(CoordinatorEntity[TouchlineSLModuleCoordinator]):
identifiers={(DOMAIN, f"{coordinator.data.module.id}-{zone_id}")},
name=self.zone.name,
manufacturer="Roth",
via_device=(DOMAIN, coordinator.data.module.id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, coordinator.data.module.id),
config_entry_id=coordinator.config_entry.entry_id,
),
model="zone",
suggested_area=self.zone.name,
)
+6 -1
View File
@@ -10,6 +10,7 @@ from pytradfri.device import Device
from pytradfri.error import RequestError
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -61,7 +62,11 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
model=info.model_number,
name=self._device.name,
sw_version=info.firmware_version,
via_device=(DOMAIN, gateway_id),
via_device_id=dr.async_get_device_id_by_identifier(
device_coordinator.hass,
(DOMAIN, gateway_id),
config_entry_id=device_coordinator.config_entry.entry_id,
),
)
self._attr_unique_id = f"{gateway_id}-{self._device_id}"
+21 -12
View File
@@ -116,22 +116,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool
},
)
identifiers = {(DOMAIN, device.usn)}
identifiers = [(DOMAIN, device.usn)]
if device.host:
identifiers.add((IDENTIFIER_HOST, device.host))
identifiers.append((IDENTIFIER_HOST, device.host))
if device.serial_number:
identifiers.add((IDENTIFIER_SERIAL_NUMBER, device.serial_number))
identifiers.append((IDENTIFIER_SERIAL_NUMBER, device.serial_number))
connections = {(dr.CONNECTION_UPNP, discovery_info.ssdp_udn)}
connections = [(dr.CONNECTION_UPNP, discovery_info.ssdp_udn)]
if discovery_info.ssdp_udn != device.udn:
connections.add((dr.CONNECTION_UPNP, device.udn))
connections.append((dr.CONNECTION_UPNP, device.udn))
if device_mac_address:
connections.add((dr.CONNECTION_NETWORK_MAC, device_mac_address))
connections.append((dr.CONNECTION_NETWORK_MAC, device_mac_address))
dev_registry = dr.async_get(hass)
device_entry = dev_registry.async_get_device(
identifiers=identifiers, connections=connections
)
device_entry = None
for identifier in identifiers:
if device_entry := dev_registry.async_get_device_by_identifier(
identifier, entry.entry_id
):
break
if device_entry is None:
for connection in connections:
if device_entry := dev_registry.async_get_device_by_connection(
connection, entry.entry_id
):
break
if device_entry:
LOGGER.debug(
"Found device using connections: %s, device_entry: %s",
@@ -142,8 +151,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool
# No device found, create new device entry.
device_entry = dev_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections=connections,
identifiers=identifiers,
connections=set(connections),
identifiers=set(identifiers),
name=device.name,
manufacturer=device.manufacturer,
model=device.model_name,
@@ -155,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UpnpConfigEntry) -> bool
# Update identifier.
device_entry = dev_registry.async_update_device(
device_entry.id,
new_identifiers=identifiers,
new_identifiers=set(identifiers),
)
assert device_entry
@@ -18,6 +18,7 @@ HAS_PORT = {
MonitorType.RADIUS,
MonitorType.SNMP,
MonitorType.SMTP,
MonitorType.NTP,
}
HAS_HOST = HAS_PORT | {
MonitorType.PING,
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pythonkuma"],
"quality_scale": "platinum",
"requirements": ["pythonkuma==0.5.1"]
"requirements": ["pythonkuma==0.5.2"]
}
@@ -118,8 +118,10 @@
"mongodb": "MongoDB",
"mqtt": "MQTT",
"mysql": "MySQL/MariaDB",
"ntp": "NTP",
"oracledb": "Oracle Database",
"ping": "Ping",
"pm2": "PM2 Process",
"port": "TCP port",
"postgres": "PostgreSQL",
"push": "Push",
+8 -8
View File
@@ -922,7 +922,7 @@ emoji==2.8.0
emulated-roku==0.3.0
# homeassistant.components.energieleser
energieleser==0.1.5
energieleser==0.1.6
# homeassistant.components.huisbaasje
energyflip-client==0.2.2
@@ -961,7 +961,7 @@ eq3btsmart==2.3.0
esios_api==4.4.0
# homeassistant.components.esphome
esphome-dashboard-api==1.3.0
esphome-dashboard-api==1.4.0
# homeassistant.components.essent
essent-dynamic-pricing==0.3.1
@@ -1438,7 +1438,7 @@ knocki==0.4.2
knx-frontend==2026.7.23.145751
# homeassistant.components.knx
knx-telegram-store[sqlite,postgres]==0.11.1
knx-telegram-store[sqlite,postgres]==0.11.2
# homeassistant.components.kraken
krakenex==2.2.2
@@ -1688,7 +1688,7 @@ nextdns==5.0.1
nhc==0.8.0
# homeassistant.components.nibe_heatpump
nibe==2.22.0
nibe==2.24.0
# homeassistant.components.nice_go
nice-go==1.0.2
@@ -2786,7 +2786,7 @@ python-xbox==0.2.0
pythonegardia==1.0.52
# homeassistant.components.uptime_kuma
pythonkuma==0.5.1
pythonkuma==0.5.2
# homeassistant.components.tile
pytile==2024.12.0
@@ -2916,7 +2916,7 @@ renault-api==0.5.12
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.21.7
reolink-aio==0.21.8
# homeassistant.components.radio_frequency
rf-protocols==4.3.0
@@ -3406,10 +3406,10 @@ wyoming==1.10.0
xiaomi-ble==1.11.0
# homeassistant.components.knx
xknx==3.17.0
xknx==3.18.0
# homeassistant.components.knx
xknxproject==3.9.0
xknxproject==3.10.0
# homeassistant.components.fritz
# homeassistant.components.rest
@@ -10,6 +10,8 @@ from homeassistant.components.coolmaster.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
def _flow_data(send_wakeup_prompt: bool = False) -> dict:
options: dict = {"host": "1.1.1.1"}
@@ -109,3 +111,26 @@ async def test_form_no_units(hass: HomeAssistant) -> None:
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "no_units"}
async def test_form_duplicate_host(hass: HomeAssistant) -> None:
"""Test we abort when a bridge on this host is already configured."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
"host": "1.1.1.1",
"port": 10102,
"supported_modes": AVAILABLE_MODES,
},
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], _flow_data()
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
+250 -6
View File
@@ -1,7 +1,10 @@
"""Philips Hue binary_sensor platform tests for V2 bridge/api."""
from typing import Any
from unittest.mock import Mock
import pytest
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.util.json import JsonArrayType
@@ -9,6 +12,60 @@ from homeassistant.util.json import JsonArrayType
from .conftest import setup_platform
from .const import FAKE_BINARY_SENSOR, FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY
MOTION_AWARE_ENTITY_ID = "binary_sensor.test_room_test_room_motion_aware_sensor_1"
MOTION_AREA_CONFIGURATION_ID = "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b"
AREA_MOTION_SERVICE_IDS = {
"convenience_area_motion": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345",
"security_area_motion": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f",
}
MOTION_DETECTED = {
"motion": True,
"motion_valid": True,
"motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True},
}
MOTION_CLEARED = {
"motion": False,
"motion_valid": True,
"motion_report": {"changed": "2023-09-23T08:13:42.394Z", "motion": False},
}
MOTION_INVALID = {
"motion": False,
"motion_valid": False,
"motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": False},
}
def area_motion_service(
service_type: str,
*,
enabled: bool = True,
motion: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a service of the MotionAware zone, without `motion` when none is given."""
service = {
"id": AREA_MOTION_SERVICE_IDS[service_type],
"owner": {
"rid": MOTION_AREA_CONFIGURATION_ID,
"rtype": "motion_area_configuration",
},
"enabled": enabled,
"type": service_type,
}
if motion is not None:
service["motion"] = motion
return service
def replace_resources(
data: JsonArrayType, resources: list[dict[str, Any]]
) -> JsonArrayType:
"""Return the test data with each resource of the same id replaced."""
replacements = {resource["id"]: resource for resource in resources}
missing = replacements.keys() - {resource["id"] for resource in data}
assert not missing, f"resource id(s) not present in the test data: {missing}"
return [replacements.get(resource["id"], resource) for resource in data]
async def test_binary_sensors(
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
@@ -88,7 +145,7 @@ async def test_binary_sensors(
assert sensor.attributes["device_class"] == "motion"
# test motion aware sensor
sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1")
sensor = hass.states.get(MOTION_AWARE_ENTITY_ID)
assert sensor is not None
assert sensor.state == "off"
assert sensor.name == "Test Room Motion Aware Sensor 1"
@@ -195,15 +252,17 @@ async def test_motion_aware_sensor(
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
# test motion aware sensor exists and has correct state
sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1")
sensor = hass.states.get(MOTION_AWARE_ENTITY_ID)
assert sensor is not None
assert sensor.state == "off"
assert sensor.attributes["device_class"] == "motion"
# test update of motion aware sensor works on incoming event
# the zone in the test data has its convenience service enabled, so that is the
# service reporting its motion
updated_sensor = {
"id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f",
"type": "security_area_motion",
"id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345",
"type": "convenience_area_motion",
"motion": {
"motion": True,
"motion_valid": True,
@@ -212,7 +271,7 @@ async def test_motion_aware_sensor(
}
mock_bridge_v2.api.emit_event("update", updated_sensor)
await hass.async_block_till_done()
sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1")
sensor = hass.states.get(MOTION_AWARE_ENTITY_ID)
assert sensor.state == "on"
# test name update when motion area configuration name changes
@@ -225,6 +284,191 @@ async def test_motion_aware_sensor(
await hass.async_block_till_done()
# The entity name is derived from the motion area configuration name
# but the entity ID doesn't change - we just verify the sensor still exists
sensor = hass.states.get("binary_sensor.test_room_test_room_motion_aware_sensor_1")
sensor = hass.states.get(MOTION_AWARE_ENTITY_ID)
assert sensor is not None
assert sensor.name == "Test Room Updated Motion Area"
@pytest.mark.parametrize(
("services", "expected_state"),
[
pytest.param(
[
area_motion_service("security_area_motion", motion=MOTION_CLEARED),
area_motion_service("convenience_area_motion", motion=MOTION_DETECTED),
],
"on",
id="bound_to_lights_reads_convenience",
),
pytest.param(
[
area_motion_service("security_area_motion", motion=MOTION_CLEARED),
area_motion_service(
"convenience_area_motion", enabled=False, motion=MOTION_DETECTED
),
],
"off",
id="not_bound_to_lights_reads_security",
),
pytest.param(
[
area_motion_service("security_area_motion"),
area_motion_service("convenience_area_motion", motion=MOTION_DETECTED),
],
"on",
id="hue_secure_security_without_motion_reads_convenience",
),
pytest.param(
[
area_motion_service("security_area_motion", motion=MOTION_INVALID),
area_motion_service(
"convenience_area_motion", enabled=False, motion=MOTION_DETECTED
),
],
"unknown",
id="not_bound_to_lights_without_valid_reading",
),
# a real zone can have its convenience service enabled while only the security
# service reports, so an enabled service without a reading must not win
pytest.param(
[
area_motion_service("security_area_motion", motion=MOTION_DETECTED),
area_motion_service("convenience_area_motion", motion=MOTION_INVALID),
],
"on",
id="falls_back_to_security_when_convenience_has_no_reading",
),
],
)
async def test_motion_aware_sensor_motion_source(
hass: HomeAssistant,
mock_bridge_v2: Mock,
v2_resources_test_data: JsonArrayType,
services: list[dict[str, Any]],
expected_state: str,
) -> None:
"""Test the MotionAware sensor reads the zone service that reports motion."""
# every case gives the service that must be ignored the opposite state, so
# reading the wrong one results in a state other than the asserted one
await mock_bridge_v2.api.load_test_data(
replace_resources(v2_resources_test_data, services)
)
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == expected_state
async def test_motion_aware_sensor_follows_convenience_service(
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
) -> None:
"""Test the MotionAware sensor updates on events of the convenience service."""
await mock_bridge_v2.api.load_test_data(
replace_resources(
v2_resources_test_data,
[
area_motion_service("security_area_motion"),
area_motion_service("convenience_area_motion", motion=MOTION_CLEARED),
],
)
)
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off"
mock_bridge_v2.api.emit_event(
"update",
area_motion_service("convenience_area_motion", motion=MOTION_DETECTED),
)
await hass.async_block_till_done()
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "on"
async def test_motion_aware_sensor_follows_security_service(
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
) -> None:
"""Test the MotionAware sensor updates on events of the security service."""
await mock_bridge_v2.api.load_test_data(
replace_resources(
v2_resources_test_data,
[
area_motion_service("security_area_motion", motion=MOTION_CLEARED),
area_motion_service(
"convenience_area_motion", enabled=False, motion=MOTION_CLEARED
),
],
)
)
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off"
mock_bridge_v2.api.emit_event(
"update",
area_motion_service("security_area_motion", motion=MOTION_DETECTED),
)
await hass.async_block_till_done()
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "on"
async def test_motion_aware_sensor_without_convenience_resource(
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
) -> None:
"""Test the MotionAware sensor works when the convenience service is missing."""
# the zone still lists the service, but the bridge never delivered the resource
data = replace_resources(
v2_resources_test_data,
[area_motion_service("security_area_motion", motion=MOTION_DETECTED)],
)
convenience_id = AREA_MOTION_SERVICE_IDS["convenience_area_motion"]
await mock_bridge_v2.api.load_test_data(
[resource for resource in data if resource["id"] != convenience_id]
)
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "on"
@pytest.mark.parametrize(
("zone_update", "zone_restore"),
[
pytest.param({"enabled": False}, {"enabled": True}, id="zone_switched_off"),
pytest.param(
{"health": "not_running"}, {"health": "healthy"}, id="zone_not_running"
),
],
)
async def test_motion_aware_sensor_zone_not_reporting(
hass: HomeAssistant,
mock_bridge_v2: Mock,
v2_resources_test_data: JsonArrayType,
zone_update: dict[str, Any],
zone_restore: dict[str, Any],
) -> None:
"""Test the MotionAware sensor reports unknown while its zone is not reporting."""
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off"
# the services keep reporting a valid state while the zone itself does not
mock_bridge_v2.api.emit_event(
"update",
{
"id": MOTION_AREA_CONFIGURATION_ID,
"type": "motion_area_configuration",
**zone_update,
},
)
await hass.async_block_till_done()
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "unknown"
mock_bridge_v2.api.emit_event(
"update",
{
"id": MOTION_AREA_CONFIGURATION_ID,
"type": "motion_area_configuration",
**zone_restore,
},
)
await hass.async_block_till_done()
assert hass.states.get(MOTION_AWARE_ENTITY_ID).state == "off"
-28
View File
@@ -10,7 +10,6 @@ from homeassistant.components.met.const import (
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.core_config import async_process_ha_core_config
from homeassistant.helpers import device_registry as dr
from . import init_integration
@@ -50,30 +49,3 @@ async def test_fail_default_home_entry(
"Skip setting up met.no integration; No Home location has been set"
in caplog.text
)
async def test_removing_incorrect_devices(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
caplog: pytest.LogCaptureFixture,
mock_weather,
) -> None:
"""Test we remove incorrect devices."""
entry = await init_integration(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
name="Forecast_legacy",
entry_type=dr.DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)},
manufacturer="Met.no",
model="Forecast",
configuration_url="https://www.met.no/en",
)
assert await hass.config_entries.async_reload(entry.entry_id)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert not device_registry.async_get_device(identifiers={(DOMAIN,)})
assert device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)})
assert "Removing improper device Forecast_legacy" in caplog.text
+1 -1
View File
@@ -1,7 +1,7 @@
"""Test Met weather entity."""
from homeassistant import config_entries
from homeassistant.components.met import DOMAIN
from homeassistant.components.met.const import DOMAIN
from homeassistant.components.weather import (
ATTR_CONDITION_CLOUDY,
ATTR_WEATHER_DEW_POINT,
+3 -4
View File
@@ -53,7 +53,7 @@ from .common import (
help_test_update_with_json_attrs_not_dict,
)
from tests.common import MockConfigEntry, async_fire_mqtt_message
from tests.common import async_fire_mqtt_message
from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient
DEFAULT_CONFIG = {
@@ -547,10 +547,9 @@ async def test_entity_device_info_with_hub(
) -> None:
"""Test MQTT event device registry integration."""
await mqtt_mock_entry()
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0]
hub = device_registry.async_get_or_create(
config_entry_id=other_config_entry.entry_id,
config_entry_id=mqtt_config_entry.entry_id,
connections=set(),
identifiers={("mqtt", "hub-id")},
manufacturer="manufacturer",
+2 -4
View File
@@ -76,7 +76,6 @@ from .common import (
)
from tests.common import (
MockConfigEntry,
async_capture_events,
async_fire_mqtt_message,
async_fire_time_changed,
@@ -1585,10 +1584,9 @@ async def test_entity_device_info_with_hub(
) -> None:
"""Test MQTT sensor device registry integration."""
await mqtt_mock_entry()
other_config_entry = MockConfigEntry()
other_config_entry.add_to_hass(hass)
mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0]
hub = device_registry.async_get_or_create(
config_entry_id=other_config_entry.entry_id,
config_entry_id=mqtt_config_entry.entry_id,
connections=set(),
identifiers={("mqtt", "hub-id")},
manufacturer="manufacturer",
+1
View File
@@ -45,6 +45,7 @@ def mock_tv():
tv.notify_change_supported = False
tv.pairing_type = None
tv.powerstate = None
tv.screenstate = None
tv.source_id = None
tv.ambilight_current_configuration = None
tv.ambilight_styles = {}
@@ -0,0 +1,41 @@
"""Tests for the Philips TV media player."""
from haphilipsjs import PhilipsTV
import pytest
from homeassistant.components.philips_js.const import TV_STATE_OFF, TV_STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from . import MOCK_ENTITY_ID
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("powerstate", "screenstate", "expected_state"),
[
pytest.param(TV_STATE_ON, TV_STATE_OFF, STATE_ON, id="powerstate-on"),
pytest.param("Standby", TV_STATE_ON, STATE_OFF, id="powerstate-standby"),
pytest.param(None, TV_STATE_ON, STATE_ON, id="screenstate-on"),
pytest.param(None, TV_STATE_OFF, STATE_OFF, id="screenstate-off"),
],
)
async def test_state(
hass: HomeAssistant,
mock_tv: PhilipsTV,
mock_config_entry: MockConfigEntry,
powerstate: str | None,
screenstate: str,
expected_state: str,
) -> None:
"""Test the media player state."""
mock_tv.json_feature_supported.return_value = False
mock_tv.powerstate = powerstate
mock_tv.screenstate = screenstate
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (state := hass.states.get(MOCK_ENTITY_ID))
assert state.state == expected_state
@@ -0,0 +1,48 @@
"""Tests for the Philips TV switches."""
from haphilipsjs import PhilipsTV
import pytest
from homeassistant.components.philips_js.const import TV_STATE_OFF
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("entity_id", "screenstate", "huelamp_power"),
[
pytest.param(
"switch.philips_tv_screen_state",
TV_STATE_OFF,
None,
id="screen",
),
pytest.param(
"switch.philips_tv_ambilight_hue",
None,
TV_STATE_OFF,
id="ambilight-hue",
),
],
)
async def test_available_without_powerstate(
hass: HomeAssistant,
mock_tv: PhilipsTV,
mock_config_entry: MockConfigEntry,
entity_id: str,
screenstate: str | None,
huelamp_power: str | None,
) -> None:
"""Test switches are available when the power state endpoint is absent."""
mock_tv.json_feature_supported.return_value = True
mock_tv.powerstate = None
mock_tv.screenstate = screenstate
mock_tv.huelamp_power = huelamp_power
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OFF
+27 -1
View File
@@ -1,6 +1,6 @@
"""Tests for the SwitchBot Cloud integration init."""
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -20,6 +20,7 @@ from homeassistant.components.switchbot_cloud.const import (
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
from homeassistant.components.switchbot_cloud.coordinator import SwitchBotCoordinator
from homeassistant.components.webhook import DOMAIN as WEBHOOK_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
@@ -744,3 +745,28 @@ async def test_remove_entry_with_cloud_unavailable(
await hass.async_block_till_done()
assert not hass.config_entries.async_entries("switchbot_cloud")
async def test_single_coordinator_for_multi_platform_device(
hass: HomeAssistant, mock_list_devices: AsyncMock, mock_get_status: AsyncMock
) -> None:
"""Test that a multi-platform device creates only one coordinator."""
mock_list_devices.return_value = [
Device(
version="V1.0",
deviceId="relay-switch-pm-id-1",
deviceName="relay-switch-pm-1",
deviceType="Relay Switch 1PM",
hubDeviceId="test-hub-id",
),
]
mock_get_status.return_value = {"switchStatus": 0}
with patch(
"homeassistant.components.switchbot_cloud.SwitchBotCoordinator",
wraps=SwitchBotCoordinator,
) as coordinator_cls:
entry = await configure_integration(hass)
assert entry.state is ConfigEntryState.LOADED
assert coordinator_cls.call_count == 1
@@ -69,6 +69,7 @@
'dns',
'docker',
'system_service',
'pm2',
'real_browser',
'group',
'push',
@@ -78,6 +79,7 @@
'json_query',
'kafka_producer',
'mqtt',
'ntp',
'rabbit_mq',
'sip_options',
'smtp',
@@ -139,6 +141,7 @@
'dns',
'docker',
'system_service',
'pm2',
'real_browser',
'group',
'push',
@@ -148,6 +151,7 @@
'json_query',
'kafka_producer',
'mqtt',
'ntp',
'rabbit_mq',
'sip_options',
'smtp',
@@ -770,6 +774,7 @@
'dns',
'docker',
'system_service',
'pm2',
'real_browser',
'group',
'push',
@@ -779,6 +784,7 @@
'json_query',
'kafka_producer',
'mqtt',
'ntp',
'rabbit_mq',
'sip_options',
'smtp',
@@ -840,6 +846,7 @@
'dns',
'docker',
'system_service',
'pm2',
'real_browser',
'group',
'push',
@@ -849,6 +856,7 @@
'json_query',
'kafka_producer',
'mqtt',
'ntp',
'rabbit_mq',
'sip_options',
'smtp',
@@ -1576,6 +1584,7 @@
'dns',
'docker',
'system_service',
'pm2',
'real_browser',
'group',
'push',
@@ -1585,6 +1594,7 @@
'json_query',
'kafka_producer',
'mqtt',
'ntp',
'rabbit_mq',
'sip_options',
'smtp',
@@ -1646,6 +1656,7 @@
'dns',
'docker',
'system_service',
'pm2',
'real_browser',
'group',
'push',
@@ -1655,6 +1666,7 @@
'json_query',
'kafka_producer',
'mqtt',
'ntp',
'rabbit_mq',
'sip_options',
'smtp',