Merge branch 'dev' into button_event_triggers

This commit is contained in:
Abílio Costa
2026-08-02 22:50:01 +01:00
committed by GitHub
297 changed files with 8797 additions and 1528 deletions
+3 -3
View File
@@ -342,13 +342,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -521,7 +521,7 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+2 -2
View File
@@ -736,7 +736,7 @@ jobs:
apt-cache-version: ${{ env.APT_CACHE_VERSION }}
- name: Restore pytest test counts cache
id: cache-pytest-counts
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: pytest_test_counts.json
# Primary key is a sentinel; restore-keys pick the most recent
@@ -769,7 +769,7 @@ jobs:
steps.cache-pytest-counts.outputs.cache-matched-key,
steps.cache-pytest-counts-hash.outputs.hash
)
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: pytest_test_counts.json
key: >-
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: python
@@ -55,5 +55,4 @@ class AcmedaEntity(entity.Entity):
identifiers={(DOMAIN, self.unique_id)},
manufacturer="Rollease Acmeda",
name=self.roller.name,
via_device=(DOMAIN, self.roller.hub.id),
)
@@ -6,6 +6,7 @@ from actron_neo_api.models.system import ActronAirSystemInfo
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, LOGGER
from .coordinator import (
@@ -37,6 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
translation_key="setup_connection_error",
) from err
device_registry = dr.async_get(hass)
system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems:
coordinator = ActronAirSystemCoordinator(hass, entry, api, system)
@@ -44,6 +46,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
await coordinator.async_config_entry_first_refresh()
system_coordinators[system.serial] = coordinator
# Register the AC system device so zone entities can link to it as their
# via device when they are set up.
ac_system = coordinator.data.ac_system
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, system.serial)},
name=ac_system.system_name,
manufacturer="Actron Air",
model_id=ac_system.master_wc_model,
sw_version=ac_system.master_wc_firmware_version,
serial_number=system.serial,
)
entry.runtime_data = ActronAirRuntimeData(
api=api,
system_coordinators=system_coordinators,
@@ -40,6 +40,8 @@ type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]):
"""System coordinator for Actron Air integration."""
config_entry: ActronAirConfigEntry
def __init__(
self,
hass: HomeAssistant,
@@ -7,6 +7,7 @@ from typing import Any, Concatenate, override
from actron_neo_api import ActronAirAPIError, ActronAirZone
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -90,5 +91,9 @@ class ActronAirZoneEntity(ActronAirEntity):
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, self._serial_number),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, self._serial_number),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@@ -4,7 +4,7 @@ from advantage_air import advantage_air
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
@@ -50,6 +50,18 @@ async def async_setup_entry(
entry.runtime_data = coordinator
# Register the system device so child devices can resolve it as their
# via_device parent regardless of platform setup order.
system = coordinator.data["system"]
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, system["rid"])},
manufacturer="Advantage Air",
model=system["sysType"],
name=system["name"],
sw_version=system["myAppRev"],
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -5,6 +5,7 @@ from typing import Any
from advantage_air import ApiError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -49,7 +50,11 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
self._attr_unique_id += f"-{ac_key}"
self._attr_device_info = DeviceInfo(
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.data["system"]["rid"]),
config_entry_id=self.coordinator.config_entry.entry_id,
),
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Advantage Air",
model=self.coordinator.data["system"]["sysType"],
@@ -105,7 +110,11 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo(
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.data["system"]["rid"]),
config_entry_id=self.coordinator.config_entry.entry_id,
),
identifiers={(DOMAIN, self._attr_unique_id)},
manufacturer="Advantage Air",
model="MyPlace",
@@ -4,6 +4,7 @@ from typing import Any, override
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
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
@@ -55,7 +56,11 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
self._attr_unique_id += f"-{self._id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
via_device=(DOMAIN, self.coordinator.data["system"]["rid"]),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, self.coordinator.data["system"]["rid"]),
config_entry_id=self.coordinator.config_entry.entry_id,
),
manufacturer="Advantage Air",
model=light.get("moduleType"),
name=light["name"],
+55 -12
View File
@@ -6,8 +6,12 @@ from typing import Any
from aioairzone.const import (
AZD_FIRMWARE,
AZD_FULL_NAME,
AZD_HOT_WATER,
AZD_ID,
AZD_MAC,
AZD_MODEL,
AZD_NAME,
AZD_SYSTEMS,
AZD_WEBSERVER,
DEFAULT_SYSTEM_ID,
)
@@ -93,19 +97,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
device_registry = dr.async_get(hass)
ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER)
if ws_data is not None:
mac = ws_data.get(AZD_MAC, "")
@callback
def _async_register_devices() -> None:
"""Register the WebServer, System, and DHW via_device parents.
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, f"{entry.entry_id}_ws")},
manufacturer=MANUFACTURER,
model=ws_data.get(AZD_MODEL),
name=ws_data.get(AZD_FULL_NAME),
sw_version=ws_data.get(AZD_FIRMWARE),
)
The WebServer, Systems, and DHW can appear on later coordinator
updates, so this runs on every update (before the platform
listeners) to keep the via_device parents registered before their
child entities are added.
"""
ws_device_id: str | None = None
ws_data: dict[str, Any] | None = coordinator.data.get(AZD_WEBSERVER)
if ws_data is not None:
mac = ws_data.get(AZD_MAC, "")
ws_device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, mac)},
identifiers={(DOMAIN, f"{entry.entry_id}_ws")},
manufacturer=MANUFACTURER,
model=ws_data.get(AZD_MODEL),
name=ws_data.get(AZD_FULL_NAME),
sw_version=ws_data.get(AZD_FIRMWARE),
)
ws_device_id = ws_device.id
for system_data in coordinator.data.get(AZD_SYSTEMS, {}).values():
system_id = system_data[AZD_ID]
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{entry.entry_id}_{system_id}")},
manufacturer=MANUFACTURER,
model=system_data.get(AZD_MODEL),
name=f"System {system_id}",
sw_version=system_data.get(AZD_FIRMWARE),
via_device_id=ws_device_id,
)
dhw_data: dict[str, Any] | None = coordinator.data.get(AZD_HOT_WATER)
if dhw_data is not None:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{entry.entry_id}_dhw")},
manufacturer=MANUFACTURER,
model="DHW",
name=dhw_data.get(AZD_NAME),
via_device_id=ws_device_id,
)
# Register the parents before forwarding platforms, and keep them registered
# as new devices appear so child entities can resolve their via_device_id.
_async_register_devices()
entry.async_on_unload(coordinator.async_add_listener(_async_register_devices))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+19 -3
View File
@@ -67,7 +67,13 @@ class AirzoneSystemEntity(AirzoneEntity):
sw_version=self.get_airzone_value(AZD_FIRMWARE),
)
if AZD_WEBSERVER in self.coordinator.data:
self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_device_info["via_device_id"] = (
dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, f"{entry.entry_id}_ws"),
config_entry_id=entry.entry_id,
)
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@property
@@ -120,7 +126,13 @@ class AirzoneHotWaterEntity(AirzoneEntity):
name=self.get_airzone_value(AZD_NAME),
)
if AZD_WEBSERVER in self.coordinator.data:
self._attr_device_info["via_device"] = (DOMAIN, f"{entry.entry_id}_ws")
self._attr_device_info["via_device_id"] = (
dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, f"{entry.entry_id}_ws"),
config_entry_id=entry.entry_id,
)
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@override
@@ -195,7 +207,11 @@ class AirzoneZoneEntity(AirzoneEntity):
model=self.get_airzone_value(AZD_THERMOSTAT_MODEL),
name=zone_data[AZD_NAME],
sw_version=self.get_airzone_value(AZD_THERMOSTAT_FW),
via_device=(DOMAIN, f"{entry.entry_id}_{self.system_id}"),
via_device_id=dr.async_get_device_id_by_identifier(
self.coordinator.hass,
(DOMAIN, f"{entry.entry_id}_{self.system_id}"),
config_entry_id=entry.entry_id,
),
)
self._attr_unique_id = entry.unique_id or entry.entry_id
@@ -2,11 +2,20 @@
from aioairzone_cloud.cloudapi import AirzoneCloudApi
from aioairzone_cloud.common import ConnectionOptions
from aioairzone_cloud.const import (
AZD_FIRMWARE,
AZD_MODEL,
AZD_NAME,
AZD_SYSTEMS,
AZD_WEBSERVER,
AZD_WEBSERVERS,
)
from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client, device_registry as dr
from .const import DOMAIN, MANUFACTURER
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
PLATFORMS: list[Platform] = [
@@ -42,11 +51,53 @@ async def async_setup_entry(
entry.runtime_data = coordinator
_async_register_devices(hass, entry, coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@callback
def _async_register_devices(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
coordinator: AirzoneUpdateCoordinator,
) -> None:
"""Register WebServer and System devices referenced as via_device parents.
Child devices resolve their via_device_id at add time, so the parents must
already exist regardless of which platform creates their own entities.
"""
device_registry = dr.async_get(hass)
for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, ws_id)},
identifiers={(DOMAIN, ws_id)},
manufacturer=MANUFACTURER,
model="WebServer",
name=ws_data[AZD_NAME],
sw_version=ws_data[AZD_FIRMWARE],
)
for system_id, system_data in coordinator.data.get(AZD_SYSTEMS, {}).items():
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, system_id)},
manufacturer=MANUFACTURER,
model=system_data.get(AZD_MODEL),
name=system_data[AZD_NAME],
sw_version=system_data.get(AZD_FIRMWARE),
via_device_id=dr.async_get_device_id_by_identifier(
hass,
(DOMAIN, system_data[AZD_WEBSERVER]),
config_entry_id=entry.entry_id,
),
)
async def async_unload_entry(
hass: HomeAssistant, entry: AirzoneCloudConfigEntry
) -> bool:
@@ -73,7 +73,11 @@ class AirzoneAidooEntity(AirzoneEntity):
manufacturer=MANUFACTURER,
model=aidoo_data[AZD_MODEL],
name=aidoo_data[AZD_NAME],
via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, aidoo_data[AZD_WEBSERVER]),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@override
@@ -164,7 +168,11 @@ class AirzoneHotWaterEntity(AirzoneEntity):
manufacturer=MANUFACTURER,
model="Hot Water",
name=dhw_data[AZD_NAME],
via_device=(DOMAIN, dhw_data[AZD_WEBSERVER]),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, dhw_data[AZD_WEBSERVER]),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@override
@@ -257,7 +265,11 @@ class AirzoneSystemEntity(AirzoneEntity):
model=system_data.get(AZD_MODEL),
manufacturer=MANUFACTURER,
name=system_data[AZD_NAME],
via_device=(DOMAIN, system_data[AZD_WEBSERVER]),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, system_data[AZD_WEBSERVER]),
config_entry_id=coordinator.config_entry.entry_id,
),
sw_version=system_data.get(AZD_FIRMWARE),
)
@@ -322,7 +334,11 @@ class AirzoneZoneEntity(AirzoneEntity):
model=zone_data.get(AZD_THERMOSTAT_MODEL),
manufacturer=MANUFACTURER,
name=zone_data[AZD_NAME],
via_device=(DOMAIN, self.system_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, self.system_id),
config_entry_id=coordinator.config_entry.entry_id,
),
sw_version=zone_data.get(AZD_THERMOSTAT_FW),
)
+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:
@@ -1,8 +1,8 @@
"""Config flow for Aquacell integration."""
from collections.abc import Mapping
from datetime import datetime
import logging
import time
from typing import Any, override
from aioaquacell import ApiException, AquacellApi, AuthenticationFailed
@@ -76,7 +76,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
CONF_BRAND: user_input[CONF_BRAND],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
CONF_REFRESH_TOKEN_CREATION_TIME: time.time(),
},
)
@@ -120,7 +120,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
CONF_REFRESH_TOKEN_CREATION_TIME: time.time(),
},
)
@@ -1,8 +1,8 @@
"""Coordinator to update data from Aquacell API."""
import asyncio
from datetime import datetime
import logging
import time
from typing import override
from aioaquacell import (
@@ -73,7 +73,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
+ REFRESH_TOKEN_EXPIRY_TIME.total_seconds()
)
try:
if datetime.now().timestamp() >= expiry_time: # pylint: disable=home-assistant-enforce-naive-now
if time.time() >= expiry_time:
await self._reauthenticate()
else:
await self.aquacell_api.authenticate_refresh(self.refresh_token)
@@ -94,7 +94,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
data = {
**self.config_entry.data,
CONF_REFRESH_TOKEN: self.refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), # pylint: disable=home-assistant-enforce-naive-now
CONF_REFRESH_TOKEN_CREATION_TIME: time.time(),
}
self.hass.config_entries.async_update_entry(self.config_entry, data=data)
@@ -21,7 +21,7 @@ from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PARALLEL_UPDATES = 0
NO_SENSOR = "no_sensor"
ON_STATES = ["on", "yes", "charging", "alert", "exceeded"]
ON_STATES = ["on", "yes", "charging", "alert", "exceeded", "open"]
DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = {
CompitParameter.AIRING: BinarySensorEntityDescription(
@@ -53,6 +53,18 @@ DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = {
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.GWC: BinarySensorEntityDescription(
key=CompitParameter.GWC.value,
translation_key="ground_heat_exchanger_attached",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.MIXER_PUMP_STATUS: BinarySensorEntityDescription(
key=CompitParameter.MIXER_PUMP_STATUS.value,
translation_key="mixer_pump_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),
CompitParameter.PUMP_STATUS: BinarySensorEntityDescription(
key=CompitParameter.PUMP_STATUS.value,
translation_key="pump_status",
@@ -77,10 +89,20 @@ class CompitDeviceDescription:
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
3: CompitDeviceDescription(
name="R810",
parameters={
CompitParameter.MIXER_PUMP_STATUS: DESCRIPTIONS[
CompitParameter.MIXER_PUMP_STATUS
],
},
),
12: CompitDeviceDescription(
name="Nano Color",
parameters={
CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING],
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
CompitParameter.GWC: DESCRIPTIONS[CompitParameter.GWC],
},
),
78: CompitDeviceDescription(
@@ -98,6 +120,7 @@ DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
parameters={
CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING],
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
CompitParameter.GWC: DESCRIPTIONS[CompitParameter.GWC],
},
),
225: CompitDeviceDescription(
@@ -13,6 +13,12 @@
"dust_alert": {
"default": "mdi:alert"
},
"ground_heat_exchanger_attached": {
"default": "mdi:heat-pump"
},
"mixer_pump_status": {
"default": "mdi:pump"
},
"pump_status": {
"default": "mdi:pump"
},
@@ -46,6 +46,12 @@
"dust_alert": {
"name": "Dust alert"
},
"ground_heat_exchanger_attached": {
"name": "Ground heat exchanger attached"
},
"mixer_pump_status": {
"name": "Mixer pump"
},
"pump_status": {
"name": "Pump status"
},
@@ -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."
@@ -8,8 +8,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
SCAN_INTERVAL = timedelta(seconds=30)
@@ -28,6 +31,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: DirecTVConfigEntry) -> b
entry.runtime_data = dtv
# Register the receiver device so client entities can link to it via_device_id.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, dtv.device.info.receiver_id)},
manufacturer=dtv.device.info.brand,
name=next(
(
str.title(location.name)
for location in dtv.device.locations
if not location.client
),
None,
),
sw_version=dtv.device.info.version,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+23 -4
View File
@@ -2,9 +2,12 @@
from directv import DIRECTV
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import DirecTVConfigEntry
from .const import DOMAIN
@@ -14,16 +17,32 @@ class DIRECTVEntity(Entity):
_attr_has_entity_name = True
_attr_name = None
def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None:
def __init__(
self,
*,
hass: HomeAssistant,
dtv: DIRECTV,
entry: DirecTVConfigEntry,
name: str,
address: str = "0",
) -> None:
"""Initialize the DirecTV entity."""
self._address = address
self._device_id = address if address != "0" else dtv.device.info.receiver_id
self._is_client = address != "0"
self.dtv = dtv
via_device_id: str | None = None
if self._is_client:
via_device_id = dr.async_get_device_id_by_identifier(
hass,
(DOMAIN, dtv.device.info.receiver_id),
config_entry_id=entry.entry_id,
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
manufacturer=self.dtv.device.info.brand,
manufacturer=dtv.device.info.brand,
name=name,
sw_version=self.dtv.device.info.version,
via_device=(DOMAIN, self.dtv.device.info.receiver_id),
sw_version=dtv.device.info.version,
)
if via_device_id is not None:
self._attr_device_info["via_device_id"] = via_device_id
@@ -61,7 +61,9 @@ async def async_setup_entry(
async_add_entities(
(
DIRECTVMediaPlayer(
hass=hass,
dtv=dtv,
entry=entry,
name=str.title(location.name),
address=location.address,
)
@@ -74,10 +76,20 @@ async def async_setup_entry(
class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity):
"""Representation of a DirecTV receiver on the network."""
def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None:
def __init__(
self,
*,
hass: HomeAssistant,
dtv: DIRECTV,
entry: DirecTVConfigEntry,
name: str,
address: str = "0",
) -> None:
"""Initialize DirecTV media player."""
super().__init__(
hass=hass,
dtv=dtv,
entry=entry,
name=name,
address=address,
)
+13 -1
View File
@@ -29,7 +29,9 @@ async def async_setup_entry(
async_add_entities(
(
DIRECTVRemote(
hass=hass,
dtv=dtv,
entry=entry,
name=str.title(location.name),
address=location.address,
)
@@ -42,10 +44,20 @@ async def async_setup_entry(
class DIRECTVRemote(DIRECTVEntity, RemoteEntity):
"""Device that sends commands to a DirecTV receiver."""
def __init__(self, *, dtv: DIRECTV, name: str, address: str = "0") -> None:
def __init__(
self,
*,
hass: HomeAssistant,
dtv: DIRECTV,
entry: DirecTVConfigEntry,
name: str,
address: str = "0",
) -> None:
"""Initialize DirecTV remote."""
super().__init__(
hass=hass,
dtv=dtv,
entry=entry,
name=name,
address=address,
)
@@ -1,11 +1,17 @@
"""The EHEIM Digital integration."""
from typing import TYPE_CHECKING
from eheimdigital.device import EheimDigitalDevice
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import async_device_info
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -28,6 +34,17 @@ async def async_setup_entry(
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
main = coordinator.hub.main
if TYPE_CHECKING:
# After the first refresh at least one device is found and so there is
# always a main device set.
assert isinstance(main, EheimDigitalDevice)
# Register the main device up front so child devices can resolve their
# via_device_id link during concurrent platform setup.
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, **async_device_info(coordinator, main)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+30 -12
View File
@@ -10,6 +10,7 @@ from eheimdigital.types import EheimDigitalClientError
from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -17,6 +18,22 @@ from .const import DOMAIN
from .coordinator import EheimDigitalUpdateCoordinator
def async_device_info(
coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalDevice
) -> DeviceInfo:
"""Return the base device info for an EHEIM Digital device."""
return DeviceInfo(
configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
name=device.name,
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="EHEIM",
model=device.model_name,
identifiers={(DOMAIN, device.mac_address)},
suggested_area=device.aquarium_name,
sw_version=device.sw_version,
)
class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
CoordinatorEntity[EheimDigitalUpdateCoordinator], ABC
):
@@ -29,21 +46,22 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
) -> None:
"""Initialize a EHEIM Digital entity."""
super().__init__(coordinator)
main = coordinator.hub.main
if TYPE_CHECKING:
# At this point at least one device is found
# and so there is always a main device set
assert isinstance(coordinator.hub.main, EheimDigitalDevice)
self._attr_device_info = DeviceInfo(
configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}",
name=device.name,
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="EHEIM",
model=device.model_name,
identifiers={(DOMAIN, device.mac_address)},
suggested_area=device.aquarium_name,
sw_version=device.sw_version,
via_device=(DOMAIN, coordinator.hub.main.mac_address),
)
assert isinstance(main, EheimDigitalDevice)
self._attr_device_info = async_device_info(coordinator, device)
if device.mac_address != main.mac_address:
# The main device is registered during setup, before the platforms
# are forwarded, so this link always resolves deterministically.
self._attr_device_info["via_device_id"] = (
dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, main.mac_address),
config_entry_id=coordinator.config_entry.entry_id,
)
)
self._device = device
self._device_address = device.mac_address
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.7.0"],
"requirements": ["eheimdigital==1.7.1"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]
+9 -1
View File
@@ -26,7 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.network import is_ip_address
@@ -58,6 +58,7 @@ from .discovery import (
async_trigger_discovery,
async_update_entry_from_discovery,
)
from .entity import create_elk_system_device_info
from .models import ELKM1Data
from .services import async_setup_services
@@ -308,6 +309,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo
keypads={},
)
# Register the ElkM1 system device before forwarding platforms so entities
# on any platform can deterministically resolve it as their via_device.
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,
**create_elk_system_device_info(elk, prefix, entry.unique_id),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+26 -14
View File
@@ -10,6 +10,7 @@ from elkm1_lib.elk import Elk
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -47,6 +48,23 @@ def create_elk_entities(
return entities
def create_elk_system_device_info(elk: Elk, prefix: str, mac: str | None) -> DeviceInfo:
"""Return the device info for the ElkM1 system device."""
device_name = "ElkM1"
if prefix:
device_name += f" {prefix}"
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{prefix}_system")},
manufacturer="ELK Products, Inc.",
model="M1",
name=device_name,
sw_version=elk.panel.elkm1_version,
)
if mac:
device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
return device_info
def generate_unique_id(prefix: str, element: Element) -> str:
"""Generate a unique id."""
# unique_id starts with elkm1_ iff there is no prefix
@@ -128,10 +146,16 @@ class ElkEntity(Entity):
@override
def device_info(self) -> DeviceInfo:
"""Device info connecting via the ElkM1 system."""
config_entry = self.platform.config_entry
assert config_entry
return DeviceInfo(
name=self._element.name,
identifiers={(DOMAIN, self._unique_id)},
via_device=(DOMAIN, f"{self._prefix}_system"),
via_device_id=dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, f"{self._prefix}_system"),
config_entry_id=config_entry.entry_id,
),
)
@@ -142,16 +166,4 @@ class ElkAttachedEntity(ElkEntity):
@override
def device_info(self) -> DeviceInfo:
"""Device info for the underlying ElkM1 system."""
device_name = "ElkM1"
if self._prefix:
device_name += f" {self._prefix}"
device_info = DeviceInfo(
identifiers={(DOMAIN, f"{self._prefix}_system")},
manufacturer="ELK Products, Inc.",
model="M1",
name=device_name,
sw_version=self._elk.panel.elkm1_version,
)
if self._mac:
device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, self._mac)}
return device_info
return create_elk_system_device_info(self._elk, self._prefix, self._mac)
@@ -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."]
@@ -171,13 +171,15 @@ class FeedReaderCoordinator(
"""Update last_entry_timestamp and fire entry."""
# Check if the entry has a updated or published date.
# Start from a updated date because generally `updated` > `published`.
if time_stamp := entry.get("updated_parsed") or entry.get("published_parsed"):
self._last_entry_timestamp = time_stamp
else:
time_stamp = entry.get("updated_parsed") or entry.get("published_parsed")
if time_stamp is None:
_LOGGER.debug(
"No updated_parsed or published_parsed info available for entry %s",
entry,
)
elif time_stamp and time_stamp > self._last_entry_timestamp:
self._last_entry_timestamp = time_stamp
entry["feed_url"] = self.url
self.hass.bus.async_fire(self._event_type, entry)
_LOGGER.debug("New event fired for entry %s", entry.get("link"))
@@ -2,7 +2,7 @@
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import timedelta
import logging
from typing import override
@@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -76,25 +77,22 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
@override
async def _async_update_data(self) -> FireflyCoordinatorData:
"""Fetch data from Firefly III API."""
now = datetime.now() # pylint: disable=home-assistant-enforce-naive-now
now = dt_util.now()
start_date = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end_date = now
@@ -127,19 +125,16 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except FireflyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except FireflyTimeoutError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
return FireflyCoordinatorData(
@@ -89,13 +89,13 @@
},
"exceptions": {
"cannot_connect": {
"message": "An error occurred while trying to connect to the Firefly III instance: {error}"
"message": "An error occurred while trying to connect to the Firefly III instance"
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
"message": "An error occurred while trying to authenticate"
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Firefly III instance: {error}"
"message": "A timeout occurred while trying to connect to the Firefly III instance"
}
}
}
@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260729.1"]
"requirements": ["home-assistant-frontend==20260729.3"]
}
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["afsapi"],
"requirements": ["afsapi==1.0.1"],
"requirements": ["afsapi==1.0.2"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
@@ -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:
@@ -25,6 +25,7 @@ from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
@@ -41,6 +42,8 @@ from .const import (
CONF_RECOMMENDED,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_LEVEL,
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
@@ -59,6 +62,8 @@ from .const import (
RECOMMENDED_STT_MODEL,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
RECOMMENDED_THINKING_LEVEL,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_MODEL,
@@ -438,6 +443,7 @@ async def google_generative_ai_config_option_schema(
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
}
)
if subentry_type != "tts":
schema.update(
{
@@ -456,6 +462,35 @@ async def google_generative_ai_config_option_schema(
description={"suggested_value": options.get(CONF_MAX_TOKENS)},
default=RECOMMENDED_MAX_TOKENS,
): int,
vol.Optional(
CONF_THINKING_BUDGET,
description={"suggested_value": options.get(CONF_THINKING_BUDGET)},
default=RECOMMENDED_THINKING_BUDGET,
): vol.All(
NumberSelector(
NumberSelectorConfig(
min=-1, max=24576, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Coerce(int),
),
vol.Optional(
CONF_THINKING_LEVEL,
description={"suggested_value": options.get(CONF_THINKING_LEVEL)},
default=RECOMMENDED_THINKING_LEVEL,
): SelectSelector(
SelectSelectorConfig(
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_THINKING_LEVEL,
options=[
"auto",
"minimal",
"low",
"medium",
"high",
],
)
),
vol.Optional(
CONF_HARASSMENT_BLOCK_THRESHOLD,
description={
@@ -40,6 +40,10 @@ CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE"
CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool"
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
CONF_THINKING_BUDGET = "thinking_budget"
RECOMMENDED_THINKING_BUDGET = -1
CONF_THINKING_LEVEL = "thinking_level"
RECOMMENDED_THINKING_LEVEL = "auto"
TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05
@@ -28,6 +28,7 @@ from google.genai.types import (
SafetySetting,
Schema,
ThinkingConfig,
ThinkingLevel,
Tool,
ToolListUnion,
)
@@ -49,6 +50,8 @@ from .const import (
CONF_MAX_TOKENS,
CONF_SEXUAL_BLOCK_THRESHOLD,
CONF_TEMPERATURE,
CONF_THINKING_BUDGET,
CONF_THINKING_LEVEL,
CONF_TOP_K,
CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL,
@@ -59,6 +62,8 @@ from .const import (
RECOMMENDED_HARM_BLOCK_THRESHOLD,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_THINKING_BUDGET,
RECOMMENDED_THINKING_LEVEL,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
TIMEOUT_MILLIS,
@@ -91,6 +96,75 @@ SUPPORTED_SCHEMA_KEYS = {
}
def _is_thinking_model(model: str) -> bool:
"""Check if the model supports thinking configuration."""
name = model.removeprefix("models/")
# Exclude non-text models (TTS, image generation)
if name.endswith(("tts", "image", "image-preview")):
return False
return name.startswith(("gemini-2.5", "gemini-3"))
def _is_gemini_3_model(model: str) -> bool:
"""Check if the model is a Gemini 3 series model."""
name = model.removeprefix("models/")
return name.startswith("gemini-3")
def _create_thinking_config(
model: str,
thinking_budget: int,
thinking_level: str | None = None,
) -> ThinkingConfig | None:
"""Create a ThinkingConfig based on the model and user configuration.
Args:
model: The model name (e.g., "models/gemini-2.5-flash").
thinking_budget: The user-configured thinking budget:
-1 = automatic (default behavior),
0 = disable thinking,
>0 = custom token budget (Gemini 2.5 only).
thinking_level: The user-configured thinking level for Gemini 3 models:
"auto" = automatic (default), "minimal", "low", "medium", "high".
"""
if not _is_thinking_model(model):
return None
if _is_gemini_3_model(model):
name = model.removeprefix("models/")
level_map: dict[str, ThinkingLevel] = {
"minimal": ThinkingLevel.MINIMAL,
"low": ThinkingLevel.LOW,
"medium": ThinkingLevel.MEDIUM,
"high": ThinkingLevel.HIGH,
}
if name.startswith("gemini-3") and "pro" in name:
level_map.pop("minimal")
if thinking_level and thinking_level in level_map:
return ThinkingConfig(
include_thoughts=True,
thinking_level=level_map[thinking_level],
)
return ThinkingConfig(include_thoughts=True)
# Gemini 2.5 models use integer thinking_budget
if thinking_budget == -1:
return ThinkingConfig(include_thoughts=True)
name = model.removeprefix("models/")
if name.startswith("gemini-2.5-pro"):
# gemini-2.5-pro minimum thinking budget is 128
if thinking_budget < 128:
return ThinkingConfig(include_thoughts=True, thinking_budget=128)
return ThinkingConfig(include_thoughts=True, thinking_budget=thinking_budget)
if thinking_budget == 0:
return ThinkingConfig(include_thoughts=False, thinking_budget=0)
return ThinkingConfig(include_thoughts=True, thinking_budget=thinking_budget)
def _camel_to_snake(name: str) -> str:
"""Convert camel case to snake case."""
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
@@ -659,11 +733,11 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
"""Create the GenerateContentConfig for the LLM."""
options = self.subentry.data
model = options.get(CONF_CHAT_MODEL, self.default_model)
thinking_config: ThinkingConfig | None = None
if model.startswith("models/gemini-2.5") and not model.endswith(
("tts", "image", "image-preview")
):
thinking_config = ThinkingConfig(include_thoughts=True)
thinking_config = _create_thinking_config(
model,
int(options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)),
options.get(CONF_THINKING_LEVEL, RECOMMENDED_THINKING_LEVEL),
)
return GenerateContentConfig(
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
@@ -45,6 +45,8 @@
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"thinking_budget": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_budget%]",
"thinking_level": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_level%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]"
}
@@ -79,12 +81,16 @@
"recommended": "Recommended model settings",
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
"temperature": "Temperature",
"thinking_budget": "Thinking budget",
"thinking_level": "Thinking level",
"top_k": "Top K",
"top_p": "Top P"
},
"data_description": {
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\".",
"prompt": "Instruct how the LLM should respond. This can be a template."
"prompt": "Instruct how the LLM should respond. This can be a template.",
"thinking_budget": "Token budget for model reasoning (Gemini 2.5 only). Use -1 for automatic or 0 to disable (not available for Gemini 2.5 Pro).",
"thinking_level": "Thinking level for Gemini 3 models. Ignored for Gemini 2.5 models, which use the thinking budget instead. \"Minimal\" does not guarantee that thinking is off."
}
}
}
@@ -112,6 +118,8 @@
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
"thinking_budget": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_budget%]",
"thinking_level": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::thinking_level%]",
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]"
},
@@ -149,5 +157,16 @@
}
}
}
},
"selector": {
"thinking_level": {
"options": {
"auto": "Auto",
"high": "High",
"low": "Low",
"medium": "Medium",
"minimal": "Minimal"
}
}
}
}
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers import aiohttp_client, device_registry as dr
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
@@ -115,6 +115,17 @@ async def async_setup_entry(
sleep_coordinator=sleep_coordinator,
)
# Register the account device up front so the per-device sensors can resolve
# it as their via_device parent even when only the device scope is granted
# (the account-level sensors that would otherwise create it are gated on
# different scopes).
dr.async_get(hass).async_get_or_create(
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)
return True
@@ -64,9 +64,7 @@ rules:
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category:
status: exempt
comment: All entities are user-facing primary sensors.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
@@ -15,6 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
UnitOfLength,
UnitOfMass,
@@ -22,11 +23,17 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceEntryType,
DeviceInfo,
async_get_device_id_by_identifier,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem
from . import GoogleHealthConfigEntry
from .const import DOMAIN
@@ -50,6 +57,7 @@ class GoogleHealthSensorEntityDescription[
"""Class describing Google Health sensor entities."""
value_fn: Callable[[Any], _ValueT]
suggested_unit_fn: Callable[[UnitSystem], str | None] | None = None
ACTIVITY_SENSORS: list[
@@ -69,11 +77,17 @@ ACTIVITY_SENSORS: list[
value_fn=lambda data: (
data.distance.millimeters_sum / 1000.0 if data and data.distance else 0.0
),
suggested_unit_fn=lambda units: (
UnitOfLength.MILES
if units is US_CUSTOMARY_SYSTEM
else UnitOfLength.KILOMETERS
),
),
GoogleHealthSensorEntityDescription[GoogleHealthActivityCoordinator, float](
key="active_calories",
translation_key="active_calories",
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: (
data.active_energy_burned.kcal_sum
@@ -85,6 +99,7 @@ ACTIVITY_SENSORS: list[
key="total_calories",
translation_key="total_calories",
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: (
data.total_calories.kcal_sum if data and data.total_calories else 0.0
@@ -109,6 +124,9 @@ BODY_SENSORS: list[
value_fn=lambda data: (
data.weight.weight_grams / 1000.0 if data and data.weight else None
),
suggested_unit_fn=lambda units: (
UnitOfMass.POUNDS if units is US_CUSTOMARY_SYSTEM else None
),
),
GoogleHealthSensorEntityDescription[GoogleHealthBodyCoordinator, int | None](
key="resting_heart_rate",
@@ -212,11 +230,15 @@ NUTRITION_SENSORS: list[
if data and data.hydration and data.hydration.amount_consumed
else 0.0
),
suggested_unit_fn=lambda units: (
UnitOfVolume.FLUID_OUNCES if units is US_CUSTOMARY_SYSTEM else None
),
),
GoogleHealthSensorEntityDescription[GoogleHealthNutritionCoordinator, float](
key="calories_consumed",
translation_key="calories_consumed",
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: (
data.nutrition.energy.kcal_sum
@@ -335,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",
)
@@ -345,6 +368,15 @@ class GoogleHealthSensor[_CoordinatorT: GoogleHealthDataUpdateCoordinator[Any]](
"""Return the state of the sensor."""
return cast(StateType, self.entity_description.value_fn(self.coordinator.data))
@property
@override
def suggested_unit_of_measurement(self) -> str | None:
"""Return the suggested unit of measurement."""
if (suggested_unit_fn := self.entity_description.suggested_unit_fn) is not None:
return suggested_unit_fn(self.hass.config.units)
return super().suggested_unit_of_measurement
class GoogleHealthDeviceSensor(
CoordinatorEntity[GoogleHealthDeviceCoordinator], SensorEntity
@@ -352,6 +384,7 @@ class GoogleHealthDeviceSensor(
"""Device-specific Google Health sensor entity."""
_attr_has_entity_name = True
_attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: GoogleHealthDeviceSensorEntityDescription
def __init__(
@@ -374,7 +407,11 @@ class GoogleHealthDeviceSensor(
or (device.device_type.title() if device.device_type else "Device"),
model=device.device_type.title() if device.device_type else None,
sw_version=device.device_version,
via_device=(DOMAIN, entry_id),
via_device_id=async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, entry_id),
config_entry_id=entry_id,
),
)
if device.mac_address:
@@ -1,24 +1,14 @@
"""Green Planet Energy integration for Home Assistant."""
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import GreenPlanetEnergyUpdateCoordinator
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -26,105 +16,10 @@ type GreenPlanetEnergyConfigEntry = ConfigEntry[GreenPlanetEnergyUpdateCoordinat
PLATFORMS: list[Platform] = [Platform.SENSOR]
# Service constants
SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration"
ATTR_DURATION = "duration"
ATTR_TIME_RANGE = "time_range"
# Time range options
TIME_RANGE_DAY = "day"
TIME_RANGE_NIGHT = "night"
TIME_RANGE_FULL_DAY = "full_day"
SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DURATION): vol.All(
vol.Coerce(float), vol.Range(min=0.5, max=24)
),
vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In(
[TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY]
),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Green Planet Energy component."""
async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse:
"""Handle the get_cheapest_duration service call."""
# This integration has single_config_entry, so get the first entry
entries = hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
entry = entries[0]
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
coordinator: GreenPlanetEnergyUpdateCoordinator = entry.runtime_data
duration = call.data[ATTR_DURATION]
time_range = call.data[ATTR_TIME_RANGE]
data = coordinator.data
api = coordinator.api
now = dt_util.now()
current_hour = now.hour
result: tuple[float | None, int | None]
if time_range == TIME_RANGE_DAY:
result = api.get_cheapest_duration_day(data, duration, current_hour)
elif time_range == TIME_RANGE_NIGHT:
result = api.get_cheapest_duration_night(data, duration, current_hour)
else: # TIME_RANGE_FULL_DAY
result = api.get_cheapest_duration(data, duration, current_hour)
avg_price, start_hour_result = result
if avg_price is None or start_hour_result is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_data_available",
)
start_time = dt_util.start_of_local_day(now).replace(
hour=start_hour_result, minute=0, second=0, microsecond=0
)
# If the calculated start time is in the past, shift to tomorrow
if start_time < now:
start_time = start_time + timedelta(days=1)
end_time = start_time + timedelta(hours=duration)
hours_until_start = (start_time - now).total_seconds() / 3600
return {
"duration": duration,
"average_price": round(avg_price / 100, 4),
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"hours_until_start": round(hours_until_start, 1),
"time_range": time_range,
}
hass.services.async_register(
DOMAIN,
SERVICE_GET_CHEAPEST_DURATION,
get_cheapest_duration,
schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
async_setup_services(hass)
return True
@@ -2,6 +2,9 @@
"services": {
"get_cheapest_duration": {
"service": "mdi:clock-check"
},
"get_prices": {
"service": "mdi:lightning-bolt-circle"
}
}
}
@@ -1,17 +1,13 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration registers no actions.
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration registers no actions.
docs-actions: done
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
@@ -32,9 +28,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration registers no actions.
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
@@ -0,0 +1,197 @@
"""Services for Green Planet Energy integration."""
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.helpers.service import async_get_config_entry
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import DOMAIN
SERVICE_GET_PRICES = "get_prices"
ATTR_HOURS = "hours"
SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration"
ATTR_DURATION = "duration"
ATTR_TIME_RANGE = "time_range"
TIME_RANGE_DAY = "day"
TIME_RANGE_NIGHT = "night"
TIME_RANGE_FULL_DAY = "full_day"
def _validate_hours(v: float) -> float:
"""Validate that hours is a multiple of 0.25 (15 minutes)."""
if abs(v * 4 - round(v * 4)) >= 1e-9:
raise vol.Invalid("hours must be a multiple of 0.25 (15 minutes)")
return v
SERVICE_GET_PRICES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): ConfigEntrySelector(
{"integration": DOMAIN}
),
vol.Required(ATTR_HOURS): vol.All(
vol.Coerce(float),
vol.Range(min=0.25, max=24),
_validate_hours,
),
}
)
SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DURATION): vol.All(
vol.Coerce(float), vol.Range(min=0.5, max=24)
),
vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In(
[TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY]
),
}
)
async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse:
"""Find the cheapest consecutive time window for a given duration."""
entries = call.hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
entry = entries[0]
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
coordinator = entry.runtime_data
duration = call.data[ATTR_DURATION]
time_range = call.data[ATTR_TIME_RANGE]
data = coordinator.data
api = coordinator.api
now = dt_util.now()
current_hour = now.hour
result: tuple[float | None, int | None]
if time_range == TIME_RANGE_DAY:
result = api.get_cheapest_duration_day(data, duration, current_hour)
elif time_range == TIME_RANGE_NIGHT:
result = api.get_cheapest_duration_night(data, duration, current_hour)
else:
result = api.get_cheapest_duration(data, duration, current_hour)
avg_price, start_hour_result = result
if avg_price is None or start_hour_result is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_data_available",
)
start_time = dt_util.start_of_local_day(now).replace(
hour=start_hour_result, minute=0, second=0, microsecond=0
)
if start_time < now:
start_time = start_time + timedelta(days=1)
end_time = start_time + timedelta(hours=duration)
hours_until_start = (start_time - now).total_seconds() / 3600
return {
"duration": duration,
"average_price": round(avg_price / 100, 4),
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"hours_until_start": round(hours_until_start, 1),
"time_range": time_range,
}
async def get_prices(call: ServiceCall) -> ServiceResponse:
"""Return raw 15-minute-slot electricity prices for the next N hours.
Prices are in EUR/kWh. Slots for which the API has no data yet (e.g.
tomorrow's prices have not been published yet) are silently omitted
from the result.
"""
entry = async_get_config_entry(call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID])
data = entry.runtime_data.data
hours: float = call.data[ATTR_HOURS]
now = dt_util.now()
slot_timestamp = int(dt_util.as_timestamp(now) // 900 * 900)
slot_start = dt_util.as_local(dt_util.utc_from_timestamp(slot_timestamp))
end_time = slot_start + timedelta(hours=hours)
today = slot_start.date()
tomorrow = today + timedelta(days=1)
slots: list[JsonValueType] = []
current = slot_start
while current < end_time:
slot_end = current + timedelta(minutes=15)
h = current.hour
m = current.minute
current_date = current.date()
if current_date == today:
key = f"gpe_price_{h:02d}_{m:02d}"
elif current_date == tomorrow:
key = f"gpe_price_{h:02d}_{m:02d}_tomorrow"
else:
current = slot_end
continue
if key in data:
slots.append(
{
"start": current.isoformat(),
"end": slot_end.isoformat(),
"price": round(data[key] / 100, 6),
}
)
current = slot_end
return {
"prices": slots,
"hours_requested": hours,
}
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Green Planet Energy."""
hass.services.async_register(
DOMAIN,
SERVICE_GET_CHEAPEST_DURATION,
get_cheapest_duration,
schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_PRICES,
get_prices,
schema=SERVICE_GET_PRICES_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -23,3 +23,19 @@ get_cheapest_duration:
value: "day"
- label: Night (18:00-06:00)
value: "night"
get_prices:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: green_planet_energy
hours:
required: true
selector:
number:
min: 0.25
max: 24
step: 0.25
unit_of_measurement: hours
@@ -70,6 +70,20 @@
}
},
"name": "Get cheapest time window"
},
"get_prices": {
"description": "Returns raw 15-minute electricity price slots for the next N hours. Slots beyond the API horizon are omitted.",
"fields": {
"config_entry_id": {
"description": "The Green Planet Energy integration instance to use.",
"name": "Config entry"
},
"hours": {
"description": "How many hours of price data to return, starting from the current 15-minute slot. Minimum 0.25, maximum 24.",
"name": "Hours"
}
},
"name": "Get energy prices"
}
}
}
@@ -1,22 +1,33 @@
"""Support for Homekit motion sensors."""
"""Support for HomeKit binary sensors."""
from dataclasses import dataclass
from typing import override
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes
from aiohomekit.model.services import Service, ServicesTypes
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
from .connection import HKDevice
from .entity import HomeKitEntity
from .entity import CharacteristicEntity, HomeKitEntity
from .utils import folded_name
@dataclass(frozen=True)
class HomeKitBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes a HomeKit binary sensor."""
on_value: int | bool = 1
class HomeKitMotionSensor(HomeKitEntity, BinarySensorEntity):
@@ -167,13 +178,64 @@ REJECT_CHAR_BY_TYPE = {
ServicesTypes.BATTERY_SERVICE: CharacteristicsTypes.BATTERY_LEVEL,
}
CHARACTERISTIC_BINARY_SENSORS: dict[str, HomeKitBinarySensorEntityDescription] = {
CharacteristicsTypes.STATUS_LO_BATT: HomeKitBinarySensorEntityDescription(
key=CharacteristicsTypes.STATUS_LO_BATT,
name="Low Battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
CharacteristicsTypes.STATUS_FAULT: HomeKitBinarySensorEntityDescription(
key=CharacteristicsTypes.STATUS_FAULT,
name="Problem",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
class CharacteristicBinarySensor(CharacteristicEntity, BinarySensorEntity):
"""Representation of a HomeKit binary sensor backed by a single characteristic."""
entity_description: HomeKitBinarySensorEntityDescription
def __init__(
self,
conn: HKDevice,
info: ConfigType,
char: Characteristic,
description: HomeKitBinarySensorEntityDescription,
) -> None:
"""Initialise a HomeKit characteristic binary sensor."""
self.entity_description = description
super().__init__(conn, info, char)
@property
@override
def name(self) -> str:
"""Return the name of the sensor."""
if name := self.accessory.name:
return f"{name} {self.entity_description.name}"
return f"{self.entity_description.name}"
@override
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity is tracking."""
return [self._char.type]
@property
@override
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self._char.value == self.entity_description.on_value
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit lighting."""
"""Set up HomeKit binary sensors."""
hkid: str = config_entry.data["AccessoryPairingID"]
conn: HKDevice = hass.data[KNOWN_DEVICES][hkid]
@@ -198,3 +260,60 @@ async def async_setup_entry(
return True
conn.add_listener(async_add_service)
@callback
def async_add_characteristic(char: Characteristic) -> bool:
if char.service.type == ServicesTypes.BATTERY_SERVICE:
return False
if not (description := CHARACTERISTIC_BINARY_SENSORS.get(char.type)):
return False
if char.type == CharacteristicsTypes.STATUS_LO_BATT and (
_should_skip_low_battery_characteristic(char)
):
return False
info = {"aid": char.service.accessory.aid, "iid": char.service.iid}
entity = CharacteristicBinarySensor(conn, info, char, description)
conn.async_migrate_unique_id(
entity.old_unique_id, entity.unique_id, Platform.BINARY_SENSOR
)
async_add_entities([entity])
return True
conn.add_char_factory(async_add_characteristic)
def _should_skip_low_battery_characteristic(char: Characteristic) -> bool:
"""Check if the low battery characteristic should not create an entity."""
return char.service.accessory.services.first(
service_type=ServicesTypes.BATTERY_SERVICE
) is not None or _has_earlier_low_battery_characteristic(char)
def _has_earlier_low_battery_characteristic(char: Characteristic) -> bool:
"""Check if the accessory already exposed the same low battery source.
Unscoped low battery characteristics are treated as accessory-level duplicates.
"""
source_key = _low_battery_source_key(char.service)
return any(
service.iid < char.service.iid
and service.has(char.type)
and _low_battery_source_key(service) == source_key
for service in char.service.accessory.services
)
def _low_battery_source_key(service: Service) -> str | None:
"""Return the low battery source key for the service."""
if (
service_label_index := service.value(CharacteristicsTypes.SERVICE_LABEL_INDEX)
) is not None:
return f"label:{service.type}:{service_label_index}"
service_name = service.value(CharacteristicsTypes.NAME)
if service_name is not None and folded_name(str(service_name)) != folded_name(
service.accessory.name
):
return f"name:{folded_name(str(service_name))}"
return None
@@ -81,6 +81,9 @@ CHARACTERISTIC_PLATFORMS = {
CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: "number",
CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: "number",
CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: "sensor",
CharacteristicsTypes.SET_DURATION: "number",
CharacteristicsTypes.STATUS_FAULT: "binary_sensor",
CharacteristicsTypes.STATUS_LO_BATT: "binary_sensor",
CharacteristicsTypes.VENDOR_HAA_SETUP: "button",
CharacteristicsTypes.VENDOR_HAA_UPDATE: "button",
CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY: "sensor",
@@ -12,11 +12,12 @@ from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.const import EntityCategory, Platform, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
@@ -64,6 +65,14 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = {
translation_key="sensitivity",
entity_category=EntityCategory.CONFIG,
),
CharacteristicsTypes.SET_DURATION: NumberEntityDescription(
key=CharacteristicsTypes.SET_DURATION,
name="Duration",
device_class=NumberDeviceClass.DURATION,
translation_key="duration",
entity_category=EntityCategory.CONFIG,
native_unit_of_measurement=UnitOfTime.SECONDS,
),
}
@@ -13,6 +13,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==10.1.0"],
"requirements": ["python-homewizard-energy==10.2.0"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
@@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
ATTR_VIA_DEVICE,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfApparentPower,
UnitOfElectricCurrent,
@@ -133,7 +133,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
HomeWizardSensorEntityDescription(
key="wifi_rssi",
translation_key="wifi_rssi",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
+12 -42
View File
@@ -2,13 +2,12 @@
from aiohue.util import normalize_bridge_id
from homeassistant.components import persistent_notification
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .bridge import HueBridge, HueConfigEntry
from .bridge import HueBridge, HueConfigEntry, _async_register_bridge_device
from .const import DOMAIN
from .migration import check_migration
from .services import async_setup_services
@@ -70,47 +69,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool:
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
return False
# add bridge device to device registry
device_registry = dr.async_get(hass)
if bridge.api_version == 1:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)},
identifiers={(DOMAIN, api.config.bridge_id)},
manufacturer="Signify",
name=api.config.name,
model_id=api.config.model_id,
sw_version=api.config.software_version,
)
# create persistent notification if we found a bridge version
# with security vulnerability
if (
api.config.model_id == "BSB002"
and api.config.software_version < "1935144040"
):
persistent_notification.async_create(
hass,
(
"Your Hue hub has a known security vulnerability ([CVE-2020-6007] "
"(https://cve.circl.lu/cve/CVE-2020-6007)). "
"Go to the Hue app and check for software updates."
),
"Signify Hue",
"hue_hub_firmware",
)
else:
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)},
identifiers={
(DOMAIN, api.config.bridge_id),
(DOMAIN, api.config.bridge_device.id),
},
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
name=api.config.name,
model_id=api.config.model_id,
sw_version=api.config.software_version,
# v1 bridges already register their device before platform forwarding, so
# light/sensor entities can resolve it as their via_device parent; only
# register it here if that has not already happened. v2 bridges are always
# (re)registered here to merge the network MAC connection into the bridge
# device created by async_setup_devices with only its Zigbee MAC.
if bridge.api_version != 1 or (
dr.async_get(hass).async_get_device_by_identifier(
(DOMAIN, api.config.bridge_id), entry.entry_id
)
is None
):
_async_register_bridge_device(hass, entry, api, bridge.api_version)
return True
+59 -1
View File
@@ -11,10 +11,11 @@ from aiohue import HueBridgeV1, HueBridgeV2, LinkButtonNotPressed, Unauthorized
from aiohue.errors import AiohueException, BridgeBusy
from homeassistant import core
from homeassistant.components import persistent_notification
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, Platform
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers import aiohttp_client, device_registry as dr
from .const import DOMAIN
from .v1.sensor_base import SensorManager
@@ -105,6 +106,13 @@ class HueBridge:
if self.api_version == 1:
if self.api.sensors is not None:
self.sensor_manager = SensorManager(self)
# Register the bridge device before forwarding the platforms so the
# light and sensor entities can resolve it as their via_device parent
# while they are being added.
if self.hass.config_entries.async_get_entry(self.config_entry.entry_id):
_async_register_bridge_device(
self.hass, self.config_entry, self.api, self.api_version
)
await self.hass.config_entries.async_forward_entry_setups(
self.config_entry, PLATFORMS_v1
)
@@ -179,6 +187,56 @@ class HueBridge:
create_config_flow(self.hass, self.host)
@core.callback
def _async_register_bridge_device(
hass: core.HomeAssistant,
config_entry: HueConfigEntry,
api: HueBridgeV1 | HueBridgeV2,
api_version: int,
) -> None:
"""Add the bridge device to the device registry."""
device_registry = dr.async_get(hass)
if api_version == 1:
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)},
identifiers={(DOMAIN, api.config.bridge_id)},
manufacturer="Signify",
name=api.config.name,
model_id=api.config.model_id,
sw_version=api.config.software_version,
)
# create persistent notification if we found a bridge version
# with security vulnerability
if (
api.config.model_id == "BSB002"
and api.config.software_version < "1935144040"
):
persistent_notification.async_create(
hass,
(
"Your Hue hub has a known security vulnerability ([CVE-2020-6007] "
"(https://cve.circl.lu/cve/CVE-2020-6007)). "
"Go to the Hue app and check for software updates."
),
"Signify Hue",
"hue_hub_firmware",
)
else:
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, api.config.mac_address)},
identifiers={
(DOMAIN, api.config.bridge_id),
(DOMAIN, api.config.bridge_device.id),
},
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
name=api.config.name,
model_id=api.config.model_id,
sw_version=api.config.software_version,
)
async def _update_listener(hass: core.HomeAssistant, entry: HueConfigEntry) -> None:
"""Handle ConfigEntry options update."""
await hass.config_entries.async_reload(entry.entry_id)
+1 -1
View File
@@ -10,6 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
"requirements": ["aiohue==4.8.2"],
"requirements": ["aiohue==4.9.0"],
"zeroconf": ["_hue._tcp.local."]
}
+37 -2
View File
@@ -1,5 +1,6 @@
"""Support for switch platform for Hue resources (V2 only)."""
from collections.abc import Callable
from typing import Any, override
from aiohue.v2 import HueBridgeV2
@@ -11,17 +12,20 @@ from aiohue.v2.controllers.sensors import (
Motion,
MotionController,
)
from aiohue.v2.models.behavior_script import BehaviorScriptCategory
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import HueConfigEntry
from .const import DOMAIN
from .v2.entity import HueBaseEntity
@@ -48,12 +52,15 @@ async def async_setup_entry(
| HueLightSensorEnabledEntity
| HueMotionSensorEnabledEntity
],
resource_filter: Callable[[Any], bool] | None = None,
):
@callback
def async_add_entity(
event_type: EventType, resource: BehaviorInstance | LightLevel | Motion
) -> None:
"""Add entity from Hue resource."""
if resource_filter is not None and not resource_filter(resource):
return
async_add_entities([switch_class(bridge, controller, resource)])
# add all current items in controller
@@ -67,10 +74,38 @@ async def async_setup_entry(
)
)
@callback
def is_user_automation(resource: BehaviorInstance) -> bool:
"""Return if the behavior instance is an automation from the Hue app.
Anything else is device configuration, which the bridge keeps running
even after it accepts switching it off. Categories we do not recognise
are skipped too, better no switch than one that does nothing.
"""
script = api.config.behavior_script.get(resource.script_id)
return (
script is not None
and script.metadata.category is BehaviorScriptCategory.AUTOMATION
)
# clean up entities previously created for internal behavior instances
entity_registry = er.async_get(hass)
for resource in api.config.behavior_instance:
if is_user_automation(resource):
continue
if entity_id := entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, resource.id
):
entity_registry.async_remove(entity_id)
# setup for each switch-type hue resource
register_items(api.sensors.motion, HueMotionSensorEnabledEntity)
register_items(api.sensors.light_level, HueLightSensorEnabledEntity)
register_items(api.config.behavior_instance, HueBehaviorInstanceEnabledEntity)
register_items(
api.config.behavior_instance,
HueBehaviorInstanceEnabledEntity,
is_user_automation,
)
class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity):
+6 -1
View File
@@ -29,6 +29,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -545,7 +546,11 @@ class HueLight(CoordinatorEntity, LightEntity):
name=self.name,
sw_version=self.light.swversion,
suggested_area=suggested_area,
via_device=(DOMAIN, self.bridge.api.config.bridgeid),
via_device_id=dr.async_get_device_id_by_identifier(
self.bridge.hass,
(DOMAIN, self.bridge.api.config.bridgeid),
config_entry_id=self.bridge.config_entry.entry_id,
),
)
@override
@@ -2,7 +2,7 @@
from typing import override
from homeassistant.helpers import entity
from homeassistant.helpers import device_registry as dr, entity
from homeassistant.helpers.device_registry import DeviceInfo
from ..const import CONF_ALLOW_UNREACHABLE, DEFAULT_ALLOW_UNREACHABLE, DOMAIN
@@ -61,5 +61,9 @@ class GenericHueDevice(entity.Entity): # pylint: disable=home-assistant-enforce
model=(self.primary_sensor.productname or self.primary_sensor.modelid),
name=self.primary_sensor.name,
sw_version=self.primary_sensor.swversion,
via_device=(DOMAIN, self.bridge.api.config.bridgeid),
via_device_id=dr.async_get_device_id_by_identifier(
self.bridge.hass,
(DOMAIN, self.bridge.api.config.bridgeid),
config_entry_id=self.bridge.config_entry.entry_id,
),
)
@@ -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
+12 -3
View File
@@ -18,7 +18,6 @@ from homeassistant.const import (
ATTR_NAME,
ATTR_SUGGESTED_AREA,
ATTR_SW_VERSION,
ATTR_VIA_DEVICE,
)
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
@@ -49,7 +48,11 @@ async def async_setup_devices(bridge: HueBridge):
name=hue_resource.metadata.name,
model=hue_resource.type.value.replace("_", " ").title(),
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
via_device=(DOMAIN, api.config.bridge_device.id),
via_device_id=dr.async_get_device_id_by_identifier(
hass,
(DOMAIN, api.config.bridge_device.id),
config_entry_id=entry.entry_id,
),
suggested_area=hue_resource.metadata.name
if hue_resource.type == ResourceTypes.ROOM
else None,
@@ -68,7 +71,13 @@ async def async_setup_devices(bridge: HueBridge):
if hue_resource.id == api.config.bridge_device.id:
params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id))
else:
params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id)
# The bridge device is always registered first (see sort below), so
# its id can be resolved here for the via_device link.
params["via_device_id"] = dr.async_get_device_id_by_identifier(
hass,
(DOMAIN, api.config.bridge_device.id),
config_entry_id=entry.entry_id,
)
zigbee = dev_controller.get_zigbee_connectivity(hue_resource.id)
if zigbee and zigbee.mac_address:
params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)}
+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}",
)
@@ -61,6 +61,10 @@ def entity_selector_compatible(
if current
else None
)
if unit_of_measurement is None:
return selector.EntitySelector(
selector.EntitySelectorConfig(domain=ALLOWED_DOMAINS)
)
entities = [
ent.entity_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(
+13 -1
View File
@@ -7,7 +7,7 @@ from homeassistant import config_entries
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import DATA_CONFIG, DOMAIN
@@ -184,6 +184,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: IZoneConfigEntry) -> boo
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Register the controller device before forwarding platforms so zone
# entities can resolve their via_device_id parent at construction time.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, controller.device_uid)},
manufacturer="IZone",
model=controller.sys_type,
name=f"iZone Controller {controller.device_uid}",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
+6 -2
View File
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers import device_registry as dr, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.temperature import display_temp as show_temp
@@ -403,7 +403,11 @@ class ZoneDevice(IZoneCoordinatorEntity, ClimateEntity):
manufacturer="IZone",
model=zone.type.name.title(),
name=zone.name.title(),
via_device=(DOMAIN, controller_entity.unique_id),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, controller_entity.unique_id),
config_entry_id=coordinator.config_entry.entry_id,
),
)
@property
+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)
+93 -34
View File
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID
from homeassistant.const import CONF_API_TOKEN, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -27,30 +27,43 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
DOMAIN = DOMAIN
_host: str | None = None
_locks: list[dict[str, Any]]
_api_token: str | None = None
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._locks = []
async def validate_input(
self, hass: HomeAssistant, data: dict[str, Any]
) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
# 1. Checking loqed-connection
try:
session = async_get_clientsession(hass)
session = async_get_clientsession(hass)
if self._locks and not self._host:
# Reuse the lock list already fetched during manual setup to
# avoid a duplicate cloud request.
lock_data = {"data": self._locks}
else:
cloud_api_client = cloud_loqed.CloudAPIClient(
session,
data[CONF_API_TOKEN],
)
cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client)
lock_data = await cloud_client.async_get_locks()
except aiohttp.ClientError as err:
_LOGGER.error("HTTP Connection error to loqed API")
raise CannotConnect from err
try:
lock_data = await cloud_client.async_get_locks()
except aiohttp.ClientError as err:
_LOGGER.error("HTTP Connection error to loqed API")
raise CannotConnect from err
try:
match_key, match_value = (
("bridge_ip", self._host) if self._host else ("id", data.get("lock_id"))
)
selected_lock = next(
lock
for lock in lock_data["data"]
if lock["bridge_ip"] == self._host or lock["name"] == data.get("name")
lock for lock in lock_data["data"] if lock[match_key] == match_value
)
apiclient = loqed.APIClient(session, f"http://{selected_lock['bridge_ip']}")
@@ -73,11 +86,11 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
"name": selected_lock["name"],
"id": selected_lock["id"],
}
except StopIteration:
raise InvalidAuth from StopIteration
except aiohttp.ClientError:
except StopIteration as err:
raise InvalidAuth from err
except aiohttp.ClientError as err:
_LOGGER.error("HTTP Connection error to loqed lock")
raise CannotConnect from aiohttp.ClientError
raise CannotConnect from err
@override
async def async_step_zeroconf(
@@ -103,21 +116,10 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show userform to user."""
user_data_schema = (
vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
if self._host
else vol.Schema(
{
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Required(CONF_NAME): str,
vol.Required(CONF_API_TOKEN): str,
}
)
user_data_schema = vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
if user_input is None:
@@ -131,6 +133,39 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
# If no Zeroconf discovery and no selected lock, we need to fetch locks and show picker
if not self._host and not user_input.get("lock_id"):
session = async_get_clientsession(self.hass)
cloud_api_client = cloud_loqed.CloudAPIClient(
session,
user_input[CONF_API_TOKEN],
)
cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client)
try:
lock_data = await cloud_client.async_get_locks()
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
else:
self._locks = lock_data["data"]
self._api_token = user_input[CONF_API_TOKEN]
if not self._locks:
errors["base"] = "no_locks"
elif len(self._locks) == 1:
user_input["lock_id"] = self._locks[0]["id"]
else:
return await self.async_step_pick_lock()
if errors:
return self.async_show_form(
step_id="user",
data_schema=user_data_schema,
errors=errors,
description_placeholders={
"config_url": "https://integrations.loqed.com/personal-access-tokens",
},
)
try:
info = await self.validate_input(self.hass, user_input)
except CannotConnect:
@@ -147,10 +182,12 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="LOQED Touch Smart Lock",
data=(
user_input | {CONF_WEBHOOK_ID: webhook.async_generate_id()} | info
),
title=info["name"],
data={
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
CONF_WEBHOOK_ID: webhook.async_generate_id(),
**info,
},
)
return self.async_show_form(
@@ -162,6 +199,28 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
async def async_step_pick_lock(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle lock selection when multiple locks are available."""
if user_input is not None:
if self._api_token is None:
return await self.async_step_user()
return await self.async_step_user(
{**user_input, CONF_API_TOKEN: self._api_token}
)
lock_options = {lock["id"]: lock["name"] for lock in self._locks}
return self.async_show_form(
step_id="pick_lock",
data_schema=vol.Schema(
{
vol.Required("lock_id"): vol.In(lock_options),
}
),
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
+9 -3
View File
@@ -5,14 +5,20 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_locks": "No locks found in your LOQED account."
},
"flow_title": "LOQED Touch Smartlock setup",
"step": {
"pick_lock": {
"data": {
"lock_id": "Select your lock"
},
"description": "Multiple locks found. Please select the lock you want to configure."
},
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
"name": "Name of your lock in the LOQED app."
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token."
}
+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
@@ -1,5 +1,6 @@
"""Config flow for Lyngdorf integration."""
import logging
from typing import Any, override
from urllib.parse import urlparse
@@ -23,6 +24,8 @@ from homeassistant.helpers.service_info.ssdp import (
from .const import CONF_SERIAL_NUMBER, DEFAULT_DEVICE_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Lyngdorf config flow."""
@@ -158,6 +161,11 @@ class LyngdorfFlowHandler(ConfigFlow, domain=DOMAIN):
device_model_name = discovery_info.upnp.get(ATTR_UPNP_MODEL_NAME) or ""
if not (model := lookup_receiver_model(device_model_name)):
_LOGGER.warning(
"SSDP discovered device with unrecognized model name %r at %s",
device_model_name,
self._host,
)
raise AbortFlow("unsupported_model")
self._device_model = model.model_name
self._device_serial_number = (
+4 -5
View File
@@ -1,5 +1,4 @@
"""Support for Mailgun."""
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
import hashlib
import hmac
@@ -16,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_flow, config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import DATA_CONFIG, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -45,7 +44,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if DOMAIN not in config:
return True
hass.data[DOMAIN] = config[DOMAIN]
hass.data[DATA_CONFIG] = config[DOMAIN]
return True
@@ -76,7 +75,7 @@ async def handle_webhook(
async def verify_webhook(hass, token=None, timestamp=None, signature=None):
"""Verify webhook was signed by Mailgun."""
if DOMAIN not in hass.data:
if DATA_CONFIG not in hass.data:
_LOGGER.warning("Cannot validate Mailgun webhook, missing API Key")
return True
@@ -84,7 +83,7 @@ async def verify_webhook(hass, token=None, timestamp=None, signature=None):
return False
hmac_digest = hmac.new(
key=bytes(hass.data[DOMAIN][CONF_API_KEY], "utf-8"),
key=bytes(hass.data[DATA_CONFIG][CONF_API_KEY], "utf-8"),
msg=bytes(f"{timestamp}{token}", "utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
@@ -1,3 +1,11 @@
"""Const for Mailgun."""
from typing import Any
from homeassistant.util.hass_dict import HassKey
DOMAIN = "mailgun"
# YAML component config (api_key / domain / sandbox) used by notify + webhook verify.
# Domain-level, not per config entry — entries only register webhooks.
DATA_CONFIG: HassKey[dict[str, Any]] = HassKey(DOMAIN)
+3 -4
View File
@@ -22,7 +22,8 @@ from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_RECIPIENT, CONF_
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_SANDBOX, DOMAIN
from . import CONF_SANDBOX
from .const import DATA_CONFIG
_LOGGER = logging.getLogger(__name__)
@@ -42,9 +43,7 @@ def get_service(
discovery_info: DiscoveryInfoType | None = None,
) -> MailgunNotificationService | None:
"""Get the Mailgun notification service."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=home-assistant-use-runtime-data
data = hass.data[DOMAIN]
data = hass.data[DATA_CONFIG]
mailgun_service = MailgunNotificationService(
data.get(CONF_DOMAIN),
data.get(CONF_SANDBOX),
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
"requirements": ["matrix-nio==0.26.0", "Pillow==12.3.0", "aiofiles==24.1.0"]
"requirements": ["matrix-nio==0.26.0", "Pillow==12.3.0", "aiofiles==25.1.0"]
}
+21 -7
View File
@@ -70,7 +70,12 @@ class MatterAdapter:
def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None:
"""Handle endpoint added event."""
node = self.matter_client.get_node(data["node_id"])
self._setup_endpoint(node.endpoints[data["endpoint_id"]])
endpoint = node.endpoints[data["endpoint_id"]]
# Ensure the bridge device (endpoint 0) is registered before a
# bridged child endpoint resolves it as its via_device.
if endpoint.is_bridged_device and node.endpoints[0] != endpoint:
self._setup_endpoint(node.endpoints[0])
self._setup_endpoint(endpoint)
def endpoint_removed_callback(event: EventType, data: dict[str, int]) -> None:
"""Handle endpoint removed event."""
@@ -136,9 +141,12 @@ class MatterAdapter:
"""Set up an node."""
LOGGER.debug("Setting up entities for node %s", node.node_id)
try:
for endpoint in node.endpoints.values():
# Process endpoints in order so the bridge device (endpoint 0) is
# registered before any bridged child endpoint resolves it as its
# via_device.
for endpoint_id in sorted(node.endpoints):
# Node endpoints are translated into HA devices
self._setup_endpoint(endpoint)
self._setup_endpoint(node.endpoints[endpoint_id])
except Exception as err: # noqa: BLE001
# We don't want to crash the whole setup when a single node fails to setup
# for whatever reason, so we catch all exceptions here.
@@ -172,14 +180,20 @@ class MatterAdapter:
or (device_type.__name__ if device_type else None)
)
device_registry = dr.async_get(self.hass)
# handle bridged devices
bridge_device_id = None
via_device_id: str | None = None
if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint:
bridge_device_id = get_device_id(
server_info,
endpoint.node.endpoints[0],
)
bridge_device_id = f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}"
via_device_id = dr.async_get_device_id_by_identifier(
self.hass,
(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}"),
config_entry_id=self.config_entry.entry_id,
)
node_device_id = get_device_id(
server_info,
@@ -214,7 +228,7 @@ class MatterAdapter:
else:
model_id = str(product_id) if (product_id := basic_info.productID) else None
dr.async_get(self.hass).async_get_or_create(
device_registry.async_get_or_create(
name=name,
config_entry_id=self.config_entry.entry_id,
identifiers=identifiers,
@@ -224,7 +238,7 @@ class MatterAdapter:
model=model_name,
model_id=model_id,
serial_number=serial_number,
via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None,
via_device_id=via_device_id,
)
def _setup_endpoint(self, endpoint: MatterEndpoint) -> None:
@@ -376,8 +376,7 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
)
or not (results := entity_response.result)
):
# No results found
return intent_obj.create_response()
raise intent.IntentHandleError(f"No results found for {search_query}")
# 2. Play Media (first result)
first_result = results[0]
@@ -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)
@@ -166,15 +166,6 @@ class MideaClimate(MideaEntity, ClimateEntity):
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_zone: int | None = None
def __init__(
self,
device: MideaClimateDevice,
description: MideaClimateEntityDescription,
) -> None:
"""Midea Climate entity init."""
super().__init__(device, description.key)
self.entity_description = description
def _float_attribute(self, attr: str) -> float | None:
"""Return a device attribute as float, if convertible."""
value = self._device.get_attribute(attr)
+6 -3
View File
@@ -6,7 +6,7 @@ from midealocal.device import MideaDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity import Entity, EntityDescription
from .const import DOMAIN, LOGGER
from .device_catalog import MIDEA_DEVICE_NAMES
@@ -20,11 +20,14 @@ class MideaEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(self, device: MideaDevice, entity_key: str) -> None:
def __init__(
self, device: MideaDevice, entity_description: EntityDescription
) -> None:
"""Initialize Midea base entity."""
self._device = device
self._unique_id = f"{self._device.device_id}_{entity_key}"
self._unique_id = f"{self._device.device_id}_{entity_description.key}"
self._device_name = self._device.name
self.entity_description = entity_description
@override
async def async_added_to_hass(self) -> None:
+52 -9
View File
@@ -5,39 +5,82 @@ from yarl import URL
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import DOMAIN
from .coordinator import MikrotikDataUpdateCoordinator
class MikrotikEntity[DescriptionT: EntityDescription](
CoordinatorEntity[MikrotikDataUpdateCoordinator]
):
"""Base class for Mikrotik entities."""
class MikrotikBaseEntity(CoordinatorEntity[MikrotikDataUpdateCoordinator]):
"""Base class for all Mikrotik entities."""
_attr_has_entity_name = True
entity_description: DescriptionT
def __init__(
self,
coordinator: MikrotikDataUpdateCoordinator,
description: DescriptionT,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = description
self._serial = coordinator.api.serial_number
self._attr_device_info = DeviceInfo(
def _base_device_info(self) -> DeviceInfo:
"""Return the device info fields shared by all Mikrotik devices."""
coordinator = self.coordinator
return DeviceInfo(
configuration_url=URL.build(
scheme="http",
host=coordinator.host,
),
identifiers={(DOMAIN, self._serial)},
name=coordinator.hostname,
manufacturer="Mikrotik",
model=coordinator.model,
sw_version=coordinator.firmware,
serial_number=self._serial,
)
class MikrotikEntity(MikrotikBaseEntity):
"""Base class for Mikrotik entities."""
def __init__(
self,
coordinator: MikrotikDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, description)
self._attr_device_info = DeviceInfo(
**self._base_device_info(),
identifiers={(DOMAIN, self._serial)},
name=coordinator.hostname,
)
self._attr_unique_id = f"{self._serial}_{description.key}"
class MikrotikDeviceEntity(MikrotikBaseEntity):
"""Base class for Mikrotik device entities."""
def __init__(
self,
coordinator: MikrotikDataUpdateCoordinator,
description: EntityDescription,
interface: dict,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, description)
name = interface.get("name")
ident = f"{slugify(interface.get('mac-address'))}_{name}"
self._attr_device_info = DeviceInfo(
**self._base_device_info(),
identifiers={(DOMAIN, ident)},
name=name,
via_device=(DOMAIN, coordinator.api.serial_number),
)
self._attr_unique_id = ident
self._attr_name = name
self._interface = interface
+1 -3
View File
@@ -162,9 +162,7 @@ async def async_setup_entry(
async_add_entities(sensors_list)
class MikrotikSensorEntity(
MikrotikEntity[MikrotikSensorEntityDescription], SensorEntity
):
class MikrotikSensorEntity(MikrotikEntity, SensorEntity):
"""Sensor device."""
entity_description: MikrotikSensorEntityDescription
+2 -2
View File
@@ -81,13 +81,13 @@ async def async_setup_entry(
class MikrotikUpdateEntity(MikrotikEntity, UpdateEntity):
"""Mixin for update entity specific attributes."""
update_description: MikrotikUpdateEntityDescription
entity_description: MikrotikUpdateEntityDescription
@property
@override
def supported_features(self) -> UpdateEntityFeature:
"""Flag supported features."""
return cast(UpdateEntityFeature, self.entity_description.supported_features)
return self.entity_description.supported_features
@property
def _device_path_info(self) -> dict[str, Any]:
@@ -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,
)
@@ -4,11 +4,12 @@
import asyncio
import logging
from motionblinds import AsyncMotionMulticast
from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, AsyncMotionMulticast
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import (
CONF_BLIND_TYPE_LIST,
@@ -21,6 +22,7 @@ from .const import (
PLATFORMS,
)
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
from .entity import gateway_device_info
from .gateway import ConnectMotionGateway
_LOGGER = logging.getLogger(__name__)
@@ -101,6 +103,20 @@ async def async_setup_entry(
entry.runtime_data = coordinator
# Register the gateway device up front so child blinds can resolve it as their
# via_device parent regardless of the order platforms are set up in. The any()
# is the exact complement of the children's linking condition, so the gateway is
# still registered if it self-reports an unexpected device_type while RF (non
# Wi-Fi) blinds depend on it.
if motion_gateway.device_type in DEVICE_TYPES_GATEWAY or any(
blind.device_type not in DEVICE_TYPES_WIFI
for blind in motion_gateway.device_list.values()
):
dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id,
**gateway_device_info(motion_gateway),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -24,6 +24,23 @@ from .coordinator import DataUpdateCoordinatorMotionBlinds
from .gateway import device_name
def gateway_device_info(gateway: MotionGateway) -> DeviceInfo:
"""Return the device info of a Motionblinds gateway."""
if gateway.firmware is not None:
sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}"
else:
sw_version = f"Protocol: {gateway.protocol}"
return DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, gateway.mac)},
identifiers={(DOMAIN, gateway.mac)},
manufacturer=MANUFACTURER,
name=DEFAULT_GATEWAY_NAME,
model="Wi-Fi bridge",
sw_version=sw_version,
)
class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]):
"""Representation of a Motionblind entity."""
@@ -50,42 +67,37 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
self._update_interval_moving = UPDATE_INTERVAL_MOVING
if blind.device_type in DEVICE_TYPES_GATEWAY:
gateway = blind
self._attr_device_info = gateway_device_info(blind)
else:
gateway = blind._gateway # noqa: SLF001
if gateway.firmware is not None:
sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}"
else:
sw_version = f"Protocol: {gateway.protocol}"
if gateway.firmware is not None:
sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}"
else:
sw_version = f"Protocol: {gateway.protocol}"
if blind.device_type in DEVICE_TYPES_GATEWAY:
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
name=DEFAULT_GATEWAY_NAME,
model="Wi-Fi bridge",
sw_version=sw_version,
)
elif blind.device_type in DEVICE_TYPES_WIFI:
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
sw_version=sw_version,
hw_version=blind.wireless_name,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
via_device=(DOMAIN, blind._gateway.mac), # noqa: SLF001
hw_version=blind.wireless_name,
)
if blind.device_type in DEVICE_TYPES_WIFI:
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
sw_version=sw_version,
hw_version=blind.wireless_name,
)
else:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, blind.mac)},
manufacturer=MANUFACTURER,
model=blind.blind_type,
name=device_name(blind),
via_device_id=dr.async_get_device_id_by_identifier(
coordinator.hass,
(DOMAIN, gateway.mac),
config_entry_id=coordinator.config_entry.entry_id,
),
hw_version=blind.wireless_name,
)
@property
@override
+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
+1 -1
View File
@@ -220,7 +220,7 @@ class MqttInfraredReceiverEntity(MqttEntity, InfraredReceiverEntity):
_LOGGER.debug("Ignoring retained infrared signal on topic %s", msg.topic)
return
payload = self._value_template(msg.payload)
if not payload or payload in (PAYLOAD_NONE, "null"):
if not payload or payload in (PAYLOAD_NONE, "null", '""'):
_LOGGER.debug(
"Ignoring payload for %s on topic %s, with template %s",
self.entity_id,

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