Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create, update and delete KNX entities from UI / WS-commands #104079

Merged
merged 51 commits into from
Jul 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
a447040
knx entity CRUD - initial commit - switch
farmio Nov 16, 2023
cc11f56
platform dependent schema
farmio Nov 16, 2023
afc9b43
coerce empty GA-lists to None
farmio Nov 17, 2023
56d12c5
read entity configuration from WS
farmio Nov 17, 2023
549a7f9
use entity_id instead of unique_id for lookup
farmio Dec 8, 2023
f91dd07
Add device support
farmio Dec 22, 2023
2084062
Rename KNXEntityStore to KNXConfigStore
farmio Dec 22, 2023
13c28e2
fix test after rename
farmio Dec 22, 2023
5580d58
Send schema options for creating / editing entities
farmio Dec 22, 2023
88611a5
Return entity_id after entity creation
farmio Dec 23, 2023
1cf08e5
remove device_class config in favour of more-info-dialog settings
farmio Dec 23, 2023
f50925d
refactor group address schema for custom selector
farmio Dec 27, 2023
e21bd0c
Rename GA keys and remove invalid keys from schema
farmio Dec 27, 2023
eb77652
fix rebase
farmio Jan 18, 2024
83d54ac
Fix deleting devices and their entities
farmio Jan 18, 2024
bada30d
Validate entity schema in extra step - return validation infos
farmio Feb 4, 2024
8e8b68b
Use exception to signal validation error; return validated data
farmio Feb 16, 2024
e1ee4d2
Forward validation result when editing entities
farmio Feb 16, 2024
fac7d61
Get proper validation error message for optional GAs
farmio Feb 18, 2024
efb54f9
Add entity validation only WS command
farmio Feb 19, 2024
ee30f59
use ulid instead of uuid
farmio Feb 20, 2024
13738b1
Fix error handling for edit unknown entity
farmio Feb 23, 2024
804ea00
Remove unused optional group address sets from validated schema
farmio Feb 27, 2024
6a9a76c
Add optional dpt field for ga_schema
farmio Feb 27, 2024
caaa0a1
Move knx config things to sub-key
farmio Mar 9, 2024
43b5514
Add light platform
farmio Mar 27, 2024
c81a1ba
async_forward_entry_setups only once
farmio May 2, 2024
a6a10f8
Test crate and remove devices
farmio May 4, 2024
85afe05
Test removing entities of a removed device
farmio May 6, 2024
9fa1615
Test entity creation and storage
farmio May 7, 2024
4ef3979
Test deleting entities
farmio May 9, 2024
5aa6434
Test unsuccessful entity creation
farmio May 9, 2024
c2901be
Test updating entity data
farmio May 9, 2024
b938cc5
Test get entity config
farmio May 9, 2024
2a659e1
Test validate entity
farmio May 9, 2024
26c251d
Update entity data by entity_id instead of unique_id
farmio May 10, 2024
70d0ef7
Remove unnecessary uid unique check
farmio May 10, 2024
9da038e
remove schema_options
farmio May 17, 2024
92df5f5
test fixture for entity creation
farmio May 17, 2024
1e17c1a
clean up group address schema
farmio May 18, 2024
623c55a
Revert: Add light platfrom
farmio May 18, 2024
89339fa
remove unused optional_ga_schema
farmio May 18, 2024
468ec6c
Test GASelector
farmio May 18, 2024
5f40e7f
lint tests
farmio Jun 20, 2024
9280e81
Review
farmio Jul 18, 2024
8ca664f
group entities before adding
farmio Jul 18, 2024
4d6b694
fix / ignore mypy
farmio Jul 18, 2024
e7b44a7
always has_entity_name
farmio Jul 19, 2024
48fe094
Entity name: check for empty string when no device
farmio Jul 19, 2024
d0fc065
use constants instead of strings in schema
farmio Jul 19, 2024
0f71ede
Fix mypy errors for voluptuous schemas
cdce8p Jul 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 38 additions & 11 deletions homeassistant/components/knx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
Expand Down Expand Up @@ -62,7 +63,8 @@
DATA_KNX_CONFIG,
DOMAIN,
KNX_ADDRESS,
SUPPORTED_PLATFORMS,
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
Expand Down Expand Up @@ -90,6 +92,7 @@
WeatherSchema,
)
from .services import register_knx_services
from .storage.config_store import KNXConfigStore
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
from .websocket import register_panel

Expand Down Expand Up @@ -190,10 +193,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
knx_module.exposures.append(
create_knx_exposure(hass, knx_module.xknx, expose_config)
)
# always forward sensor for system entities (telegram counter, etc.)
platforms = {platform for platform in SUPPORTED_PLATFORMS if platform in config}
platforms.add(Platform.SENSOR)
await hass.config_entries.async_forward_entry_setups(entry, platforms)
await hass.config_entries.async_forward_entry_setups(
entry,
{
Platform.SENSOR, # always forward sensor for system entities (telegram counter, etc.)
*SUPPORTED_PLATFORMS_UI, # forward all platforms that support UI entity management
*{ # forward yaml-only managed platforms on demand
platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
},
},
farmio marked this conversation as resolved.
Show resolved Hide resolved
)

# set up notify service for backwards compatibility - remove 2024.11
if NotifySchema.PLATFORM in config:
Expand All @@ -220,15 +229,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

unload_ok = await hass.config_entries.async_unload_platforms(
entry,
[
{
Platform.SENSOR, # always unload system entities (telegram counter, etc.)
*[
*SUPPORTED_PLATFORMS_UI, # unload all platforms that support UI entity management
*{ # unload yaml-only managed platforms if configured
platform
for platform in SUPPORTED_PLATFORMS
for platform in SUPPORTED_PLATFORMS_YAML
if platform in hass.data[DATA_KNX_CONFIG]
and platform is not Platform.SENSOR
],
],
},
},
)
if unload_ok:
await knx_module.stop()
Expand Down Expand Up @@ -263,6 +272,22 @@ def remove_files(storage_dir: Path, knxkeys_filename: str | None) -> None:
await hass.async_add_executor_job(remove_files, storage_dir, knxkeys_filename)


async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
knx_module: KNXModule = hass.data[DOMAIN]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you feel extra fancy, maybe you can do https://developers.home-assistant.io/blog/2024/05/01/improved-hass-data-typing (but in a separate PR of course)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do fancy type safety for sure!
Maybe I can even use runtime_data, but for now I'm trying to avoid merge conflict hell 😬 (I have some follow-ups ready locally 🙃)

if not device_entry.identifiers.isdisjoint(
knx_module.interface_device.device_info["identifiers"]
):
# can not remove interface device
return False
for entity in knx_module.config_store.get_entity_entries():
if entity.device_id == device_entry.id:
await knx_module.config_store.delete_entity(entity.entity_id)
return True
farmio marked this conversation as resolved.
Show resolved Hide resolved


class KNXModule:
"""Representation of KNX Object."""

Expand All @@ -278,6 +303,7 @@ def __init__(
self.entry = entry

self.project = KNXProject(hass=hass, entry=entry)
self.config_store = KNXConfigStore(hass=hass, entry=entry)

self.xknx = XKNX(
connection_config=self.connection_config(),
Expand Down Expand Up @@ -309,6 +335,7 @@ def __init__(
async def start(self) -> None:
"""Start XKNX object. Connect to tunneling or Routing device."""
await self.project.load_project()
await self.config_store.load_data()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure data loading in KNXModule.start() is robust and error-handled.

Consider adding error handling around the data loading calls in KNXModule.start() to manage potential failures gracefully. This could improve the robustness of the module startup process.

await self.telegrams.load_history()
await self.xknx.start()

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/knx/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
TELEGRAM_LOG_MAX,
KNXConfigEntryData,
)
from .helpers.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .validation import ia_validator, ip_v4_validator

CONF_KNX_GATEWAY: Final = "gateway"
Expand Down
13 changes: 8 additions & 5 deletions homeassistant/components/knx/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,13 @@ class KNXConfigEntryData(TypedDict, total=False):
class ColorTempModes(Enum):
"""Color temperature modes for config validation."""

ABSOLUTE = "DPT-7.600"
ABSOLUTE_FLOAT = "DPT-9"
RELATIVE = "DPT-5.001"
# YAML uses Enum.name (with vol.Upper), UI uses Enum.value for lookup
ABSOLUTE = "7.600"
ABSOLUTE_FLOAT = "9"
RELATIVE = "5.001"


SUPPORTED_PLATFORMS: Final = [
SUPPORTED_PLATFORMS_YAML: Final = {
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Expand All @@ -150,7 +151,9 @@ class ColorTempModes(Enum):
Platform.TEXT,
Platform.TIME,
Platform.WEATHER,
]
}

SUPPORTED_PLATFORMS_UI: Final = {Platform.SWITCH}

# Map KNX controller modes to HA modes. This list might not be complete.
CONTROLLER_MODES: Final = {
Expand Down
145 changes: 145 additions & 0 deletions homeassistant/components/knx/storage/config_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""KNX entity configuration store."""

from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, Any, Final, TypedDict

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.storage import Store
from homeassistant.util.ulid import ulid_now

from ..const import DOMAIN
from .const import CONF_DATA

if TYPE_CHECKING:
from ..knx_entity import KnxEntity

_LOGGER = logging.getLogger(__name__)

STORAGE_VERSION: Final = 1
STORAGE_KEY: Final = f"{DOMAIN}/config_store.json"

KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration
KNXEntityStoreModel = dict[
str, KNXPlatformStoreModel
] # platform: KNXPlatformStoreModel


class KNXConfigStoreModel(TypedDict):
"""Represent KNX configuration store data."""

entities: KNXEntityStoreModel


class KNXConfigStore:
"""Manage KNX config store data."""

def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
) -> None:
"""Initialize config store."""
self.hass = hass
self._store = Store[KNXConfigStoreModel](hass, STORAGE_VERSION, STORAGE_KEY)
self.data = KNXConfigStoreModel(entities={})

# entities and async_add_entity are filled by platform setups
self.entities: dict[str, KnxEntity] = {} # unique_id as key
self.async_add_entity: dict[
Platform, Callable[[str, dict[str, Any]], None]
] = {}

async def load_data(self) -> None:
"""Load config store data from storage."""
if data := await self._store.async_load():
self.data = KNXConfigStoreModel(**data)
_LOGGER.debug(
"Loaded KNX config data from storage. %s entity platforms",
len(self.data["entities"]),
)

async def create_entity(
self, platform: Platform, data: dict[str, Any]
) -> str | None:
"""Create a new entity."""
if platform not in self.async_add_entity:
raise ConfigStoreException(f"Entity platform not ready: {platform}")

Check warning on line 70 in homeassistant/components/knx/storage/config_store.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/knx/storage/config_store.py#L70

Added line #L70 was not covered by tests
unique_id = f"knx_es_{ulid_now()}"
self.async_add_entity[platform](unique_id, data)
# store data after entity was added to be sure config didn't raise exceptions
self.data["entities"].setdefault(platform, {})[unique_id] = data
await self._store.async_save(self.data)

entity_registry = er.async_get(self.hass)
return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id)

@callback
def get_entity_config(self, entity_id: str) -> dict[str, Any]:
"""Return KNX entity configuration."""
entity_registry = er.async_get(self.hass)
if (entry := entity_registry.async_get(entity_id)) is None:
raise ConfigStoreException(f"Entity not found: {entity_id}")
try:
return {
CONF_PLATFORM: entry.domain,
CONF_DATA: self.data["entities"][entry.domain][entry.unique_id],
}
except KeyError as err:
raise ConfigStoreException(f"Entity data not found: {entity_id}") from err

async def update_entity(
self, platform: Platform, entity_id: str, data: dict[str, Any]
) -> None:
"""Update an existing entity."""
if platform not in self.async_add_entity:
raise ConfigStoreException(f"Entity platform not ready: {platform}")

Check warning on line 99 in homeassistant/components/knx/storage/config_store.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/knx/storage/config_store.py#L99

Added line #L99 was not covered by tests
entity_registry = er.async_get(self.hass)
if (entry := entity_registry.async_get(entity_id)) is None:
raise ConfigStoreException(f"Entity not found: {entity_id}")
unique_id = entry.unique_id
if (
platform not in self.data["entities"]
or unique_id not in self.data["entities"][platform]
):
raise ConfigStoreException(
f"Entity not found in storage: {entity_id} - {unique_id}"
)
await self.entities.pop(unique_id).async_remove()
self.async_add_entity[platform](unique_id, data)
# store data after entity is added to make sure config doesn't raise exceptions
self.data["entities"][platform][unique_id] = data
await self._store.async_save(self.data)

async def delete_entity(self, entity_id: str) -> None:
"""Delete an existing entity."""
entity_registry = er.async_get(self.hass)
if (entry := entity_registry.async_get(entity_id)) is None:
raise ConfigStoreException(f"Entity not found: {entity_id}")
try:
del self.data["entities"][entry.domain][entry.unique_id]
except KeyError as err:
raise ConfigStoreException(
f"Entity not found in {entry.domain}: {entry.unique_id}"
) from err
try:
del self.entities[entry.unique_id]
except KeyError:
_LOGGER.warning("Entity not initialized when deleted: %s", entity_id)

Check warning on line 131 in homeassistant/components/knx/storage/config_store.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/knx/storage/config_store.py#L130-L131

Added lines #L130 - L131 were not covered by tests
entity_registry.async_remove(entity_id)
await self._store.async_save(self.data)

def get_entity_entries(self) -> list[er.RegistryEntry]:
"""Get entity_ids of all configured entities by platform."""
return [
entity.registry_entry
for entity in self.entities.values()
if entity.registry_entry is not None
]


class ConfigStoreException(Exception):
"""KNX config store exception."""
14 changes: 14 additions & 0 deletions homeassistant/components/knx/storage/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Constants used in KNX config store."""

from typing import Final

CONF_DATA: Final = "data"
CONF_ENTITY: Final = "entity"
CONF_DEVICE_INFO: Final = "device_info"
CONF_GA_WRITE: Final = "write"
CONF_GA_STATE: Final = "state"
CONF_GA_PASSIVE: Final = "passive"
CONF_DPT: Final = "dpt"


CONF_GA_SWITCH: Final = "ga_switch"
Loading