Compare commits

...

38 Commits

Author SHA1 Message Date
farmio
8165cf0a22 Remove merge conflict leftovers 2026-03-11 00:25:58 +01:00
farmio
00eae1dd25 Account for empty strings in base entity 2026-03-10 23:48:31 +01:00
farmio
f1962e590a Also for entity name 2026-03-10 23:48:31 +01:00
farmio
da90eaad7b Move default value to schema 2026-03-10 23:48:30 +01:00
farmio
73c818a827 Use empty string as default in number 2026-03-10 23:48:30 +01:00
farmio
71640ceb52 Update schema.py 2026-03-10 23:48:30 +01:00
farmio
cce976a721 clean up name config for yaml entities 2026-03-10 23:48:30 +01:00
Erik Montnemery
cad8f97e97 Prevent network access in telegram_bot tests (#165284) 2026-03-10 21:53:35 +01:00
Jeef
4ae6099d84 Add local/cloud option to Intellifire (#162739)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-10 21:11:57 +01:00
epenet
60dc88fa15 Move NUT coordinator to separate module (#164848)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 09:13:00 -10:00
Josh Gustafson
2d2c6d676d Address Arcam FMJ post-merge feedback (#165277)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:09:54 +01:00
Matthias Alphart
f3879335ab KNX: add config for unit_of_measurement for yaml sensor entities (#165082) 2026-03-10 19:27:59 +01:00
Matthias Alphart
11bc00038e KNX: add config for device_class and unit_of_measurement for yaml number entities (#165083) 2026-03-10 19:27:48 +01:00
David Bonnes
6845e8b880 Extend RESET_SYSTEM action to all Evohome controller types (#164459) 2026-03-10 19:27:35 +01:00
cdheiser
5741016931 Bump pylutron version to 0.3.0 (#164707)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-10 18:10:28 +01:00
WardZhou
6cbc4e7f62 Add support for Thread Integration to Display Icons for Aeotec SmartThings TBRs (#165275) 2026-03-10 18:07:50 +01:00
Joost Lekkerkerker
4064df0114 Create reset HEPA filter button for main component in SmartThings (#165262) 2026-03-10 18:00:55 +01:00
Troels Schwarz-Linnet
789f850691 Implement 2 new sensors in pyvicare (#164523) 2026-03-10 17:59:36 +01:00
Abílio Costa
efca71852b Implement exception-translations for whirlpool integration (#165017) 2026-03-10 17:56:59 +01:00
A. Gideonse
1967e9f309 Add reconfiguration flow to Indevolt integration (#165132) 2026-03-10 17:43:19 +01:00
Artur Pragacz
6ac0c163aa Improve group entities (#160860) 2026-03-10 17:34:52 +01:00
Norbert Rittel
bbe20fd698 Improve descriptions of bond actions (#164744) 2026-03-10 17:08:23 +01:00
hanwg
f576743340 Fix proxy settings not applied for Telegram bot (#165240) 2026-03-10 16:42:46 +01:00
John O'Nolan
3b4a1fba5f Update Ghost integration quality scale to gold (#165215) 2026-03-10 16:25:15 +01:00
Artur Pragacz
1677a9bfa6 Add clean area intent for vacuum (#165182) 2026-03-10 16:24:18 +01:00
Jordan Harvey
0d9c458705 Anglian Water: Add last meter reading processed sensor (#159144)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-03-10 16:18:11 +01:00
epenet
57026a862d Ensure actions have name and description translations (#158243)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-10 16:06:51 +01:00
Josh Gustafson
fd05be4c52 Refactor Arcam FMJ to use coordinator pattern (#165232)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:37:09 +01:00
Dave Love
b1f038849e Add Midea Smart Inverter Window AC to Matter Fan Only mode list (#165170) 2026-03-10 15:28:09 +01:00
Ariel Ebersberger
b46c9ccc65 Influxdb: Add reconfigure flow (#165186) 2026-03-10 15:06:31 +01:00
epenet
80601426cf Move spotify coordinator to separate module (#164927) 2026-03-10 15:01:04 +01:00
Michael
9519bd2428 Add turned off and turned on triggers to input boolean (#158824)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-10 14:26:15 +01:00
Manu
be0b7f06a8 Bump pyrate-limiter to 4.0.2, PSNAWP to 3.0.3, python-roborock to 4.17.2 (#164133)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-03-10 13:54:37 +01:00
Joost Lekkerkerker
d30c6de168 Add another air purifier fixture to SmartThings (#165261) 2026-03-10 12:30:12 +01:00
Sab44
0fa666518e Dynamically add new devices to Libre Hardware Monitor (#165250) 2026-03-10 09:19:50 +01:00
Josef Zweck
cf454a1fa3 Bump onedrive-personal-sdk to 0.1.6 (#165219) 2026-03-10 09:13:07 +01:00
Panda-NZ
a36733c4dc Add ambient temperature range controls to ToGrill integration (#165235) 2026-03-09 23:40:30 +01:00
Bram Kragten
bf846e0756 Validate reorder is only used when multiple is true (#165216) 2026-03-09 22:32:02 +01:00
138 changed files with 7302 additions and 802 deletions

View File

@@ -342,6 +342,7 @@ homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from pyanglianwater.meter import SmartMeter
@@ -32,13 +33,14 @@ class AnglianWaterSensor(StrEnum):
YESTERDAY_WATER_COST = "yesterday_water_cost"
YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost"
LATEST_READING = "latest_reading"
LAST_UPDATED = "last_updated"
@dataclass(frozen=True, kw_only=True)
class AnglianWaterSensorEntityDescription(SensorEntityDescription):
"""Describes AnglianWater sensor entity."""
value_fn: Callable[[SmartMeter], float]
value_fn: Callable[[SmartMeter], float | datetime | None]
ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
@@ -76,6 +78,13 @@ ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
entity_category=EntityCategory.DIAGNOSTIC,
),
AnglianWaterSensorEntityDescription(
key=AnglianWaterSensor.LAST_UPDATED,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda entity: entity.last_updated,
translation_key=AnglianWaterSensor.LAST_UPDATED,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
@@ -112,6 +121,6 @@ class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
self.entity_description = description
@property
def native_value(self) -> float | None:
def native_value(self) -> float | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.smart_meter)

View File

@@ -34,6 +34,9 @@
},
"entity": {
"sensor": {
"last_updated": {
"name": "Last meter reading processed"
},
"latest_reading": {
"name": "Latest reading"
},

View File

@@ -8,19 +8,11 @@ from typing import Any
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
DEFAULT_SCAN_INTERVAL,
SIGNAL_CLIENT_DATA,
SIGNAL_CLIENT_STARTED,
SIGNAL_CLIENT_STOPPED,
)
type ArcamFmjConfigEntry = ConfigEntry[Client]
from .const import DEFAULT_SCAN_INTERVAL
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData
_LOGGER = logging.getLogger(__name__)
@@ -30,24 +22,41 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
"""Set up config entry."""
entry.runtime_data = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
coordinators: dict[int, ArcamFmjCoordinator] = {}
for zone in (1, 2):
coordinator = ArcamFmjCoordinator(hass, entry, client, zone)
coordinators[zone] = coordinator
entry.runtime_data = ArcamFmjRuntimeData(client, coordinators)
entry.async_create_background_task(
hass, _run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL), "arcam_fmj"
hass,
_run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL),
"arcam_fmj",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
"""Cleanup before removing config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> None:
async def _run_client(
hass: HomeAssistant,
runtime_data: ArcamFmjRuntimeData,
interval: float,
) -> None:
client = runtime_data.client
coordinators = runtime_data.coordinators
def _listen(_: Any) -> None:
async_dispatcher_send(hass, SIGNAL_CLIENT_DATA, client.host)
for coordinator in coordinators.values():
coordinator.async_notify_data_updated()
while True:
try:
@@ -55,16 +64,21 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N
await client.start()
_LOGGER.debug("Client connected %s", client.host)
async_dispatcher_send(hass, SIGNAL_CLIENT_STARTED, client.host)
try:
for coordinator in coordinators.values():
await coordinator.state.start()
with client.listen(_listen):
for coordinator in coordinators.values():
coordinator.async_notify_connected()
await client.process()
finally:
await client.stop()
_LOGGER.debug("Client disconnected %s", client.host)
async_dispatcher_send(hass, SIGNAL_CLIENT_STOPPED, client.host)
for coordinator in coordinators.values():
coordinator.async_notify_disconnected()
except ConnectionFailed:
await asyncio.sleep(interval)

View File

@@ -2,10 +2,6 @@
DOMAIN = "arcam_fmj"
SIGNAL_CLIENT_STARTED = "arcam.client_started"
SIGNAL_CLIENT_STOPPED = "arcam.client_stopped"
SIGNAL_CLIENT_DATA = "arcam.client_data"
EVENT_TURN_ON = "arcam_fmj.turn_on"
DEFAULT_PORT = 50000

View File

@@ -0,0 +1,76 @@
"""Coordinator for Arcam FMJ integration."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
@dataclass
class ArcamFmjRuntimeData:
"""Runtime data for Arcam FMJ integration."""
client: Client
coordinators: dict[int, ArcamFmjCoordinator]
type ArcamFmjConfigEntry = ConfigEntry[ArcamFmjRuntimeData]
class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a single Arcam FMJ zone."""
config_entry: ArcamFmjConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ArcamFmjConfigEntry,
client: Client,
zone: int,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Arcam FMJ zone {zone}",
)
self.client = client
self.state = State(client, zone)
self.last_update_success = False
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
try:
await self.state.update()
except ConnectionFailed as err:
raise UpdateFailed(
f"Connection failed during update for zone {self.state.zn}"
) from err
@callback
def async_notify_data_updated(self) -> None:
"""Notify that new data has been received from the device."""
self.async_set_updated_data(None)
@callback
def async_notify_connected(self) -> None:
"""Handle client connected."""
self.hass.async_create_task(self.async_refresh())
@callback
def async_notify_disconnected(self) -> None:
"""Handle client disconnected."""
self.last_update_success = False
self.async_update_listeners()

View File

@@ -8,7 +8,6 @@ import logging
from typing import Any
from arcam.fmj import ConnectionFailed, SourceCodes
from arcam.fmj.state import State
from homeassistant.components.media_player import (
BrowseError,
@@ -20,20 +19,14 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ArcamFmjConfigEntry
from .const import (
DOMAIN,
EVENT_TURN_ON,
SIGNAL_CLIENT_DATA,
SIGNAL_CLIENT_STARTED,
SIGNAL_CLIENT_STOPPED,
)
from .const import DOMAIN, EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -44,19 +37,17 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
client = config_entry.runtime_data
coordinators = config_entry.runtime_data.coordinators
async_add_entities(
[
ArcamFmj(
config_entry.title,
State(client, zone),
coordinators[zone],
config_entry.unique_id or config_entry.entry_id,
)
for zone in (1, 2)
],
True,
)
@@ -77,21 +68,21 @@ def convert_exception[**_P, _R](
return _convert_exception
class ArcamFmj(MediaPlayerEntity):
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
"""Representation of a media device."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
device_name: str,
state: State,
coordinator: ArcamFmjCoordinator,
uuid: str,
) -> None:
"""Initialize device."""
self._state = state
self._attr_name = f"Zone {state.zn}"
super().__init__(coordinator)
self._state = coordinator.state
self._attr_name = f"Zone {self._state.zn}"
self._attr_supported_features = (
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -102,10 +93,10 @@ class ArcamFmj(MediaPlayerEntity):
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.TURN_ON
)
if state.zn == 1:
if self._state.zn == 1:
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_unique_id = f"{uuid}-{state.zn}"
self._attr_entity_registry_enabled_default = state.zn == 1
self._attr_unique_id = f"{uuid}-{self._state.zn}"
self._attr_entity_registry_enabled_default = self._state.zn == 1
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, uuid),
@@ -122,49 +113,6 @@ class ArcamFmj(MediaPlayerEntity):
return MediaPlayerState.ON
return MediaPlayerState.OFF
async def async_added_to_hass(self) -> None:
"""Once registered, add listener for events."""
await self._state.start()
try:
await self._state.update()
except ConnectionFailed as connection:
_LOGGER.debug("Connection lost during addition: %s", connection)
@callback
def _data(host: str) -> None:
if host == self._state.client.host:
self.async_write_ha_state()
@callback
def _started(host: str) -> None:
if host == self._state.client.host:
self.async_schedule_update_ha_state(force_refresh=True)
@callback
def _stopped(host: str) -> None:
if host == self._state.client.host:
self.async_schedule_update_ha_state(force_refresh=True)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_DATA, _data)
)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STARTED, _started)
)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STOPPED, _stopped)
)
async def async_update(self) -> None:
"""Force update of state."""
_LOGGER.debug("Update state %s", self.name)
try:
await self._state.update()
except ConnectionFailed as connection:
_LOGGER.debug("Connection lost during update: %s", connection)
@convert_exception
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""

View File

@@ -147,6 +147,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"garage_door",
"humidifier",
"humidity",
"input_boolean",
"lawn_mower",
"light",
"lock",

View File

@@ -37,8 +37,8 @@
"name": "Entity"
},
"speed": {
"description": "Fan Speed as %.",
"name": "Fan Speed"
"description": "The fan speed as a percentage.",
"name": "Fan speed"
}
},
"name": "Set fan speed tracked state"
@@ -47,7 +47,7 @@
"description": "Sets the tracked brightness state of a Bond light.",
"fields": {
"brightness": {
"description": "Brightness.",
"description": "The tracked brightness of the light.",
"name": "Brightness"
},
"entity_id": {
@@ -79,22 +79,22 @@
"name": "Entity"
},
"power_state": {
"description": "Power state.",
"description": "The tracked power state.",
"name": "Power state"
}
},
"name": "Set switch power tracked state"
},
"start_decreasing_brightness": {
"description": "Starts decreasing the brightness of the light (deprecated).",
"description": "Starts decreasing the brightness of a light (deprecated).",
"name": "Start decreasing brightness"
},
"start_increasing_brightness": {
"description": "Starts increasing the brightness of the light (deprecated).",
"description": "Starts increasing the brightness of a light (deprecated).",
"name": "Start increasing brightness"
},
"stop": {
"description": "Stops any in-progress action and empty the queue (deprecated).",
"description": "Stops any in-progress action and empties the queue (deprecated).",
"name": "[%key:common::action::stop%]"
}
}

View File

@@ -363,10 +363,12 @@ class EvoController(EvoClimateEntity):
Data validation is not required, it will have been done upstream.
"""
if service == EvoService.SET_SYSTEM_MODE:
mode = data[ATTR_MODE]
else: # otherwise it is EvoService.RESET_SYSTEM
mode = EvoSystemMode.AUTO_WITH_RESET
if service == EvoService.RESET_SYSTEM:
await self.coordinator.call_client_api(self._evo_device.reset())
return
mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
if ATTR_PERIOD in data:
until = dt_util.start_of_local_day()

View File

@@ -27,7 +27,6 @@ from .coordinator import EvoDataUpdateCoordinator
# because supported modes can vary for edge-case systems
# Zone service schemas (registered as entity services)
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
@@ -47,7 +46,7 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
entity_domain=CLIMATE_DOMAIN,
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
schema=None,
func="async_clear_zone_override",
)
service.async_register_platform_entity_service(
@@ -79,7 +78,6 @@ def setup_service_functions(
@verify_domain_control(DOMAIN)
async def set_system_mode(call: ServiceCall) -> None:
"""Set the system mode."""
assert coordinator.tcs is not None # mypy
payload = {
"unique_id": coordinator.tcs.id,
@@ -91,18 +89,11 @@ def setup_service_functions(
assert coordinator.tcs is not None # mypy
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
# Enumerate which operating modes are supported by this system
modes = list(coordinator.tcs.allowed_system_modes)
# Not all systems support "AutoWithReset": register this handler only if required
if any(
m[SZ_SYSTEM_MODE]
for m in modes
if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET
):
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
system_mode_schemas = []
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioghost"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["aioghost==0.4.0"]
}

View File

@@ -7,7 +7,13 @@ from collections.abc import Callable, Collection, Mapping
import logging
from typing import Any
from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
ATTR_GROUP_ENTITIES,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -35,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
class GroupEntity(Entity):
"""Representation of a Group of entities."""
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID})
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES})
_attr_should_poll = False
_entity_ids: list[str]

View File

@@ -20,9 +20,6 @@ from homeassistant.const import (
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
@@ -32,6 +29,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.group import GenericGroup
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -117,47 +115,13 @@ class LockGroup(GroupEntity, LockEntity):
) -> None:
"""Initialize a lock group."""
self._entity_ids = entity_ids
self.group = GenericGroup(self, entity_ids)
self._attr_supported_features = LockEntityFeature.OPEN
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
async def async_lock(self, **kwargs: Any) -> None:
"""Forward the lock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
_LOGGER.debug("Forwarded lock command: %s", data)
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_LOCK,
data,
blocking=True,
context=self._context,
)
async def async_unlock(self, **kwargs: Any) -> None:
"""Forward the unlock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_UNLOCK,
data,
blocking=True,
context=self._context,
)
async def async_open(self, **kwargs: Any) -> None:
"""Forward the open command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_OPEN,
data,
blocking=True,
context=self._context,
)
@callback
def async_update_group_state(self) -> None:
"""Query all members and determine the lock group state."""

View File

@@ -51,6 +51,38 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the Indevolt device host."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
# Attempt to setup from user input
if user_input is not None:
errors, device_data = await self._async_validate_input(user_input)
if not errors and device_data:
await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER])
self._abort_if_unique_id_mismatch(reason="different_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: user_input[CONF_HOST],
**device_data,
},
)
# Retrieve user input (prefilled form)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_HOST): str}),
reconfigure_entry.data,
),
errors=errors,
)
async def _async_validate_input(
self, user_input: dict[str, Any]
) -> tuple[dict[str, str], dict[str, Any] | None]:

View File

@@ -77,8 +77,7 @@ rules:
status: todo
icon-translations:
status: todo
reconfiguration-flow:
status: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repair issues needed for current functionality

View File

@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "Failed to connect (aborted)"
"cannot_connect": "Failed to connect (aborted)",
"different_device": "The device at the new host has a different serial number. Please ensure the new host is the same device.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -10,6 +12,16 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::indevolt::config::step::user::data_description::host%]"
},
"description": "Update the connection details for your Indevolt device.",
"title": "Reconfigure Indevolt device"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

@@ -241,6 +241,104 @@ class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
entry = self._get_reconfigure_entry()
if entry.data[CONF_API_VERSION] == API_VERSION_2:
return await self.async_step_reconfigure_v2(user_input)
return await self.async_step_reconfigure_v1(user_input)
async def async_step_reconfigure_v1(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of InfluxDB v1."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
url = URL(user_input[CONF_URL])
data = {
CONF_API_VERSION: DEFAULT_API_VERSION,
CONF_HOST: url.host,
CONF_PORT: url.port,
CONF_USERNAME: user_input.get(CONF_USERNAME),
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
CONF_DB_NAME: user_input[CONF_DB_NAME],
CONF_SSL: url.scheme == "https",
CONF_PATH: url.path,
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
}
if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None:
path = await _save_uploaded_cert_file(self.hass, cert)
data[CONF_SSL_CA_CERT] = str(path)
elif CONF_SSL_CA_CERT in entry.data:
data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT]
errors = await _validate_influxdb_connection(self.hass, data)
if not errors:
title = f"{data[CONF_DB_NAME]} ({data[CONF_HOST]})"
return self.async_update_reload_and_abort(
entry, title=title, data_updates=data
)
suggested_values = dict(entry.data) | (user_input or {})
if user_input is None:
suggested_values[CONF_URL] = str(
URL.build(
scheme="https" if entry.data.get(CONF_SSL) else "http",
host=entry.data.get(CONF_HOST, ""),
port=entry.data.get(CONF_PORT),
path=entry.data.get(CONF_PATH, ""),
)
)
return self.async_show_form(
step_id="reconfigure_v1",
data_schema=self.add_suggested_values_to_schema(
INFLUXDB_V1_SCHEMA, suggested_values
),
errors=errors,
)
async def async_step_reconfigure_v2(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of InfluxDB v2."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
data = {
CONF_API_VERSION: API_VERSION_2,
CONF_URL: user_input[CONF_URL],
CONF_TOKEN: user_input[CONF_TOKEN],
CONF_ORG: user_input[CONF_ORG],
CONF_BUCKET: user_input[CONF_BUCKET],
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
}
if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None:
path = await _save_uploaded_cert_file(self.hass, cert)
data[CONF_SSL_CA_CERT] = str(path)
elif CONF_SSL_CA_CERT in entry.data:
data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT]
errors = await _validate_influxdb_connection(self.hass, data)
if not errors:
title = f"{data[CONF_BUCKET]} ({data[CONF_URL]})"
return self.async_update_reload_and_abort(
entry, title=title, data_updates=data
)
return self.async_show_form(
step_id="reconfigure_v2",
data_schema=self.add_suggested_values_to_schema(
INFLUXDB_V2_SCHEMA, entry.data | (user_input or {})
),
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle the initial step."""
import_data = {**import_data}

View File

@@ -3,6 +3,9 @@
"ssl_ca_cert": "SSL CA certificate (Optional)"
},
"config": {
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
@@ -46,6 +49,39 @@
"import": {
"title": "Import configuration"
},
"reconfigure_v1": {
"data": {
"database": "[%key:component::influxdb::config::step::configure_v1::data::database%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]",
"url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"database": "[%key:component::influxdb::config::step::configure_v1::data_description::database%]",
"ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v1::data_description::ssl_ca_cert%]"
},
"description": "Update the connection settings for your InfluxDB v1.x server.",
"title": "[%key:component::influxdb::config::step::configure_v1::title%]"
},
"reconfigure_v2": {
"data": {
"bucket": "[%key:component::influxdb::config::step::configure_v2::data::bucket%]",
"organization": "[%key:component::influxdb::config::step::configure_v2::data::organization%]",
"ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]",
"token": "[%key:common::config_flow::data::api_token%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"bucket": "[%key:component::influxdb::config::step::configure_v2::data_description::bucket%]",
"organization": "[%key:component::influxdb::config::step::configure_v2::data_description::organization%]",
"ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v2::data_description::ssl_ca_cert%]"
},
"description": "Update the connection settings for your InfluxDB v2.x / v3 server.",
"title": "[%key:component::influxdb::config::step::configure_v2::title%]"
},
"user": {
"menu_options": {
"configure_v1": "InfluxDB v1.x",

View File

@@ -20,5 +20,13 @@
"turn_on": {
"service": "mdi:toggle-switch"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted toggles to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::input_boolean::title%]",
@@ -17,6 +21,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads helpers from the YAML-configuration.",
@@ -35,5 +48,27 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Input boolean"
"title": "Input boolean",
"triggers": {
"turned_off": {
"description": "Triggers after one or more toggles turn off.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned off"
},
"turned_on": {
"description": "Triggers after one or more toggles turn on.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned on"
}
}
}

View File

@@ -0,0 +1,17 @@
"""Provides triggers for input booleans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for input booleans."""
return TRIGGERS

View File

@@ -0,0 +1,18 @@
.trigger_common: &trigger_common
target:
entity:
domain: input_boolean
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -6,6 +6,7 @@ import asyncio
from intellifire4py import UnifiedFireplace
from intellifire4py.cloud_interface import IntelliFireCloudInterface
from intellifire4py.const import IntelliFireApiMode
from intellifire4py.model import IntelliFireCommonFireplaceData
from homeassistant.const import (
@@ -20,6 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import (
API_MODE_LOCAL,
CONF_AUTH_COOKIE,
CONF_CONTROL_MODE,
CONF_READ_MODE,
@@ -55,8 +57,10 @@ def _construct_common_data(
serial=entry.data[CONF_SERIAL],
api_key=entry.data[CONF_API_KEY],
ip_address=entry.data[CONF_IP_ADDRESS],
read_mode=entry.options[CONF_READ_MODE],
control_mode=entry.options[CONF_CONTROL_MODE],
read_mode=IntelliFireApiMode(entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)),
control_mode=IntelliFireApiMode(
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
),
)
@@ -97,12 +101,34 @@ async def async_migrate_entry(
hass.config_entries.async_update_entry(
config_entry,
data=new,
options={CONF_READ_MODE: "local", CONF_CONTROL_MODE: "local"},
options={
CONF_READ_MODE: API_MODE_LOCAL,
CONF_CONTROL_MODE: API_MODE_LOCAL,
},
unique_id=new[CONF_SERIAL],
version=1,
minor_version=2,
minor_version=3,
)
LOGGER.debug("Pseudo Migration %s successful", config_entry.version)
LOGGER.debug("Migration to 1.3 successful")
if config_entry.minor_version < 3:
# Migrate old option keys (cloud_read, cloud_control) to new keys
old_options = config_entry.options
new_options = {
CONF_READ_MODE: old_options.get(
"cloud_read", old_options.get(CONF_READ_MODE, API_MODE_LOCAL)
),
CONF_CONTROL_MODE: old_options.get(
"cloud_control", old_options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
),
}
hass.config_entries.async_update_entry(
config_entry,
options=new_options,
version=1,
minor_version=3,
)
LOGGER.debug("Migration to 1.3 successful (options keys renamed)")
return True
@@ -139,9 +165,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_update_options(
hass: HomeAssistant, entry: IntellifireConfigEntry
) -> None:
"""Handle options update."""
coordinator: IntellifireDataUpdateCoordinator = entry.runtime_data
new_read_mode = IntelliFireApiMode(
entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
)
new_control_mode = IntelliFireApiMode(
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
)
fireplace = coordinator.fireplace
current_read_mode = fireplace.read_mode
current_control_mode = fireplace.control_mode
# Only update modes that actually changed
if new_read_mode != current_read_mode:
LOGGER.debug("Updating read mode: %s -> %s", current_read_mode, new_read_mode)
await fireplace.set_read_mode(new_read_mode)
if new_control_mode != current_control_mode:
LOGGER.debug(
"Updating control mode: %s -> %s", current_control_mode, new_control_mode
)
await fireplace.set_control_mode(new_control_mode)
# Refresh data with new mode settings
await coordinator.async_request_refresh()
async def _async_wait_for_initialization(
fireplace: UnifiedFireplace, timeout=STARTUP_TIMEOUT
):

View File

@@ -13,7 +13,12 @@ from intellifire4py.local_api import IntelliFireAPILocal
from intellifire4py.model import IntelliFireCommonFireplaceData
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
@@ -21,9 +26,12 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
API_MODE_CLOUD,
API_MODE_LOCAL,
CONF_AUTH_COOKIE,
CONF_CONTROL_MODE,
@@ -34,6 +42,7 @@ from .const import (
DOMAIN,
LOGGER,
)
from .coordinator import IntellifireConfigEntry
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
@@ -70,7 +79,7 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for IntelliFire."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
def __init__(self) -> None:
"""Initialize the Config Flow Handler."""
@@ -260,3 +269,85 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="not_intellifire_device")
return await self.async_step_cloud_api()
@staticmethod
@callback
def async_get_options_flow(config_entry: IntellifireConfigEntry) -> OptionsFlow:
"""Create the options flow."""
return IntelliFireOptionsFlowHandler()
class IntelliFireOptionsFlowHandler(OptionsFlow):
"""Options flow for IntelliFire component."""
config_entry: IntellifireConfigEntry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
# Validate connectivity for requested modes if runtime data is available
coordinator = self.config_entry.runtime_data
if coordinator is not None:
fireplace = coordinator.fireplace
# Refresh connectivity status before validating
await fireplace.async_validate_connectivity()
if (
user_input[CONF_READ_MODE] == API_MODE_LOCAL
and not fireplace.local_connectivity
):
errors[CONF_READ_MODE] = "local_unavailable"
if (
user_input[CONF_READ_MODE] == API_MODE_CLOUD
and not fireplace.cloud_connectivity
):
errors[CONF_READ_MODE] = "cloud_unavailable"
if (
user_input[CONF_CONTROL_MODE] == API_MODE_LOCAL
and not fireplace.local_connectivity
):
errors[CONF_CONTROL_MODE] = "local_unavailable"
if (
user_input[CONF_CONTROL_MODE] == API_MODE_CLOUD
and not fireplace.cloud_connectivity
):
errors[CONF_CONTROL_MODE] = "cloud_unavailable"
if not errors:
return self.async_create_entry(title="", data=user_input)
existing_read = self.config_entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
existing_control = self.config_entry.options.get(
CONF_CONTROL_MODE, API_MODE_LOCAL
)
cloud_local_options = selector.SelectSelectorConfig(
options=[API_MODE_LOCAL, API_MODE_CLOUD],
translation_key="api_mode",
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_READ_MODE,
default=user_input.get(CONF_READ_MODE, existing_read)
if user_input
else existing_read,
): selector.SelectSelector(cloud_local_options),
vol.Required(
CONF_CONTROL_MODE,
default=user_input.get(CONF_CONTROL_MODE, existing_control)
if user_input
else existing_control,
): selector.SelectSelector(cloud_local_options),
}
),
errors=errors,
)

View File

@@ -13,8 +13,8 @@ CONF_WEB_CLIENT_ID = "web_client_id" # part of the cloud cookie
CONF_AUTH_COOKIE = "auth_cookie" # part of the cloud cookie
CONF_SERIAL = "serial"
CONF_READ_MODE = "cloud_read"
CONF_CONTROL_MODE = "cloud_control"
CONF_READ_MODE = "read_mode"
CONF_CONTROL_MODE = "control_mode"
API_MODE_LOCAL = "local"

View File

@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import API_MODE_CLOUD, API_MODE_LOCAL
from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator
from .entity import IntellifireEntity
@@ -66,6 +67,22 @@ def _uptime_to_timestamp(
INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
IntellifireSensorEntityDescription(
key="read_mode",
translation_key="read_mode",
device_class=SensorDeviceClass.ENUM,
options=[API_MODE_LOCAL, API_MODE_CLOUD],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda coordinator: coordinator.fireplace.read_mode.value,
),
IntellifireSensorEntityDescription(
key="control_mode",
translation_key="control_mode",
device_class=SensorDeviceClass.ENUM,
options=[API_MODE_LOCAL, API_MODE_CLOUD],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda coordinator: coordinator.fireplace.control_mode.value,
),
IntellifireSensorEntityDescription(
key="flame_height",
translation_key="flame_height",

View File

@@ -100,6 +100,13 @@
"connection_quality": {
"name": "Connection quality"
},
"control_mode": {
"name": "Control mode",
"state": {
"cloud": "Cloud",
"local": "Local"
}
},
"downtime": {
"name": "Downtime"
},
@@ -115,6 +122,13 @@
"ipv4_address": {
"name": "IP address"
},
"read_mode": {
"name": "Read mode",
"state": {
"cloud": "Cloud",
"local": "Local"
}
},
"target_temp": {
"name": "Target temperature"
},
@@ -133,5 +147,29 @@
"name": "Pilot light"
}
}
},
"options": {
"error": {
"cloud_unavailable": "Cloud connectivity is not available",
"local_unavailable": "Local connectivity is not available"
},
"step": {
"init": {
"data": {
"control_mode": "Send commands to",
"read_mode": "Read data from"
},
"description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.",
"title": "Endpoint selection"
}
}
},
"selector": {
"api_mode": {
"options": {
"cloud": "Cloud",
"local": "Local"
}
}
}
}

View File

@@ -114,24 +114,26 @@ class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX binary sensor."""
self._device = XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[CONF_STATE_ADDRESS],
invert=config[CONF_INVERT],
sync_state=config[CONF_SYNC_STATE],
ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
reset_after=config.get(CONF_RESET_AFTER),
always_callback=True,
)
super().__init__(
knx_module=knx_module,
device=XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[CONF_STATE_ADDRESS],
invert=config[CONF_INVERT],
sync_state=config[CONF_SYNC_STATE],
ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
reset_after=config.get(CONF_RESET_AFTER),
always_callback=True,
),
unique_id=str(self._device.remote_value.group_address_state),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_force_update = self._device.ignore_internal_state
self._attr_unique_id = str(self._device.remote_value.group_address_state)
class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity):

View File

@@ -35,19 +35,18 @@ class KNXButton(KnxYamlEntity, ButtonEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX button."""
super().__init__(
knx_module=knx_module,
device=XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
),
self._device = XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
)
self._payload = config[CONF_PAYLOAD]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.remote_value.group_address}_{self._payload}"
super().__init__(
knx_module=knx_module,
unique_id=f"{self._device.remote_value.group_address}_{self._payload}",
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
async def async_press(self) -> None:

View File

@@ -119,7 +119,7 @@ async def async_setup_entry(
async_add_entities(entities)
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
def _create_climate_yaml(xknx: XKNX, config: ConfigType) -> XknxClimate:
"""Return a KNX Climate device to be used within XKNX."""
climate_mode = XknxClimateMode(
xknx,
@@ -646,9 +646,17 @@ class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
self._device = _create_climate_yaml(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
unique_id=(
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE]
fan_max_step = config[ClimateConf.FAN_MAX_STEP]
@@ -660,14 +668,6 @@ class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
fan_zero_mode=fan_zero_mode,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
class KnxUiClimate(_KnxClimate, KnxUiEntity):
"""Representation of a KNX climate device configured from the UI."""

View File

@@ -191,36 +191,34 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize the cover."""
self._device = XknxCover(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS),
group_address_position_state=config.get(
CoverSchema.CONF_POSITION_STATE_ADDRESS
),
group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
group_address_angle_state=config.get(CoverSchema.CONF_ANGLE_STATE_ADDRESS),
group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=config[CoverConf.INVERT_UPDOWN],
invert_position=config[CoverConf.INVERT_POSITION],
invert_angle=config[CoverConf.INVERT_ANGLE],
)
super().__init__(
knx_module=knx_module,
device=XknxCover(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS),
group_address_position_state=config.get(
CoverSchema.CONF_POSITION_STATE_ADDRESS
),
group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
group_address_angle_state=config.get(
CoverSchema.CONF_ANGLE_STATE_ADDRESS
),
group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=config[CoverConf.INVERT_UPDOWN],
invert_position=config[CoverConf.INVERT_POSITION],
invert_angle=config[CoverConf.INVERT_ANGLE],
unique_id=(
f"{self._device.updown.group_address}_"
f"{self._device.position_target.group_address}"
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self.init_base()
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.updown.group_address}_"
f"{self._device.position_target.group_address}"
)
if custom_device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = custom_device_class

View File

@@ -105,20 +105,21 @@ class KnxYamlDate(_KNXDate, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX date."""
self._device = XknxDateDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxDateDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiDate(_KNXDate, KnxUiEntity):

View File

@@ -110,20 +110,21 @@ class KnxYamlDateTime(_KNXDateTime, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX datetime."""
self._device = XknxDateTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxDateTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiDateTime(_KNXDateTime, KnxUiEntity):

View File

@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
from xknx.devices import Device as XknxDevice
from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import EntityPlatform
@@ -52,14 +52,11 @@ class _KnxEntityBase(Entity):
"""Representation of a KNX entity."""
_attr_should_poll = False
_attr_unique_id: str
_knx_module: KNXModule
_device: XknxDevice
@property
def name(self) -> str:
"""Return the name of the KNX device."""
return self._device.name
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -100,16 +97,23 @@ class _KnxEntityBase(Entity):
class KnxYamlEntity(_KnxEntityBase):
"""Representation of a KNX entity configured from YAML."""
def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
def __init__(
self,
knx_module: KNXModule,
unique_id: str,
name: str,
entity_category: EntityCategory | None,
) -> None:
"""Initialize the YAML entity."""
self._knx_module = knx_module
self._device = device
self._attr_name = name or None
self._attr_unique_id = unique_id
self._attr_entity_category = entity_category
class KnxUiEntity(_KnxEntityBase):
"""Representation of a KNX UI entity."""
_attr_unique_id: str
_attr_has_entity_name = True
def __init__(
@@ -117,6 +121,8 @@ class KnxUiEntity(_KnxEntityBase):
) -> None:
"""Initialize the UI entity."""
self._knx_module = knx_module
self._attr_name = entity_config[CONF_NAME]
self._attr_unique_id = unique_id
if entity_category := entity_config.get(CONF_ENTITY_CATEGORY):
self._attr_entity_category = EntityCategory(entity_category)

View File

@@ -208,35 +208,32 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanConf.MAX_STEP)
self._device = XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(FanSchema.CONF_OSCILLATION_ADDRESS),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(FanSchema.CONF_SWITCH_STATE_ADDRESS),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(
FanSchema.CONF_SWITCH_STATE_ADDRESS
),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
unique_id=(
str(self._device.speed.group_address)
if self._device.speed.group_address
else str(self._device.switch.group_address)
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
if self._device.speed.group_address:
self._attr_unique_id = str(self._device.speed.group_address)
else:
self._attr_unique_id = str(self._device.switch.group_address)
class KnxUiFan(_KnxFan, KnxUiEntity):

View File

@@ -558,15 +558,16 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX light."""
self._device = _create_yaml_light(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_yaml_light(knx_module.xknx, config),
unique_id=self._device_unique_id(),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = self._device_unique_id()
def _device_unique_id(self) -> str:
"""Return unique id for this device."""

View File

@@ -46,12 +46,13 @@ class KNXNotify(KnxYamlEntity, NotifyEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX notification."""
self._device = _create_notification_instance(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_notification_instance(knx_module.xknx, config),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a notification to knx bus."""

View File

@@ -109,17 +109,32 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX number."""
self._device = NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
unique_id=str(self._device.sensor_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
self._attr_device_class = config.get(
CONF_DEVICE_CLASS,
try_parse_enum(
# sensor device classes should, with some exceptions ("enum" etc.), align with number device classes
NumberDeviceClass,
dpt_info["sensor_device_class"],
),
)
self._attr_mode = config[CONF_MODE]
self._attr_native_max_value = config.get(
NumberConf.MAX,
self._device.sensor_value.dpt_class.value_max,
@@ -128,14 +143,15 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
NumberConf.MIN,
self._device.sensor_value.dpt_class.value_min,
)
self._attr_mode = config[CONF_MODE]
self._attr_native_step = config.get(
NumberConf.STEP,
self._device.sensor_value.dpt_class.resolution,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._attr_native_unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_info["unit"],
)
self._device.sensor_value.value = max(0, self._attr_native_min_value)

View File

@@ -83,18 +83,19 @@ class KnxYamlScene(_KnxScene, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize KNX scene."""
self._device = XknxScene(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
)
super().__init__(
knx_module=knx_module,
device=XknxScene(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
unique_id=(
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)

View File

@@ -20,7 +20,10 @@ from homeassistant.components.climate import FAN_OFF, HVACMode
from homeassistant.components.cover import (
DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA,
)
from homeassistant.components.number import NumberMode
from homeassistant.components.number import (
DEVICE_CLASSES_SCHEMA as NUMBER_DEVICE_CLASSES_SCHEMA,
NumberMode,
)
from homeassistant.components.sensor import (
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
@@ -39,6 +42,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_PAYLOAD,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
Platform,
)
@@ -195,16 +199,22 @@ class KNXPlatformSchema(ABC):
}
COMMON_ENTITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=""): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
class BinarySensorSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX binary sensors."""
PLATFORM = Platform.BINARY_SENSOR
DEFAULT_NAME = "KNX Binary Sensor"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_IGNORE_INTERNAL_STATE, default=False): cv.boolean,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
@@ -214,7 +224,6 @@ class BinarySensorSchema(KNXPlatformSchema):
),
vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_RESET_AFTER): cv.positive_float,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
)
@@ -226,7 +235,6 @@ class ButtonSchema(KNXPlatformSchema):
PLATFORM = Platform.BUTTON
CONF_VALUE = "value"
DEFAULT_NAME = "KNX Button"
payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`"
length_or_type_msg = (
@@ -234,9 +242,8 @@ class ButtonSchema(KNXPlatformSchema):
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_validator,
vol.Exclusive(
CONF_PAYLOAD, "payload_or_value", msg=payload_or_value_msg
@@ -250,7 +257,6 @@ class ButtonSchema(KNXPlatformSchema):
vol.Exclusive(
CONF_TYPE, "length_or_type", msg=length_or_type_msg
): object,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -318,7 +324,6 @@ class ClimateSchema(KNXPlatformSchema):
CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address"
CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address"
DEFAULT_NAME = "KNX Climate"
DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010"
DEFAULT_SETPOINT_SHIFT_MAX = 6
DEFAULT_SETPOINT_SHIFT_MIN = -6
@@ -327,9 +332,8 @@ class ClimateSchema(KNXPlatformSchema):
DEFAULT_FAN_SPEED_MODE = "percent"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(
ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
): vol.All(int, vol.Range(min=0, max=32)),
@@ -395,7 +399,6 @@ class ClimateSchema(KNXPlatformSchema):
): vol.Coerce(HVACMode),
vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float),
vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte,
@@ -429,12 +432,10 @@ class CoverSchema(KNXPlatformSchema):
CONF_ANGLE_STATE_ADDRESS = "angle_state_address"
DEFAULT_TRAVEL_TIME = 25
DEFAULT_NAME = "KNX Cover"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_MOVE_LONG_ADDRESS): ga_list_validator,
vol.Optional(CONF_MOVE_SHORT_ADDRESS): ga_list_validator,
vol.Optional(CONF_STOP_ADDRESS): ga_list_validator,
@@ -452,7 +453,6 @@ class CoverSchema(KNXPlatformSchema):
vol.Optional(CoverConf.INVERT_POSITION, default=False): cv.boolean,
vol.Optional(CoverConf.INVERT_ANGLE, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -477,16 +477,12 @@ class DateSchema(KNXPlatformSchema):
PLATFORM = Platform.DATE
DEFAULT_NAME = "KNX Date"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -496,16 +492,12 @@ class DateTimeSchema(KNXPlatformSchema):
PLATFORM = Platform.DATETIME
DEFAULT_NAME = "KNX DateTime"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -567,12 +559,9 @@ class FanSchema(KNXPlatformSchema):
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
@@ -580,7 +569,6 @@ class FanSchema(KNXPlatformSchema):
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
}
),
@@ -625,7 +613,6 @@ class LightSchema(KNXPlatformSchema):
CONF_MIN_KELVIN = "min_kelvin"
CONF_MAX_KELVIN = "max_kelvin"
DEFAULT_NAME = "KNX Light"
DEFAULT_COLOR_TEMP_MODE = "absolute"
DEFAULT_MIN_KELVIN = 2700 # 370 mireds
DEFAULT_MAX_KELVIN = 6000 # 166 mireds
@@ -657,9 +644,8 @@ class LightSchema(KNXPlatformSchema):
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_BRIGHTNESS_ADDRESS): ga_list_validator,
@@ -709,7 +695,6 @@ class LightSchema(KNXPlatformSchema):
vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -755,14 +740,10 @@ class NotifySchema(KNXPlatformSchema):
PLATFORM = Platform.NOTIFY
DEFAULT_NAME = "KNX Notify"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Required(KNX_ADDRESS): ga_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -771,12 +752,10 @@ class NumberSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX numbers."""
PLATFORM = Platform.NUMBER
DEFAULT_NAME = "KNX Number"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_MODE, default=NumberMode.AUTO): vol.Coerce(
NumberMode
@@ -787,7 +766,8 @@ class NumberSchema(KNXPlatformSchema):
vol.Optional(NumberConf.MAX): vol.Coerce(float),
vol.Optional(NumberConf.MIN): vol.Coerce(float),
vol.Optional(NumberConf.STEP): cv.positive_float,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_DEVICE_CLASS): NUMBER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
),
_number_limit_sub_validator,
@@ -801,15 +781,12 @@ class SceneSchema(KNXPlatformSchema):
CONF_SCENE_NUMBER = "scene_number"
DEFAULT_NAME = "KNX SCENE"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Required(SceneConf.SCENE_NUMBER): vol.All(
vol.Coerce(int), vol.Range(min=1, max=64)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -821,12 +798,10 @@ class SelectSchema(KNXPlatformSchema):
CONF_OPTION = "option"
CONF_OPTIONS = "options"
DEFAULT_NAME = "KNX Select"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Required(CONF_PAYLOAD_LENGTH): vol.All(
@@ -840,7 +815,6 @@ class SelectSchema(KNXPlatformSchema):
],
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
select_options_sub_validator,
@@ -855,19 +829,17 @@ class SensorSchema(KNXPlatformSchema):
CONF_ALWAYS_CALLBACK = "always_callback"
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_SYNC_STATE = CONF_SYNC_STATE
DEFAULT_NAME = "KNX Sensor"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean,
vol.Optional(CONF_SENSOR_STATE_CLASS): STATE_CLASSES_SCHEMA,
vol.Required(CONF_TYPE): sensor_type_validator,
vol.Required(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
}
),
_sensor_attribute_sub_validator,
@@ -882,16 +854,13 @@ class SwitchSchema(KNXPlatformSchema):
CONF_INVERT = CONF_INVERT
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
DEFAULT_NAME = "KNX Switch"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SWITCH_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -901,17 +870,13 @@ class TextSchema(KNXPlatformSchema):
PLATFORM = Platform.TEXT
DEFAULT_NAME = "KNX Text"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Optional(CONF_MODE, default=TextMode.TEXT): vol.Coerce(TextMode),
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -921,16 +886,12 @@ class TimeSchema(KNXPlatformSchema):
PLATFORM = Platform.TIME
DEFAULT_NAME = "KNX Time"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -955,27 +916,21 @@ class WeatherSchema(KNXPlatformSchema):
CONF_KNX_AIR_PRESSURE_ADDRESS = "address_air_pressure"
CONF_KNX_HUMIDITY_ADDRESS = "address_humidity"
DEFAULT_NAME = "KNX Weather Station"
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_list_validator,
}
)

View File

@@ -65,9 +65,12 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX select."""
self._device = _create_raw_value(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_raw_value(knx_module.xknx, config),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._option_payloads: dict[str, int] = {
option[SelectSchema.CONF_OPTION]: option[CONF_PAYLOAD]
@@ -75,8 +78,6 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
}
self._attr_options = list(self._option_payloads)
self._attr_current_option = None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_added_to_hass(self) -> None:
"""Restore last state."""

View File

@@ -202,34 +202,37 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
self._device = XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.sensor_value.group_address_state),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
self._attr_device_class = dpt_info["sensor_device_class"]
self._attr_state_class = (
config.get(CONF_STATE_CLASS) or dpt_info["sensor_state_class"]
self._attr_device_class = config.get(
CONF_DEVICE_CLASS,
dpt_info["sensor_device_class"],
)
self._attr_native_unit_of_measurement = dpt_info["unit"]
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_extra_state_attributes = {}
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_native_unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_info["unit"],
)
self._attr_state_class = config.get(
CONF_STATE_CLASS,
dpt_info["sensor_state_class"],
)
class KnxUiSensor(_KnxSensor, KnxUiEntity):

View File

@@ -107,20 +107,21 @@ class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX switch."""
self._device = XknxSwitch(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
)
super().__init__(
knx_module=knx_module,
device=XknxSwitch(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
),
unique_id=str(self._device.switch.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_unique_id = str(self._device.switch.group_address)
class KnxUiSwitch(_KnxSwitch, KnxUiEntity):

View File

@@ -112,20 +112,21 @@ class KnxYamlText(_KnxText, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX text."""
self._device = XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_mode = config[CONF_MODE]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiText(_KnxText, KnxUiEntity):

View File

@@ -105,20 +105,21 @@ class KnxYamlTime(_KNXTime, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX time."""
self._device = XknxTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiTime(_KNXTime, KnxUiEntity):

View File

@@ -85,12 +85,13 @@ class KNXWeather(KnxYamlEntity, WeatherEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
self._device = _create_weather(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_weather(knx_module.xknx, config),
unique_id=str(self._device._temperature.group_address_state), # noqa: SLF001
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_unique_id = str(self._device._temperature.group_address_state) # noqa: SLF001
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
@property
def native_temperature(self) -> float | None:

View File

@@ -62,7 +62,9 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
registry=dr.async_get(self.hass), config_entry_id=self._entry_id
)
self._previous_devices: dict[DeviceId, DeviceName] = {
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
DeviceId(
next(iter(device.identifiers))[1].removeprefix(f"{self._entry_id}_")
): DeviceName(device.name)
for device in device_entries
if device.identifiers and device.name
}
@@ -109,11 +111,6 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
self, detected_devices: dict[DeviceId, DeviceName]
) -> None:
"""Handle device changes by deleting devices from / adding devices to Home Assistant."""
detected_devices = {
DeviceId(f"{self.config_entry.entry_id}_{detected_id}"): device_name
for detected_id, device_name in detected_devices.items()
}
previous_device_ids = set(self._previous_devices.keys())
detected_device_ids = set(detected_devices.keys())
@@ -131,25 +128,14 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
device_registry = dr.async_get(self.hass)
for device_id in orphaned_devices:
if device := device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
identifiers={(DOMAIN, f"{self._entry_id}_{device_id}")}
):
_LOGGER.debug(
"Removing device: %s", self._previous_devices[device_id]
)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
remove_config_entry_id=self._entry_id,
)
if self.data is None:
# initial update during integration startup
self._previous_devices = detected_devices # type: ignore[unreachable]
return
if new_devices := detected_device_ids - previous_device_ids:
_LOGGER.warning(
"New Device(s) detected, reload integration to add them to Home Assistant: %s",
[detected_devices[DeviceId(device_id)] for device_id in new_devices],
)
self._previous_devices = detected_devices

View File

@@ -2,9 +2,10 @@
from __future__ import annotations
import logging
from typing import Any
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
from librehardwaremonitor_api.model import DeviceId, LibreHardwareMonitorSensorData
from librehardwaremonitor_api.sensor_type import SensorType
from homeassistant.components.sensor import SensorEntity, SensorStateClass
@@ -16,6 +17,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
STATE_MIN_VALUE = "min_value"
@@ -30,10 +33,28 @@ async def async_setup_entry(
"""Set up the LibreHardwareMonitor platform."""
lhm_coordinator = config_entry.runtime_data
async_add_entities(
LibreHardwareMonitorSensor(lhm_coordinator, config_entry.entry_id, sensor_data)
for sensor_data in lhm_coordinator.data.sensor_data.values()
)
known_devices: set[DeviceId] = set()
def _check_device() -> None:
current_devices = set(lhm_coordinator.data.main_device_ids_and_names)
new_devices = current_devices - known_devices
if new_devices:
_LOGGER.debug("New Device(s) detected, adding: %s", new_devices)
known_devices.update(new_devices)
new_devices_sensor_data = [
sensor_data
for sensor_data in lhm_coordinator.data.sensor_data.values()
if sensor_data.device_id in new_devices
]
async_add_entities(
LibreHardwareMonitorSensor(
lhm_coordinator, config_entry.entry_id, sensor_data
)
for sensor_data in new_devices_sensor_data
)
_check_device()
config_entry.async_on_unload(lhm_coordinator.async_add_listener(_check_device))
class LibreHardwareMonitorSensor(

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
import logging
from typing import Any, cast
from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output
@@ -42,7 +43,7 @@ class LutronData:
covers: list[tuple[str, Output]]
fans: list[tuple[str, Output]]
lights: list[tuple[str, Output]]
scenes: list[tuple[str, Keypad, Button, Led]]
scenes: list[tuple[str, Keypad, Button, Led | None]]
switches: list[tuple[str, Output]]
@@ -110,6 +111,14 @@ async def async_setup_entry(
)
for keypad in area.keypads:
_async_check_keypad_identifiers(
hass,
device_registry,
keypad.id,
keypad.uuid,
keypad.legacy_uuid,
entry_data.client.guid,
)
for button in keypad.buttons:
# If the button has a function assigned to it, add it as a scene
if button.name != "Unknown Button" and button.button_type in (
@@ -226,6 +235,36 @@ def _async_check_device_identifiers(
)
def _async_check_keypad_identifiers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
keypad_id: int,
uuid: str,
legacy_uuid: str,
controller_guid: str,
) -> None:
"""Migrate from integer based keypad.ids to proper uuids."""
# First check for the very old integer-based ID
# We use cast(Any, ...) here because legacy devices may have integer identifiers
# in the registry, but modern Home Assistant expects strings.
device = device_registry.async_get_device(
identifiers={(DOMAIN, cast(Any, keypad_id))}
)
if device:
new_unique_id = f"{controller_guid}_{uuid or legacy_uuid}"
_LOGGER.debug("Updating keypad id from %d to %s", keypad_id, new_unique_id)
device_registry.async_update_device(
device.id, new_identifiers={(DOMAIN, new_unique_id)}
)
return
# Now handle legacy_uuid to uuid migration if needed
_async_check_device_identifiers(
hass, device_registry, uuid, legacy_uuid, controller_guid
)
async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool:
"""Clean up resources and entities associated with the integration."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -37,11 +37,12 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
ip_address = user_input[CONF_HOST]
guid: str | None = None
main_repeater = Lutron(
ip_address,
user_input.get(CONF_USERNAME),
user_input.get(CONF_PASSWORD),
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
try:
@@ -55,10 +56,11 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
else:
guid = main_repeater.guid
if len(guid) <= 10:
if guid is None or len(guid) <= 10:
errors["base"] = "cannot_connect"
if not errors:
assert guid is not None
await self.async_set_unique_id(guid)
self._abort_if_unique_id_configured()

View File

@@ -75,7 +75,7 @@ class LutronCover(LutronDevice, CoverEntity):
"""Update the state attributes."""
level = self._lutron_device.last_level()
self._attr_is_closed = level < 1
self._attr_current_cover_position = level
self._attr_current_cover_position = int(level)
_LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level)
@property

View File

@@ -43,10 +43,8 @@ class LutronBaseEntity(Entity):
@property
def unique_id(self) -> str:
"""Return a unique ID."""
if self._lutron_device.uuid is None:
return f"{self._controller.guid}_{self._lutron_device.legacy_uuid}"
return f"{self._controller.guid}_{self._lutron_device.uuid}"
device_uuid = self._lutron_device.uuid or self._lutron_device.legacy_uuid
return f"{self._controller.guid}_{device_uuid}"
def update(self) -> None:
"""Update the entity's state."""
@@ -83,8 +81,9 @@ class LutronKeypad(LutronBaseEntity):
) -> None:
"""Initialize the device."""
super().__init__(area_name, lutron_device, controller)
device_uuid = keypad.uuid or keypad.legacy_uuid
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, keypad.id)},
identifiers={(DOMAIN, f"{controller.guid}_{device_uuid}")},
manufacturer="Lutron",
name=keypad.name,
)

View File

@@ -1,8 +1,9 @@
"""Support for Lutron events."""
from enum import StrEnum
from typing import cast
from pylutron import Button, Keypad, Lutron, LutronEvent
from pylutron import Button, Keypad, Lutron, LutronEntity, LutronEvent
from homeassistant.components.event import EventEntity
from homeassistant.const import ATTR_ID
@@ -78,9 +79,10 @@ class LutronEventEntity(LutronKeypad, EventEntity):
@callback
def handle_event(
self, button: Button, _context: None, event: LutronEvent, _params: dict
self, button: LutronEntity, _context: None, event: LutronEvent, _params: dict
) -> None:
"""Handle received event."""
button = cast(Button, button)
action: LutronEventType | None = None
if self._has_release_event:
if event == Button.Event.PRESSED:

View File

@@ -83,7 +83,7 @@ class LutronFan(LutronDevice, FanEntity):
def _update_attrs(self) -> None:
"""Update the state attributes."""
level = self._lutron_device.last_level()
level = int(self._lutron_device.last_level())
self._attr_is_on = level > 0
self._attr_percentage = level
if self._prev_percentage is None or level != 0:

View File

@@ -45,12 +45,12 @@ async def async_setup_entry(
)
def to_lutron_level(level):
def to_lutron_level(level: int) -> float:
"""Convert the given Home Assistant light level (0-255) to Lutron (0.0-100.0)."""
return float((level * 100) / 255)
def to_hass_level(level):
def to_hass_level(level: float) -> int:
"""Convert the given Lutron (0.0-100.0) light level to Home Assistant (0-255)."""
return int((level * 255) / 100)

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pylutron"],
"requirements": ["pylutron==0.2.18"],
"requirements": ["pylutron==0.3.0"],
"single_config_entry": true
}

View File

@@ -87,11 +87,11 @@ class LutronLed(LutronKeypad, SwitchEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the LED on."""
self._lutron_device.state = 1
self._lutron_device.state = True
def turn_off(self, **kwargs: Any) -> None:
"""Turn the LED off."""
self._lutron_device.state = 0
self._lutron_device.state = False
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:

View File

@@ -124,6 +124,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
# support fan-only mode.
(0x0001, 0x0108),
(0x0001, 0x010A),
(0x118C, 0x2022),
(0x1209, 0x8000),
(0x1209, 0x8001),
(0x1209, 0x8002),

View File

@@ -3,13 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from aionut import AIONUTClient, NUTError, NUTLoginError
from aionut import AIONUTClient, NUTError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ALIAS,
CONF_HOST,
@@ -21,29 +19,17 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS
from .coordinator import NutConfigEntry, NutCoordinator, NutRuntimeData
NUT_FAKE_SERIAL = ["unknown", "blank"]
_LOGGER = logging.getLogger(__name__)
type NutConfigEntry = ConfigEntry[NutRuntimeData]
@dataclass
class NutRuntimeData:
"""Runtime data definition."""
coordinator: DataUpdateCoordinator
data: PyNUTData
unique_id: str
user_available_commands: set[str]
async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
"""Set up Network UPS Tools (NUT) from a config entry."""
@@ -73,36 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
entry.async_on_unload(data.async_shutdown)
async def async_update_data() -> dict[str, str]:
"""Fetch data from NUT."""
try:
return await data.async_update()
except NUTLoginError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="device_authentication",
translation_placeholders={
"err": str(err),
},
) from err
except NUTError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="data_fetch_error",
translation_placeholders={
"err": str(err),
},
) from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name="NUT resource status",
update_method=async_update_data,
update_interval=timedelta(seconds=60),
always_update=False,
)
coordinator = NutCoordinator(hass, data, entry)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()

View File

@@ -12,7 +12,7 @@ from homeassistant.components.button import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NutConfigEntry
from .coordinator import NutConfigEntry
from .entity import NUTBaseEntity
_LOGGER = logging.getLogger(__name__)

View File

@@ -0,0 +1,79 @@
"""The NUT coordinator."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from aionut import NUTError, NUTLoginError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
if TYPE_CHECKING:
from . import PyNUTData
_LOGGER = logging.getLogger(__name__)
@dataclass
class NutRuntimeData:
"""Runtime data definition."""
coordinator: NutCoordinator
data: PyNUTData
unique_id: str
user_available_commands: set[str]
type NutConfigEntry = ConfigEntry[NutRuntimeData]
class NutCoordinator(DataUpdateCoordinator[dict[str, str]]):
"""Coordinator for NUT data."""
config_entry: NutConfigEntry
def __init__(
self,
hass: HomeAssistant,
data: PyNUTData,
config_entry: NutConfigEntry,
) -> None:
"""Initialize NUT coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="NUT resource status",
update_interval=timedelta(seconds=60),
always_update=False,
)
self._data = data
async def _async_update_data(self) -> dict[str, str]:
"""Fetch data from NUT."""
try:
return await self._data.async_update()
except NUTLoginError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="device_authentication",
translation_placeholders={
"err": str(err),
},
) from err
except NUTError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="data_fetch_error",
translation_placeholders={
"err": str(err),
},
) from err

View File

@@ -13,8 +13,8 @@ from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from . import NutConfigEntry, NutRuntimeData
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS
from .coordinator import NutConfigEntry, NutRuntimeData
ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS}

View File

@@ -11,8 +11,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import NutConfigEntry
from .const import DOMAIN
from .coordinator import NutConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}

View File

@@ -13,13 +13,11 @@ from homeassistant.const import (
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import PyNUTData
from .const import DOMAIN
from .coordinator import NutCoordinator
NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
"manufacturer": ATTR_MANUFACTURER,
@@ -29,14 +27,14 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
}
class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
class NUTBaseEntity(CoordinatorEntity[NutCoordinator]):
"""NUT base entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: DataUpdateCoordinator,
coordinator: NutCoordinator,
entity_description: EntityDescription,
data: PyNUTData,
unique_id: str,

View File

@@ -25,8 +25,8 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NutConfigEntry
from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES
from .coordinator import NutConfigEntry
from .entity import NUTBaseEntity
# Coordinator is used to centralize the data updates

View File

@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NutConfigEntry
from .coordinator import NutConfigEntry
from .entity import NUTBaseEntity
_LOGGER = logging.getLogger(__name__)

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.5"]
"requirements": ["onedrive-personal-sdk==0.1.6"]
}

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.5"]
"requirements": ["onedrive-personal-sdk==0.1.6"]
}

View File

@@ -90,5 +90,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["PSNAWP==3.0.1", "pyrate-limiter==3.9.0"]
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.0.2"]
}

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.17.1",
"python-roborock==4.17.2",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -22,7 +22,7 @@ class SmartThingsButtonDescription(ButtonEntityDescription):
key: Capability
command: Command
component: str = MAIN
components: list[str] | None = None
CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = {
@@ -48,7 +48,7 @@ CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] =
translation_key="reset_hepa_filter",
command=Command.RESET_HEPA_FILTER,
entity_category=EntityCategory.DIAGNOSTIC,
component="station",
components=[MAIN, "station"],
),
}
@@ -61,11 +61,11 @@ async def async_setup_entry(
"""Add button entities for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsButtonEntity(entry_data.client, device, description)
SmartThingsButtonEntity(entry_data.client, device, description, component)
for capability, description in CAPABILITIES_TO_BUTTONS.items()
for device in entry_data.devices.values()
if description.component in device.status
and capability in device.status[description.component]
for component in description.components or [MAIN]
if component in device.status and capability in device.status[component]
)
@@ -79,11 +79,12 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
client: SmartThings,
device: FullDevice,
entity_description: SmartThingsButtonDescription,
component: str,
) -> None:
"""Initialize the instance."""
super().__init__(client, device, set(), component=entity_description.component)
super().__init__(client, device, set(), component=component)
self.entity_description = entity_description
self._attr_unique_id = f"{device.device.device_id}_{entity_description.component}_{entity_description.key}_{entity_description.command}"
self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.command}"
async def async_press(self) -> None:
"""Press the button."""

View File

@@ -2,11 +2,10 @@
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
import aiohttp
from spotifyaio import Device, SpotifyClient, SpotifyConnectionError
from spotifyaio import SpotifyClient
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
@@ -17,12 +16,15 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .browse_media import async_browse_media
from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES
from .coordinator import SpotifyConfigEntry, SpotifyCoordinator
from .models import SpotifyData
from .const import DOMAIN, SPOTIFY_SCOPES
from .coordinator import (
SpotifyConfigEntry,
SpotifyCoordinator,
SpotifyData,
SpotifyDeviceCoordinator,
)
from .util import (
is_spotify_media_type,
resolve_spotify_media_type,
@@ -73,20 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b
await coordinator.async_config_entry_first_refresh()
async def _update_devices() -> list[Device]:
try:
return await spotify.get_devices()
except SpotifyConnectionError as err:
raise UpdateFailed from err
device_coordinator: DataUpdateCoordinator[list[Device]] = DataUpdateCoordinator(
hass,
LOGGER,
name=f"{entry.title} Devices",
config_entry=entry,
update_interval=timedelta(minutes=5),
update_method=_update_devices,
)
device_coordinator = SpotifyDeviceCoordinator(hass, entry, spotify)
await device_coordinator.async_config_entry_first_refresh()
entry.runtime_data = SpotifyData(coordinator, session, device_coordinator)

View File

@@ -3,10 +3,10 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING
from spotifyaio import (
ContextType,
Device,
PlaybackState,
Playlist,
SpotifyClient,
@@ -19,21 +19,28 @@ from spotifyaio import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
if TYPE_CHECKING:
from .models import SpotifyData
_LOGGER = logging.getLogger(__name__)
type SpotifyConfigEntry = ConfigEntry[SpotifyData]
@dataclass
class SpotifyData:
"""Class to hold Spotify data."""
coordinator: SpotifyCoordinator
session: OAuth2Session
devices: SpotifyDeviceCoordinator
UPDATE_INTERVAL = timedelta(seconds=30)
FREE_API_BLOGPOST = (
@@ -164,3 +171,31 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
playlist=self._playlist,
dj_playlist=dj_playlist,
)
class SpotifyDeviceCoordinator(DataUpdateCoordinator[list[Device]]):
"""Class to manage fetching Spotify data."""
config_entry: SpotifyConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: SpotifyConfigEntry,
client: SpotifyClient,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{config_entry.title} Devices",
update_interval=timedelta(minutes=5),
)
self._client = client
async def _async_update_data(self) -> list[Device]:
try:
return await self._client.get_devices()
except SpotifyConnectionError as err:
raise UpdateFailed from err

View File

@@ -9,7 +9,6 @@ import logging
from typing import TYPE_CHECKING, Any, Concatenate
from spotifyaio import (
Device,
Episode,
Item,
ItemType,
@@ -32,7 +31,6 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .browse_media import async_browse_media_internal
from .const import (
@@ -40,7 +38,11 @@ from .const import (
MEDIA_TYPE_USER_SAVED_TRACKS,
PLAYABLE_MEDIA_TYPES,
)
from .coordinator import SpotifyConfigEntry, SpotifyCoordinator
from .coordinator import (
SpotifyConfigEntry,
SpotifyCoordinator,
SpotifyDeviceCoordinator,
)
from .entity import SpotifyEntity
_LOGGER = logging.getLogger(__name__)
@@ -122,7 +124,7 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
def __init__(
self,
coordinator: SpotifyCoordinator,
device_coordinator: DataUpdateCoordinator[list[Device]],
device_coordinator: SpotifyDeviceCoordinator,
) -> None:
"""Initialize."""
super().__init__(coordinator)

View File

@@ -1,19 +0,0 @@
"""Models for use in Spotify integration."""
from dataclasses import dataclass
from spotifyaio import Device
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .coordinator import SpotifyCoordinator
@dataclass
class SpotifyData:
"""Class to hold Spotify data."""
coordinator: SpotifyCoordinator
session: OAuth2Session
devices: DataUpdateCoordinator[list[Device]]

View File

@@ -1028,12 +1028,14 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) ->
read_timeout=read_timeout,
media_write_timeout=media_write_timeout,
)
get_updates_request = HTTPXRequest(proxy=proxy)
else:
request = HTTPXRequest(
connection_pool_size=8,
read_timeout=read_timeout,
media_write_timeout=media_write_timeout,
)
get_updates_request = None
base_url: str = p_config[CONF_API_ENDPOINT]
@@ -1042,6 +1044,7 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) ->
base_url=f"{base_url}/bot",
base_file_url=f"{base_url}/file/bot",
request=request,
get_updates_request=get_updates_request,
)

View File

@@ -790,7 +790,6 @@ edit_message:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -843,7 +842,6 @@ edit_message_media:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -922,7 +920,6 @@ edit_caption:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -960,7 +957,6 @@ edit_replymarkup:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -1015,7 +1011,6 @@ delete_message:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -1042,7 +1037,6 @@ leave_chat:
filter:
domain: notify
integration: telegram_bot
reorder: true
advanced:
collapsed: true
fields:
@@ -1064,7 +1058,6 @@ set_message_reaction:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: 54321

View File

@@ -36,6 +36,7 @@ KNOWN_BRANDS: dict[str | None, str] = {
"Nanoleaf": "nanoleaf",
"OpenThread": "openthread",
"Samsung": "samsung",
"SmartThings": "smartthings",
}
THREAD_TYPE = "_meshcop._udp.local."
CLASS_IN = 1

View File

@@ -32,7 +32,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_PROBE_COUNT, DOMAIN
from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, DOMAIN
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
@@ -213,6 +213,8 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
await client.request(PacketA1Notify)
for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1):
await client.write(PacketA8Write(probe=probe))
if self.config_entry.data.get(CONF_HAS_AMBIENT):
await client.write(PacketA8Write(probe=0))
except BleakError as exc:
raise DeviceFailed(f"Device failed {exc}") from exc
return self.data

View File

@@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ToGrillConfigEntry
from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .coordinator import ToGrillCoordinator
from .entity import ToGrillEntity
@@ -123,12 +123,64 @@ def _get_temperature_descriptions(
)
def _get_ambient_temperatures(
coordinator: ToGrillCoordinator, alarm_type: AlarmType
) -> tuple[float | None, float | None]:
if not (packet := coordinator.get_packet(PacketA8Notify, 0)):
return None, None
if packet.alarm_type != alarm_type:
return None, None
return packet.temperature_1, packet.temperature_2
ENTITY_DESCRIPTIONS = (
*[
description
for probe_number in range(1, MAX_PROBE_COUNT + 1)
for description in _get_temperature_descriptions(probe_number)
],
ToGrillNumberEntityDescription(
key="ambient_temperature_minimum",
translation_key="ambient_temperature_minimum",
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=0,
native_max_value=400,
mode=NumberMode.BOX,
icon="mdi:thermometer-chevron-down",
set_packet=lambda coordinator, value: PacketA300Write(
probe=0,
minimum=None if value == 0.0 else value,
maximum=_get_ambient_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)[
1
],
),
get_value=lambda x: _get_ambient_temperatures(x, AlarmType.TEMPERATURE_RANGE)[
0
],
entity_supported=lambda x: x.get(CONF_HAS_AMBIENT, False),
),
ToGrillNumberEntityDescription(
key="ambient_temperature_maximum",
translation_key="ambient_temperature_maximum",
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
native_min_value=0,
native_max_value=400,
mode=NumberMode.BOX,
icon="mdi:thermometer-chevron-up",
set_packet=lambda coordinator, value: PacketA300Write(
probe=0,
minimum=_get_ambient_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)[
0
],
maximum=None if value == 0.0 else value,
),
get_value=lambda x: _get_ambient_temperatures(x, AlarmType.TEMPERATURE_RANGE)[
1
],
entity_supported=lambda x: x.get(CONF_HAS_AMBIENT, False),
),
ToGrillNumberEntityDescription(
key="alarm_interval",
translation_key="alarm_interval",

View File

@@ -55,6 +55,12 @@
"alarm_interval": {
"name": "Alarm interval"
},
"ambient_temperature_maximum": {
"name": "Ambient maximum temperature"
},
"ambient_temperature_minimum": {
"name": "Ambient minimum temperature"
},
"temperature_maximum": {
"name": "Maximum temperature"
},

View File

@@ -1,12 +1,25 @@
"""Intents for the vacuum integration."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
import logging
from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, config_validation as cv, intent
from . import (
DOMAIN,
SERVICE_CLEAN_AREA,
SERVICE_RETURN_TO_BASE,
SERVICE_START,
VacuumEntityFeature,
)
_LOGGER = logging.getLogger(__name__)
INTENT_VACUUM_START = "HassVacuumStart"
INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase"
INTENT_VACUUM_CLEAN_AREA = "HassVacuumCleanArea"
async def async_setup_intents(hass: HomeAssistant) -> None:
@@ -35,3 +48,156 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
required_features=VacuumEntityFeature.RETURN_HOME,
),
)
intent.async_register(hass, CleanAreaIntentHandler())
class CleanAreaIntentHandler(intent.IntentHandler):
"""Intent handler for cleaning a specific area with a vacuum.
The area slot is used as a service parameter (cleaning_area_id),
not for entity matching.
"""
intent_type = INTENT_VACUUM_CLEAN_AREA
platforms = {DOMAIN}
description = "Tells a vacuum to clean a specific area"
@property
def slot_schema(self) -> dict:
"""Return a slot schema."""
return {
vol.Required("area"): cv.string,
vol.Optional("name"): cv.string,
vol.Optional("preferred_area_id"): cv.string,
vol.Optional("preferred_floor_id"): cv.string,
}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
# Resolve the area name to an area ID
area_name = slots["area"]["value"]
area_reg = ar.async_get(hass)
matched_areas = list(intent.find_areas(area_name, area_reg))
if not matched_areas:
raise intent.MatchFailedError(
result=intent.MatchTargetsResult(
is_match=False,
no_match_reason=intent.MatchFailedReason.INVALID_AREA,
no_match_name=area_name,
),
constraints=intent.MatchTargetsConstraints(
area_name=area_name,
),
)
# Use preferred area/floor from conversation context to disambiguate
preferred_area_id = slots.get("preferred_area_id", {}).get("value")
preferred_floor_id = slots.get("preferred_floor_id", {}).get("value")
if len(matched_areas) > 1 and preferred_area_id is not None:
filtered = [a for a in matched_areas if a.id == preferred_area_id]
if filtered:
matched_areas = filtered
if len(matched_areas) > 1 and preferred_floor_id is not None:
filtered = [a for a in matched_areas if a.floor_id == preferred_floor_id]
if filtered:
matched_areas = filtered
# Match vacuum entity by name
name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
match_constraints = intent.MatchTargetsConstraints(
name=entity_name,
domains={DOMAIN},
features=VacuumEntityFeature.CLEAN_AREA,
assistant=intent_obj.assistant,
)
# Use the resolved cleaning area and its floor as preferences
# for entity disambiguation
target_area = matched_areas[0]
match_preferences = intent.MatchTargetsPreferences(
area_id=target_area.id,
floor_id=target_area.floor_id,
)
match_result = intent.async_match_targets(
hass, match_constraints, match_preferences
)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result,
constraints=match_constraints,
preferences=match_preferences,
)
# Update intent slots to include any transformations done by the schemas
intent_obj.slots = slots
return await self._async_handle_service(intent_obj, match_result, matched_areas)
async def _async_handle_service(
self,
intent_obj: intent.Intent,
match_result: intent.MatchTargetsResult,
matched_areas: list[ar.AreaEntry],
) -> intent.IntentResponse:
"""Call clean_area for all matched areas."""
hass = intent_obj.hass
states = match_result.states
entity_ids = [state.entity_id for state in states]
area_ids = [area.id for area in matched_areas]
try:
await hass.services.async_call(
DOMAIN,
SERVICE_CLEAN_AREA,
{
"entity_id": entity_ids,
"cleaning_area_id": area_ids,
},
context=intent_obj.context,
blocking=True,
)
except Exception:
_LOGGER.exception(
"Failed to call %s for areas: %s with vacuums: %s",
SERVICE_CLEAN_AREA,
area_ids,
entity_ids,
)
raise intent.IntentHandleError(
f"Failed to call {SERVICE_CLEAN_AREA} for areas: {area_ids}"
f" with vacuums: {entity_ids}"
) from None
success_results: list[intent.IntentResponseTarget] = [
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.AREA,
name=area.name,
id=area.id,
)
for area in matched_areas
]
success_results.extend(
intent.IntentResponseTarget(
type=intent.IntentResponseTargetType.ENTITY,
name=state.name,
id=state.entity_id,
)
for state in states
)
response = intent_obj.create_response()
response.async_set_results(success_results)
# Update all states
states = [hass.states.get(state.entity_id) or state for state in states]
response.async_set_states(states)
return response

View File

@@ -721,6 +721,26 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="energy_consumption_heating_this_year",
translation_key="energy_consumption_heating_this_year",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_getter=lambda api: api.getPowerConsumptionHeatingThisYear(),
unit_getter=lambda api: api.getPowerConsumptionHeatingUnit(),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="energy_consumption_dhw_this_year",
translation_key="energy_consumption_dhw_this_year",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_getter=lambda api: api.getPowerConsumptionDomesticHotWaterThisYear(),
unit_getter=lambda api: api.getPowerConsumptionDomesticHotWaterUnit(),
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="buffer top temperature",
translation_key="buffer_top_temperature",

View File

@@ -289,6 +289,12 @@
"energy_consumption_cooling_today": {
"name": "Cooling electricity consumption today"
},
"energy_consumption_dhw_this_year": {
"name": "DHW energy consumption this year"
},
"energy_consumption_heating_this_year": {
"name": "Heating energy consumption this year"
},
"energy_dhw_summary_consumption_heating_currentday": {
"name": "DHW electricity consumption today"
},

View File

@@ -35,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
try:
await auth.do_auth(store=False)
except (ClientError, TimeoutError) as ex:
raise ConfigEntryNotReady("Cannot connect") from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from ex
except WhirlpoolAccountLocked as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="account_locked"
@@ -43,7 +45,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
if not auth.is_access_token_valid():
_LOGGER.error("Authentication failed")
raise ConfigEntryAuthFailed("Incorrect Password")
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="invalid_auth"
)
appliances_manager = AppliancesManager(backend_selector, auth, session)
if not await appliances_manager.fetch_appliances():

View File

@@ -62,7 +62,7 @@ rules:
comment: The "unknown" state should not be part of the enum for the dispense level sensor.
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations:
status: todo
comment: |

View File

@@ -216,6 +216,12 @@
"appliances_fetch_failed": {
"message": "Failed to fetch appliances"
},
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"invalid_auth": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"invalid_value_set": {
"message": "Invalid value provided"
},

View File

@@ -22,6 +22,7 @@ from homeassistant.core import State, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.group import IntegrationSpecificGroup
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
@@ -51,6 +52,18 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
meta = self.entity_data.entity.info_object
self._attr_unique_id = meta.unique_id
if self.entity_data.is_group_entity:
group_proxy = self.entity_data.group_proxy
assert group_proxy is not None
platform = self.entity_data.entity.PLATFORM
unique_ids = [
entity.info_object.unique_id
for member in group_proxy.group.members
for entity in member.associated_entities
if platform == entity.PLATFORM
]
self.group = IntegrationSpecificGroup(self, unique_ids)
if meta.entity_category is not None:
self._attr_entity_category = EntityCategory(meta.entity_category)

View File

@@ -332,6 +332,9 @@ ATTR_NAME: Final = "name"
# Contains one string or a list of strings, each being an entity id
ATTR_ENTITY_ID: Final = "entity_id"
# Contains a list of entity ids that are members of a group
ATTR_GROUP_ENTITIES: Final = "group_entities"
# Contains one string, the config entry ID
ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id"

View File

@@ -27,6 +27,7 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
ATTR_GROUP_ENTITIES,
ATTR_ICON,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
@@ -54,13 +55,15 @@ from homeassistant.loader import async_suggest_report_issue, bind_hass
from homeassistant.util import ensure_unique_string, slugify
from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed
from . import device_registry as dr, entity_registry as er, singleton
from . import device_registry as dr, entity_registry as er
from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData
from .event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
)
from .frame import report_non_thread_safe_operation
from .frame import report_non_thread_safe_operation, report_usage
from .group import Group
from .singleton import singleton
from .typing import UNDEFINED, StateType, UndefinedType
timer = time.time
@@ -90,7 +93,7 @@ def async_setup(hass: HomeAssistant) -> None:
@callback
@bind_hass
@singleton.singleton(DATA_ENTITY_SOURCE)
@singleton(DATA_ENTITY_SOURCE)
def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]:
"""Get the entity sources.
@@ -457,6 +460,15 @@ class Entity(
# Only handled internally, never to be used by integrations.
internal_integration_suggested_object_id: str | None
# A group information in case the entity represents a group
group: Group | None = None
# Internal copy of `group`. This prevents integration authors from
# mistakenly overwriting it during the entity's lifetime, which would
# break Group functionality. It also lets us check if `group` is
# actually a Group instance just once in `async_internal_added_to_hass`,
# rather than on every state write.
__group: Group | None = None
# If we reported if this entity was slow
_slow_reported = False
@@ -1064,6 +1076,10 @@ class Entity(
entry = self.registry_entry
capability_attr = self.capability_attributes
if self.__group is not None:
capability_attr = capability_attr.copy() if capability_attr else {}
capability_attr[ATTR_GROUP_ENTITIES] = self.__group.member_entity_ids.copy()
attr = capability_attr.copy() if capability_attr else {}
available = self.available # only call self.available once per update cycle
@@ -1503,6 +1519,17 @@ class Entity(
)
self._async_subscribe_device_updates()
if self.group is not None:
if not isinstance(self.group, Group):
report_usage( # type: ignore[unreachable]
f"sets a `group` attribute on entity {self.entity_id} which is "
"not a `Group` instance",
breaks_in_ha_version="2027.2",
)
else:
self.__group = self.group
self.__group.async_added_to_hass()
async def async_internal_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
@@ -1513,6 +1540,9 @@ class Entity(
if self.platform:
del entity_sources(self.hass)[self.entity_id]
if self.__group is not None:
self.__group.async_will_remove_from_hass()
@callback
def _async_registry_updated(
self, event: Event[er.EventEntityRegistryUpdatedData]

View File

@@ -3,19 +3,167 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from typing import TYPE_CHECKING, Any
from propcache.api import cached_property
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from . import entity_registry as er
from .singleton import singleton
if TYPE_CHECKING:
from .entity import Entity
DATA_GROUP_ENTITIES = "group_entities"
ENTITY_PREFIX = "group."
class Group:
"""Entity group base class."""
_entity: Entity
def __init__(self, entity: Entity) -> None:
"""Initialize the group."""
self._entity = entity
@property
def member_entity_ids(self) -> list[str]:
"""Return the list of member entity IDs."""
raise NotImplementedError
@callback
def async_added_to_hass(self) -> None:
"""Called when the entity is added to hass."""
entity = self._entity
get_group_entities(entity.hass)[entity.entity_id] = entity
@callback
def async_will_remove_from_hass(self) -> None:
"""Called when the entity will be removed from hass."""
entity = self._entity
del get_group_entities(entity.hass)[entity.entity_id]
class GenericGroup(Group):
"""Generic entity group.
Members can come from multiple integrations and are referenced by entity ID.
"""
def __init__(self, entity: Entity, member_entity_ids: list[str]) -> None:
"""Initialize the group."""
super().__init__(entity)
self._member_entity_ids = member_entity_ids
@cached_property
def member_entity_ids(self) -> list[str]:
"""Return the list of member entity IDs."""
return self._member_entity_ids
class IntegrationSpecificGroup(Group):
"""Integration-specific entity group.
Members come from a single integration and are referenced by unique ID.
Entity IDs are resolved via the entity registry. This group listens for
entity registry events to keep the resolved entity IDs up to date.
"""
_member_entity_ids: list[str] | None = None
_member_unique_ids: list[str]
def __init__(self, entity: Entity, member_unique_ids: list[str]) -> None:
"""Initialize the group."""
super().__init__(entity)
self._member_unique_ids = member_unique_ids
@cached_property
def member_entity_ids(self) -> list[str]:
"""Return the list of member entity IDs."""
entity_registry = er.async_get(self._entity.hass)
self._member_entity_ids = [
entity_id
for unique_id in self.member_unique_ids
if (
entity_id := entity_registry.async_get_entity_id(
self._entity.platform.domain,
self._entity.platform.platform_name,
unique_id,
)
)
is not None
]
return self._member_entity_ids
@property
def member_unique_ids(self) -> list[str]:
"""Return the list of member unique IDs."""
return self._member_unique_ids
@member_unique_ids.setter
def member_unique_ids(self, value: list[str]) -> None:
"""Set the list of member unique IDs."""
self._member_unique_ids = value
if self._member_entity_ids is not None:
self._member_entity_ids = None
del self.member_entity_ids
@callback
def async_added_to_hass(self) -> None:
"""Called when the entity is added to hass."""
super().async_added_to_hass()
entity = self._entity
entity_registry = er.async_get(entity.hass)
@callback
def _handle_entity_registry_updated(event: Event[Any]) -> None:
"""Handle registry create or update event."""
if (
event.data["action"] in {"create", "update"}
and (entry := entity_registry.async_get(event.data["entity_id"]))
and entry.domain == entity.platform.domain
and entry.platform == entity.platform.platform_name
and entry.unique_id in self.member_unique_ids
) or (
event.data["action"] == "remove"
and self._member_entity_ids is not None
and event.data["entity_id"] in self._member_entity_ids
):
if self._member_entity_ids is not None:
self._member_entity_ids = None
del self.member_entity_ids
entity.async_write_ha_state()
entity.async_on_remove(
entity.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
_handle_entity_registry_updated,
)
)
@callback
@singleton(DATA_GROUP_ENTITIES)
def get_group_entities(hass: HomeAssistant) -> dict[str, Entity]:
"""Get the group entities.
Items are added to this dict by Group.async_added_to_hass and
removed by Group.async_will_remove_from_hass.
"""
return {}
def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]:
"""Return entity_ids with group entity ids replaced by their members.
Async friendly.
"""
group_entities = get_group_entities(hass)
found_ids: list[str] = []
for entity_id in entity_ids:
if not isinstance(entity_id, str) or entity_id in (
@@ -25,8 +173,22 @@ def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[st
continue
entity_id = entity_id.lower()
# If entity_id points at a group, expand it
if entity_id.startswith(ENTITY_PREFIX):
if (entity := group_entities.get(entity_id)) is not None and isinstance(
entity.group, GenericGroup
):
child_entities = entity.group.member_entity_ids
if entity_id in child_entities:
child_entities = list(child_entities)
child_entities.remove(entity_id)
found_ids.extend(
ent_id
for ent_id in expand_entity_ids(hass, child_entities)
if ent_id not in found_ids
)
# If entity_id points at an old-style group, expand it
elif entity_id.startswith(ENTITY_PREFIX):
child_entities = get_entity_ids(hass, entity_id)
if entity_id in child_entities:
child_entities = list(child_entities)

View File

@@ -119,6 +119,13 @@ def _validate_supported_features(supported_features: list[str]) -> int:
return feature_mask
def _validate_selector_reorder_config(config: Any) -> Any:
"""Validate selectors with reorder option."""
if config.get("reorder") and not config.get("multiple"):
raise vol.Invalid("reorder can only be used when multiple is true")
return config
def make_selector_config_schema(schema_dict: dict | None = None) -> vol.Schema:
"""Make selector config schema."""
if schema_dict is None:
@@ -310,19 +317,22 @@ class AreaSelector(Selector[AreaSelectorConfig]):
selector_type = "area"
CONFIG_SCHEMA = make_selector_config_schema(
{
vol.Optional("entity"): vol.All(
cv.ensure_list,
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
),
vol.Optional("device"): vol.All(
cv.ensure_list,
[DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA],
),
vol.Optional("multiple", default=False): cv.boolean,
vol.Optional("reorder", default=False): cv.boolean,
}
CONFIG_SCHEMA = vol.All(
make_selector_config_schema(
{
vol.Optional("entity"): vol.All(
cv.ensure_list,
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
),
vol.Optional("device"): vol.All(
cv.ensure_list,
[DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA],
),
vol.Optional("multiple", default=False): cv.boolean,
vol.Optional("reorder", default=False): cv.boolean,
}
),
_validate_selector_reorder_config,
)
def __init__(self, config: AreaSelectorConfig | None = None) -> None:
@@ -894,18 +904,21 @@ class EntitySelector(Selector[EntitySelectorConfig]):
selector_type = "entity"
CONFIG_SCHEMA = make_selector_config_schema(
{
**_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT,
vol.Optional("exclude_entities"): [str],
vol.Optional("include_entities"): [str],
vol.Optional("multiple", default=False): cv.boolean,
vol.Optional("reorder", default=False): cv.boolean,
vol.Optional("filter"): vol.All(
cv.ensure_list,
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
),
}
CONFIG_SCHEMA = vol.All(
make_selector_config_schema(
{
**_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT,
vol.Optional("exclude_entities"): [str],
vol.Optional("include_entities"): [str],
vol.Optional("multiple", default=False): cv.boolean,
vol.Optional("reorder", default=False): cv.boolean,
vol.Optional("filter"): vol.All(
cv.ensure_list,
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
),
}
),
_validate_selector_reorder_config,
)
def __init__(self, config: EntitySelectorConfig | None = None) -> None:

10
mypy.ini generated
View File

@@ -3176,6 +3176,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.lutron.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.madvr.*]
check_untyped_defs = true
disallow_incomplete_defs = true

10
requirements_all.txt generated
View File

@@ -28,7 +28,7 @@ HueBLE==2.1.0
Mastodon.py==2.1.2
# homeassistant.components.playstation_network
PSNAWP==3.0.1
PSNAWP==3.0.3
# homeassistant.components.doods
# homeassistant.components.generic
@@ -1676,7 +1676,7 @@ ondilo==0.5.0
# homeassistant.components.onedrive
# homeassistant.components.onedrive_for_business
onedrive-personal-sdk==0.1.5
onedrive-personal-sdk==0.1.6
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -2245,7 +2245,7 @@ pylitterbot==2025.1.0
pylutron-caseta==0.27.0
# homeassistant.components.lutron
pylutron==0.2.18
pylutron==0.3.0
# homeassistant.components.mailgun
pymailgunner==1.4
@@ -2409,7 +2409,7 @@ pyrail==0.4.1
pyrainbird==6.1.1
# homeassistant.components.playstation_network
pyrate-limiter==3.9.0
pyrate-limiter==4.0.2
# homeassistant.components.recswitch
pyrecswitch==1.0.2
@@ -2639,7 +2639,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==4.17.1
python-roborock==4.17.2
# homeassistant.components.smarttub
python-smarttub==0.0.47

View File

@@ -28,7 +28,7 @@ HueBLE==2.1.0
Mastodon.py==2.1.2
# homeassistant.components.playstation_network
PSNAWP==3.0.1
PSNAWP==3.0.3
# homeassistant.components.doods
# homeassistant.components.generic
@@ -1462,7 +1462,7 @@ ondilo==0.5.0
# homeassistant.components.onedrive
# homeassistant.components.onedrive_for_business
onedrive-personal-sdk==0.1.5
onedrive-personal-sdk==0.1.6
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1919,7 +1919,7 @@ pylitterbot==2025.1.0
pylutron-caseta==0.27.0
# homeassistant.components.lutron
pylutron==0.2.18
pylutron==0.3.0
# homeassistant.components.mailgun
pymailgunner==1.4
@@ -2059,7 +2059,7 @@ pyrail==0.4.1
pyrainbird==6.1.1
# homeassistant.components.playstation_network
pyrate-limiter==3.9.0
pyrate-limiter==4.0.2
# homeassistant.components.risco
pyrisco==0.6.7
@@ -2235,7 +2235,7 @@ python-pooldose==0.8.2
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==4.17.1
python-roborock==4.17.2
# homeassistant.components.smarttub
python-smarttub==0.0.47

View File

@@ -3,6 +3,7 @@
from collections.abc import AsyncGenerator, Generator
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyanglianwater.api import API
from pyanglianwater.meter import SmartMeter
import pytest
@@ -32,20 +33,20 @@ def mock_config_entry() -> MockConfigEntry:
@pytest.fixture
def mock_smart_meter() -> SmartMeter:
"""Return a mocked Smart Meter."""
mock = AsyncMock(spec=SmartMeter)
mock.serial_number = "TESTSN"
mock.get_yesterday_consumption = 50
mock.latest_read = 50
mock.yesterday_water_cost = 0.5
mock.yesterday_sewerage_cost = 0.5
mock.readings = [
def mock_smart_meter(freezer: FrozenDateTimeFactory) -> SmartMeter:
"""Return a Smart Meter for testing."""
# Freeze time to June 2, 2024 so "yesterday" is June 1, matching our test readings
freezer.move_to("2024-06-02T00:00:00Z")
meter = SmartMeter("TESTSN")
meter.readings = [
{"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10},
{"read_at": "2024-06-01T13:00:00Z", "consumption": 15, "read": 25},
{"read_at": "2024-06-01T14:00:00Z", "consumption": 25, "read": 50},
]
return mock
meter.yesterday_water_cost = 0.5
meter.yesterday_sewerage_cost = 0.5
return meter
@pytest.fixture

View File

@@ -1,4 +1,54 @@
# serializer version: 1
# name: test_sensor[sensor.testsn_last_meter_reading_processed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.testsn_last_meter_reading_processed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last meter reading processed',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last meter reading processed',
'platform': 'anglian_water',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <AnglianWaterSensor.LAST_UPDATED: 'last_updated'>,
'unique_id': 'TESTSN_last_updated',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.testsn_last_meter_reading_processed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'TESTSN Last meter reading processed',
}),
'context': <ANY>,
'entity_id': 'sensor.testsn_last_meter_reading_processed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2024-06-01T14:00:00+00:00',
})
# ---
# name: test_sensor[sensor.testsn_latest_reading-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -53,7 +103,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '50',
'state': '50.0',
})
# ---
# name: test_sensor[sensor.testsn_yesterday_s_sewerage_cost-entry]
@@ -161,7 +211,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '50',
'state': '50.0',
})
# ---
# name: test_sensor[sensor.testsn_yesterday_s_water_cost-entry]

View File

@@ -8,6 +8,7 @@ from arcam.fmj.state import State
import pytest
from homeassistant.components.arcam_fmj.const import DEFAULT_NAME
from homeassistant.components.arcam_fmj.coordinator import ArcamFmjCoordinator
from homeassistant.components.arcam_fmj.media_player import ArcamFmj
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
@@ -47,7 +48,9 @@ def state_1_fixture(client: Mock) -> State:
state.get_power.return_value = True
state.get_volume.return_value = 0.0
state.get_source_list.return_value = []
state.get_incoming_audio_format.return_value = (0, 0)
state.get_incoming_audio_format.return_value = (None, None)
state.get_incoming_video_parameters.return_value = None
state.get_incoming_audio_sample_rate.return_value = 0
state.get_mute.return_value = None
state.get_decode_modes.return_value = []
return state
@@ -62,7 +65,9 @@ def state_2_fixture(client: Mock) -> State:
state.get_power.return_value = True
state.get_volume.return_value = 0.0
state.get_source_list.return_value = []
state.get_incoming_audio_format.return_value = (0, 0)
state.get_incoming_audio_format.return_value = (None, None)
state.get_incoming_video_parameters.return_value = None
state.get_incoming_audio_sample_rate.return_value = 0
state.get_mute.return_value = None
state.get_decode_modes.return_value = []
return state
@@ -74,10 +79,34 @@ def state_fixture(state_1: State) -> State:
return state_1
@pytest.fixture(name="mock_config_entry")
def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry:
"""Get a mock config entry."""
config_entry = MockConfigEntry(
domain="arcam_fmj",
data=MOCK_CONFIG_ENTRY,
title=MOCK_NAME,
unique_id=MOCK_UUID,
)
config_entry.add_to_hass(hass)
return config_entry
@pytest.fixture(name="player")
def player_fixture(hass: HomeAssistant, state: State) -> ArcamFmj:
"""Get standard player."""
player = ArcamFmj(MOCK_NAME, state, MOCK_UUID)
def player_fixture(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
client: Mock,
state_1: Mock,
) -> ArcamFmj:
"""Get standard player.
This fixture tests internals and should not be used going forward.
"""
coordinator = ArcamFmjCoordinator(hass, mock_config_entry, client, 1)
coordinator.state = state_1
coordinator.last_update_success = True
player = ArcamFmj(MOCK_NAME, coordinator, MOCK_UUID)
player.entity_id = MOCK_ENTITY_ID
player.hass = hass
player.platform = MockEntityPlatform(hass)
@@ -88,13 +117,13 @@ def player_fixture(hass: HomeAssistant, state: State) -> ArcamFmj:
@pytest.fixture(name="player_setup")
async def player_setup_fixture(
hass: HomeAssistant, state_1: State, state_2: State, client: Mock
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
state_1: State,
state_2: State,
client: Mock,
) -> AsyncGenerator[str]:
"""Get standard player."""
config_entry = MockConfigEntry(
domain="arcam_fmj", data=MOCK_CONFIG_ENTRY, title=MOCK_NAME
)
config_entry.add_to_hass(hass)
def state_mock(cli, zone):
if zone == 1:
@@ -103,16 +132,23 @@ async def player_setup_fixture(
return state_2
raise ValueError(f"Unknown player zone: {zone}")
async def _mock_run_client(hass: HomeAssistant, runtime_data, interval):
for coordinator in runtime_data.coordinators.values():
coordinator.async_notify_connected()
await async_setup_component(hass, "homeassistant", {})
with (
patch("homeassistant.components.arcam_fmj.Client", return_value=client),
patch(
"homeassistant.components.arcam_fmj.media_player.State",
"homeassistant.components.arcam_fmj.coordinator.State",
side_effect=state_mock,
),
patch("homeassistant.components.arcam_fmj._run_client", return_value=None),
patch(
"homeassistant.components.arcam_fmj._run_client",
side_effect=_mock_run_client,
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
yield MOCK_ENTITY_ID

View File

@@ -1,16 +1,11 @@
"""Tests for arcam fmj receivers."""
from math import isclose
from unittest.mock import ANY, PropertyMock, patch
from unittest.mock import PropertyMock, patch
from arcam.fmj import ConnectionFailed, DecodeMode2CH, DecodeModeMCH, SourceCodes
import pytest
from homeassistant.components.arcam_fmj.const import (
SIGNAL_CLIENT_DATA,
SIGNAL_CLIENT_STARTED,
SIGNAL_CLIENT_STOPPED,
)
from homeassistant.components.arcam_fmj.media_player import ArcamFmj
from homeassistant.components.homeassistant import (
DOMAIN as HA_DOMAIN,
@@ -144,7 +139,6 @@ async def test_update_lost(
blocking=True,
)
state.update.assert_called_with()
assert "Connection lost during update" in caplog.text
@pytest.mark.parametrize(
@@ -355,17 +349,3 @@ async def test_media_title(player, state, source, channel, title) -> None:
assert "media_title" not in data.attributes
else:
assert data.attributes["media_title"] == title
async def test_added_to_hass(player, state) -> None:
"""Test addition to hass."""
with patch(
"homeassistant.components.arcam_fmj.media_player.async_dispatcher_connect"
) as connect:
await player.async_added_to_hass()
state.start.assert_called_with()
connect.assert_any_call(player.hass, SIGNAL_CLIENT_DATA, ANY)
connect.assert_any_call(player.hass, SIGNAL_CLIENT_STARTED, ANY)
connect.assert_any_call(player.hass, SIGNAL_CLIENT_STOPPED, ANY)

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