mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Merge branch 'button_event_triggers' into input_button_trigger
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,11 +97,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@callback
|
||||
def _async_register_devices() -> None:
|
||||
"""Register the WebServer, System, and DHW via_device parents.
|
||||
|
||||
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, "")
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
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")},
|
||||
@@ -106,6 +120,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirzoneConfigEntry) -> b
|
||||
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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,20 +46,21 @@ 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." }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
# 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
|
||||
)
|
||||
# create persistent notification if we found a bridge version
|
||||
# with security vulnerability
|
||||
if (
|
||||
api.config.model_id == "BSB002"
|
||||
and api.config.software_version < "1935144040"
|
||||
is None
|
||||
):
|
||||
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,
|
||||
)
|
||||
_async_register_bridge_device(hass, entry, api, bridge.api_version)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
|
||||
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,22 +116,11 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show userform to user."""
|
||||
user_data_schema = (
|
||||
vol.Schema(
|
||||
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,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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,10 +98,14 @@ class LunatoneLight(
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
name=self._device.name,
|
||||
via_device=(
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +67,7 @@ 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:
|
||||
@@ -58,16 +75,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
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:
|
||||
if blind.device_type in DEVICE_TYPES_WIFI:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)},
|
||||
identifiers={(DOMAIN, blind.mac)},
|
||||
@@ -83,7 +91,11 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlind
|
||||
manufacturer=MANUFACTURER,
|
||||
model=blind.blind_type,
|
||||
name=device_name(blind),
|
||||
via_device=(DOMAIN, blind._gateway.mac), # noqa: SLF001
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user