mirror of
https://github.com/home-assistant/core.git
synced 2026-08-28 10:16:02 -05:00
Add PostgreSQL backend and storage backend selection for KNX telegrams (#175673)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Copilot Autofix powered by AI
parent
fbc2eb8d27
commit
9eb29b8494
@@ -24,11 +24,13 @@ from .const import (
|
||||
CONF_KNX_KNXKEY_FILENAME,
|
||||
CONF_KNX_RATE_LIMIT,
|
||||
CONF_KNX_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
DATA_HASS_CONFIG,
|
||||
DOMAIN,
|
||||
KNX_MODULE_KEY,
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
KNX_TELEGRAM_DB_PATH_SQLITE,
|
||||
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
@@ -188,11 +190,23 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER)
|
||||
new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT)
|
||||
|
||||
new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data=new_data, options=new_options, version=2
|
||||
entry, data=new_data, options=new_options, version=2, minor_version=2
|
||||
)
|
||||
_LOGGER.info("Migration to version 2 successful")
|
||||
|
||||
if entry.version == 2 and entry.minor_version < 2:
|
||||
# version 2.2 introduced in 2026.8
|
||||
new_options = {**entry.options}
|
||||
if CONF_KNX_TELEGRAM_DB_BACKEND not in new_options:
|
||||
new_options[CONF_KNX_TELEGRAM_DB_BACKEND] = KNX_TELEGRAM_BACKEND_SQLITE
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, options=new_options, minor_version=2
|
||||
)
|
||||
_LOGGER.info("Migration to version 2.2 successful")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Config flow for KNX."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Final, Literal, override
|
||||
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||
|
||||
from knx_telegram_store import ConnectionErrorKind
|
||||
from knx_telegram_store.backends.postgres import PostgresStore
|
||||
import voluptuous as vol
|
||||
from xknx import XKNX
|
||||
from xknx.exceptions.exception import (
|
||||
@@ -49,8 +53,16 @@ from .const import (
|
||||
CONF_KNX_SECURE_USER_ID,
|
||||
CONF_KNX_SECURE_USER_PASSWORD,
|
||||
CONF_KNX_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_DATABASE,
|
||||
CONF_KNX_TELEGRAM_DB_HOST,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
|
||||
CONF_KNX_TELEGRAM_DB_PASSWORD,
|
||||
CONF_KNX_TELEGRAM_DB_PORT,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
CONF_KNX_TELEGRAM_DB_TLS,
|
||||
CONF_KNX_TELEGRAM_DB_USER,
|
||||
CONF_KNX_TUNNEL_ENDPOINT_IA,
|
||||
CONF_KNX_TUNNELING,
|
||||
CONF_KNX_TUNNELING_TCP,
|
||||
@@ -58,6 +70,8 @@ from .const import (
|
||||
DEFAULT_ROUTING_IA,
|
||||
DOMAIN,
|
||||
KNX_MODULE_KEY,
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
KNXConfigEntryData,
|
||||
@@ -82,12 +96,17 @@ DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions(
|
||||
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
|
||||
telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
telegram_db_backend=KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
)
|
||||
|
||||
CONF_KEYRING_FILE: Final = "knxkeys_file"
|
||||
|
||||
CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section"
|
||||
|
||||
# Timeout for the PostgreSQL connection check, so an unreachable host cannot
|
||||
# block the options flow until the driver/OS connection timeout expires.
|
||||
DSN_CHECK_TIMEOUT = 10
|
||||
|
||||
CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type"
|
||||
CONF_KNX_TUNNELING_TYPE_LABELS: Final = {
|
||||
CONF_KNX_TUNNELING: "UDP (Tunneling v1)",
|
||||
@@ -113,6 +132,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a KNX config flow."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize KNX config flow."""
|
||||
@@ -951,6 +971,7 @@ class KNXOptionsFlow(OptionsFlowWithReload):
|
||||
"""Manage KNX communication settings."""
|
||||
if user_input is not None:
|
||||
telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION]
|
||||
backend = telegram_store_section[CONF_KNX_TELEGRAM_DB_BACKEND]
|
||||
self.new_entry_options |= KNXConfigEntryOptions(
|
||||
state_updater=user_input[CONF_KNX_STATE_UPDATER],
|
||||
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
|
||||
@@ -960,7 +981,10 @@ class KNXOptionsFlow(OptionsFlowWithReload):
|
||||
telegram_db_retention_days=telegram_store_section[
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS
|
||||
],
|
||||
telegram_db_backend=backend,
|
||||
)
|
||||
if backend == KNX_TELEGRAM_BACKEND_POSTGRES:
|
||||
return await self.async_step_telegram_store_postgres()
|
||||
return self.finish_flow()
|
||||
|
||||
data_schema = {
|
||||
@@ -1020,6 +1044,22 @@ class KNXOptionsFlow(OptionsFlowWithReload):
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
default=self.initial_options.get(
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
),
|
||||
): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=[
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
],
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="telegram_backend",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
),
|
||||
@@ -1027,5 +1067,136 @@ class KNXOptionsFlow(OptionsFlowWithReload):
|
||||
return self.async_show_form(
|
||||
step_id="communication_settings",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
last_step=False,
|
||||
)
|
||||
|
||||
async def async_step_telegram_store_postgres(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Collect and validate the PostgreSQL telegram store connection."""
|
||||
current_dsn = self.initial_options.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, "")
|
||||
parsed = _parse_dsn(current_dsn)
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Reuse the stored password when the field is left blank.
|
||||
params = {
|
||||
**user_input,
|
||||
CONF_KNX_TELEGRAM_DB_PASSWORD: (
|
||||
user_input.get(CONF_KNX_TELEGRAM_DB_PASSWORD)
|
||||
or parsed.get(CONF_KNX_TELEGRAM_DB_PASSWORD, "")
|
||||
),
|
||||
}
|
||||
dsn = _build_dsn(params)
|
||||
errors = await _async_check_postgres_dsn(dsn)
|
||||
if not errors:
|
||||
self.new_entry_options |= KNXConfigEntryOptions(
|
||||
telegram_db_postgres_dsn=dsn
|
||||
)
|
||||
return self.finish_flow()
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_HOST,
|
||||
default=parsed.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost"),
|
||||
): selector.TextSelector(),
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_PORT,
|
||||
default=parsed.get(CONF_KNX_TELEGRAM_DB_PORT, 5432),
|
||||
): vol.All(
|
||||
selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
min=1,
|
||||
max=65535,
|
||||
mode=selector.NumberSelectorMode.BOX,
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_USER,
|
||||
default=parsed.get(CONF_KNX_TELEGRAM_DB_USER, ""),
|
||||
): selector.TextSelector(),
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_PASSWORD, default=""
|
||||
): selector.TextSelector(
|
||||
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_DATABASE,
|
||||
default=parsed.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"),
|
||||
): selector.TextSelector(),
|
||||
vol.Required(
|
||||
CONF_KNX_TELEGRAM_DB_TLS,
|
||||
default=parsed.get(CONF_KNX_TELEGRAM_DB_TLS, False),
|
||||
): selector.BooleanSelector(),
|
||||
}
|
||||
)
|
||||
if user_input is not None:
|
||||
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
||||
return self.async_show_form(
|
||||
step_id="telegram_store_postgres",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
|
||||
async def _async_check_postgres_dsn(dsn: str) -> dict[str, str]:
|
||||
"""Validate a PostgreSQL DSN, returning form errors on failure."""
|
||||
connection_errors = {
|
||||
ConnectionErrorKind.AUTH: "invalid_auth",
|
||||
ConnectionErrorKind.HOST_UNREACHABLE: "host_unreachable",
|
||||
ConnectionErrorKind.DATABASE_MISSING: "database_missing",
|
||||
ConnectionErrorKind.PERMISSION: "permission",
|
||||
ConnectionErrorKind.TIMEOUT: "timeout",
|
||||
ConnectionErrorKind.MISSING_DEPENDENCY: "missing_dependency",
|
||||
}
|
||||
try:
|
||||
async with asyncio.timeout(DSN_CHECK_TIMEOUT):
|
||||
check_result = await PostgresStore.check_config(dsn)
|
||||
except TimeoutError:
|
||||
return {"base": "timeout"}
|
||||
except ValueError:
|
||||
return {"base": "cannot_connect"}
|
||||
if not check_result.ok:
|
||||
return {"base": connection_errors.get(check_result.kind, "cannot_connect")}
|
||||
return {}
|
||||
|
||||
|
||||
def _build_dsn(params: dict[str, Any]) -> str:
|
||||
"""Build a PostgreSQL DSN from form params."""
|
||||
quoted_user = quote(params.get(CONF_KNX_TELEGRAM_DB_USER, ""), safe="")
|
||||
quoted_password = quote(params.get(CONF_KNX_TELEGRAM_DB_PASSWORD, ""), safe="")
|
||||
host = params.get(CONF_KNX_TELEGRAM_DB_HOST, "localhost")
|
||||
if ":" in host and not host.startswith("["):
|
||||
# IPv6 literals must be bracketed in the URL netloc
|
||||
host = f"[{host}]"
|
||||
port = int(params.get(CONF_KNX_TELEGRAM_DB_PORT, 5432))
|
||||
quoted_database = quote(
|
||||
params.get(CONF_KNX_TELEGRAM_DB_DATABASE, "knx_telegrams"), safe=""
|
||||
)
|
||||
tls = params.get(CONF_KNX_TELEGRAM_DB_TLS, False)
|
||||
|
||||
netloc = f"{quoted_user}:{quoted_password}@{host}:{port}"
|
||||
query = "sslmode=require" if tls else ""
|
||||
return urlunparse(("postgresql", netloc, f"/{quoted_database}", "", query, ""))
|
||||
|
||||
|
||||
def _parse_dsn(dsn: str) -> dict[str, Any]:
|
||||
"""Parse a PostgreSQL DSN into form params."""
|
||||
if not dsn:
|
||||
return {}
|
||||
try:
|
||||
url = urlparse(dsn)
|
||||
return {
|
||||
CONF_KNX_TELEGRAM_DB_USER: unquote(url.username or ""),
|
||||
CONF_KNX_TELEGRAM_DB_PASSWORD: unquote(url.password or ""),
|
||||
CONF_KNX_TELEGRAM_DB_HOST: url.hostname or "localhost",
|
||||
CONF_KNX_TELEGRAM_DB_PORT: url.port or 5432,
|
||||
CONF_KNX_TELEGRAM_DB_DATABASE: unquote(url.path.lstrip("/")),
|
||||
CONF_KNX_TELEGRAM_DB_TLS: "sslmode=require" in url.query,
|
||||
}
|
||||
except ValueError, AttributeError:
|
||||
return {}
|
||||
|
||||
@@ -53,8 +53,20 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
|
||||
|
||||
DEFAULT_ROUTING_IA: Final = "0.0.240"
|
||||
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: Final = "telegram_db_backend"
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days"
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours"
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: Final = "telegram_db_postgres_dsn"
|
||||
|
||||
CONF_KNX_TELEGRAM_DB_HOST: Final = "host"
|
||||
CONF_KNX_TELEGRAM_DB_PORT: Final = "port"
|
||||
CONF_KNX_TELEGRAM_DB_USER: Final = "user"
|
||||
CONF_KNX_TELEGRAM_DB_PASSWORD: Final = "password"
|
||||
CONF_KNX_TELEGRAM_DB_DATABASE: Final = "database"
|
||||
CONF_KNX_TELEGRAM_DB_TLS: Final = "tls"
|
||||
|
||||
KNX_TELEGRAM_BACKEND_SQLITE: Final = "sqlite"
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES: Final = "postgres"
|
||||
|
||||
KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days
|
||||
KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day
|
||||
@@ -139,6 +151,8 @@ class KNXConfigEntryOptions(TypedDict, total=False):
|
||||
# Integration only (not forwarded to xknx)
|
||||
telegram_db_retention_days: int
|
||||
telegram_db_load_hours: int
|
||||
telegram_db_backend: str # sqlite | postgres
|
||||
telegram_db_postgres_dsn: str
|
||||
|
||||
|
||||
class ColorTempModes(Enum):
|
||||
|
||||
@@ -15,6 +15,7 @@ from .const import (
|
||||
CONF_KNX_ROUTING_BACKBONE_KEY,
|
||||
CONF_KNX_SECURE_DEVICE_AUTHENTICATION,
|
||||
CONF_KNX_SECURE_USER_PASSWORD,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
DOMAIN,
|
||||
KNX_MODULE_KEY,
|
||||
)
|
||||
@@ -24,6 +25,7 @@ TO_REDACT = {
|
||||
CONF_KNX_KNXKEY_PASSWORD,
|
||||
CONF_KNX_SECURE_USER_PASSWORD,
|
||||
CONF_KNX_SECURE_DEVICE_AUTHENTICATION,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"xknx==3.16.0",
|
||||
"xknxproject==3.9.0",
|
||||
"knx-frontend==2026.6.23.203726",
|
||||
"knx-telegram-store[sqlite]==0.3.2"
|
||||
"knx-telegram-store[sqlite,postgres]==0.9.1"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1162,6 +1162,15 @@
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"database_missing": "The specified database does not exist.",
|
||||
"host_unreachable": "Could not reach the database host.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"missing_dependency": "Required database driver is not installed.",
|
||||
"permission": "Insufficient privileges to access the database.",
|
||||
"timeout": "Connection timed out."
|
||||
},
|
||||
"step": {
|
||||
"communication_settings": {
|
||||
"data": {
|
||||
@@ -1175,10 +1184,12 @@
|
||||
"sections": {
|
||||
"telegram_store_section": {
|
||||
"data": {
|
||||
"telegram_db_backend": "Telegram storage backend",
|
||||
"telegram_db_load_hours": "Group monitor history",
|
||||
"telegram_db_retention_days": "Retention period"
|
||||
},
|
||||
"data_description": {
|
||||
"telegram_db_backend": "Select where to store KNX telegram history.",
|
||||
"telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.",
|
||||
"telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run."
|
||||
},
|
||||
@@ -1186,6 +1197,25 @@
|
||||
}
|
||||
},
|
||||
"title": "Communication settings"
|
||||
},
|
||||
"telegram_store_postgres": {
|
||||
"data": {
|
||||
"database": "Database name",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"tls": "Use TLS",
|
||||
"user": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"database": "Name of the database to store telegrams in.",
|
||||
"host": "Hostname or IP address of the PostgreSQL server.",
|
||||
"password": "Password for the PostgreSQL user. Leave blank to keep the current password.",
|
||||
"port": "Port the PostgreSQL server is listening on.",
|
||||
"tls": "Encrypt the connection to the PostgreSQL server (`sslmode=require`). Note that the server certificate is not verified.",
|
||||
"user": "Username to authenticate with the PostgreSQL server."
|
||||
},
|
||||
"title": "PostgreSQL connection"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1260,6 +1290,12 @@
|
||||
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
|
||||
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
|
||||
}
|
||||
},
|
||||
"telegram_backend": {
|
||||
"options": {
|
||||
"postgres": "PostgreSQL (External)",
|
||||
"sqlite": "Internal storage (Default)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from knx_telegram_store import (
|
||||
BufferedPostgresStore,
|
||||
BufferedSqliteStore,
|
||||
KnxTelegramStoreException,
|
||||
StoredTelegram,
|
||||
@@ -26,7 +27,10 @@ from homeassistant.helpers.storage import STORAGE_DIR, Store
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
KNX_TELEGRAM_DB_PATH_SQLITE,
|
||||
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
|
||||
SIGNAL_KNX_TELEGRAM,
|
||||
@@ -48,6 +52,15 @@ EVICT_EXPIRED_HOUR = 3
|
||||
# at risk from a longer interval are those buffered during an ungraceful shutdown.
|
||||
FLUSH_INTERVAL_SECONDS = 600
|
||||
|
||||
# The buffer drops the oldest telegrams when full. Size it to cover a full
|
||||
# flush interval at ~50 telegrams/s, the maximum rate of a KNX TP line, so
|
||||
# nothing is dropped while the database is healthy.
|
||||
MAX_BUFFER_TELEGRAMS = FLUSH_INTERVAL_SECONDS * 50
|
||||
|
||||
# Timeout for the migration probe and store initialization, so an unreachable
|
||||
# database cannot block KNX setup until the driver/OS connection timeout expires.
|
||||
STORE_INIT_TIMEOUT = 10
|
||||
|
||||
|
||||
class DecodedTelegramPayload(TypedDict):
|
||||
"""Decoded payload value and metadata."""
|
||||
@@ -89,19 +102,32 @@ class Telegrams:
|
||||
self.project = project
|
||||
self.config = config
|
||||
|
||||
self.backend: str = config[CONF_KNX_TELEGRAM_DB_BACKEND]
|
||||
self.dsn: str = str(config.get(CONF_KNX_TELEGRAM_DB_POSTGRES_DSN, ""))
|
||||
self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS]
|
||||
|
||||
self.store: BufferedSqliteStore | None = None
|
||||
self._uninitialized_store: BufferedSqliteStore | None = None
|
||||
self.store: BufferedSqliteStore | BufferedPostgresStore | None = None
|
||||
self._uninitialized_store: (
|
||||
BufferedSqliteStore | BufferedPostgresStore | None
|
||||
) = None
|
||||
self._evict_expired_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
self._uninitialized_store = BufferedSqliteStore(
|
||||
full_path,
|
||||
retention_days=self.retention_days,
|
||||
flush_interval=FLUSH_INTERVAL_SECONDS,
|
||||
)
|
||||
if self.backend == KNX_TELEGRAM_BACKEND_POSTGRES:
|
||||
self._uninitialized_store = BufferedPostgresStore(
|
||||
self.dsn,
|
||||
retention_days=self.retention_days,
|
||||
flush_interval=FLUSH_INTERVAL_SECONDS,
|
||||
max_buffer_size=MAX_BUFFER_TELEGRAMS,
|
||||
)
|
||||
else:
|
||||
full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
self._uninitialized_store = BufferedSqliteStore(
|
||||
full_path,
|
||||
retention_days=self.retention_days,
|
||||
flush_interval=FLUSH_INTERVAL_SECONDS,
|
||||
max_buffer_size=MAX_BUFFER_TELEGRAMS,
|
||||
)
|
||||
|
||||
self._xknx_telegram_cb_handle = (
|
||||
xknx.telegram_queue.register_telegram_received_cb(
|
||||
@@ -121,7 +147,8 @@ class Telegrams:
|
||||
if self._uninitialized_store is None:
|
||||
return
|
||||
try:
|
||||
needs_migration = await self._uninitialized_store.needs_migration()
|
||||
async with asyncio.timeout(STORE_INIT_TIMEOUT):
|
||||
needs_migration = await self._uninitialized_store.needs_migration()
|
||||
if needs_migration:
|
||||
_LOGGER.warning(
|
||||
"KNX telegram history database schema upgrade/migration is required. "
|
||||
@@ -129,24 +156,35 @@ class Telegrams:
|
||||
)
|
||||
await self._uninitialized_store.initialize()
|
||||
else:
|
||||
_LOGGER.debug("Initializing KNX telegram storage")
|
||||
async with asyncio.timeout(10):
|
||||
_LOGGER.debug(
|
||||
"Initializing KNX telegram storage backend '%s'",
|
||||
self.backend,
|
||||
)
|
||||
async with asyncio.timeout(STORE_INIT_TIMEOUT):
|
||||
await self._uninitialized_store.initialize()
|
||||
_LOGGER.info("Successfully initialized KNX telegram storage")
|
||||
_LOGGER.info(
|
||||
"Successfully initialized KNX telegram storage backend '%s'",
|
||||
self.backend,
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.error("Timeout initializing KNX telegram storage")
|
||||
_LOGGER.error(
|
||||
"Timeout initializing KNX telegram storage backend '%s'",
|
||||
self.backend,
|
||||
)
|
||||
await self._abort_store_init()
|
||||
return
|
||||
except KnxTelegramStoreException as err:
|
||||
_LOGGER.error(
|
||||
"Database error initializing KNX telegram storage: %s",
|
||||
"Database error initializing KNX telegram storage backend '%s': %s",
|
||||
self.backend,
|
||||
err,
|
||||
)
|
||||
await self._abort_store_init()
|
||||
return
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Error initializing KNX telegram storage: %s",
|
||||
"Error initializing KNX telegram storage backend '%s': %s",
|
||||
self.backend,
|
||||
err,
|
||||
)
|
||||
await self._abort_store_init()
|
||||
|
||||
@@ -8,7 +8,12 @@ import inspect
|
||||
from typing import TYPE_CHECKING, Any, Final, overload
|
||||
|
||||
import knx_frontend as knx_panel
|
||||
from knx_telegram_store import KnxTelegramStoreException, TelegramQuery
|
||||
from knx_telegram_store import (
|
||||
BufferedPostgresStore,
|
||||
BufferedSqliteStore,
|
||||
KnxTelegramStoreException,
|
||||
TelegramQuery,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from xknx.telegram import Telegram
|
||||
from xknxproject.exceptions import XknxProjectException
|
||||
@@ -200,7 +205,11 @@ def ws_get_base_data(
|
||||
"connected": knx.xknx.connection_manager.connected.is_set(),
|
||||
"current_address": str(knx.xknx.current_address),
|
||||
"telegram_backend": (
|
||||
"sqlite" if knx.telegrams.store is not None else "unknown"
|
||||
"sqlite"
|
||||
if isinstance(knx.telegrams.store, BufferedSqliteStore)
|
||||
else "postgres"
|
||||
if isinstance(knx.telegrams.store, BufferedPostgresStore)
|
||||
else "unknown"
|
||||
),
|
||||
"telegram_retention": knx.telegrams.store.retention_days
|
||||
if knx.telegrams.store is not None
|
||||
|
||||
Generated
+1
-1
@@ -1435,7 +1435,7 @@ knocki==0.4.2
|
||||
knx-frontend==2026.6.23.203726
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-telegram-store[sqlite]==0.3.2
|
||||
knx-telegram-store[sqlite,postgres]==0.9.1
|
||||
|
||||
# homeassistant.components.kraken
|
||||
krakenex==2.2.2
|
||||
|
||||
@@ -32,10 +32,12 @@ from homeassistant.components.knx.const import (
|
||||
CONF_KNX_MCAST_PORT,
|
||||
CONF_KNX_RATE_LIMIT,
|
||||
CONF_KNX_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
DEFAULT_ROUTING_IA,
|
||||
DOMAIN,
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
)
|
||||
@@ -364,6 +366,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
'config_entry_options': dict({
|
||||
'rate_limit': 0,
|
||||
'state_updater': True,
|
||||
'telegram_db_backend': 'sqlite',
|
||||
'telegram_db_load_hours': 24,
|
||||
'telegram_db_retention_days': 10,
|
||||
}),
|
||||
@@ -48,7 +49,9 @@
|
||||
'config_entry_options': dict({
|
||||
'rate_limit': 0,
|
||||
'state_updater': True,
|
||||
'telegram_db_backend': 'sqlite',
|
||||
'telegram_db_load_hours': 24,
|
||||
'telegram_db_postgres_dsn': '**REDACTED**',
|
||||
'telegram_db_retention_days': 10,
|
||||
}),
|
||||
'config_store': dict({
|
||||
@@ -79,6 +82,7 @@
|
||||
'config_entry_options': dict({
|
||||
'rate_limit': 0,
|
||||
'state_updater': True,
|
||||
'telegram_db_backend': 'sqlite',
|
||||
'telegram_db_load_hours': 24,
|
||||
'telegram_db_retention_days': 10,
|
||||
}),
|
||||
@@ -110,6 +114,7 @@
|
||||
'config_entry_options': dict({
|
||||
'rate_limit': 0,
|
||||
'state_updater': True,
|
||||
'telegram_db_backend': 'sqlite',
|
||||
'telegram_db_load_hours': 24,
|
||||
'telegram_db_retention_days': 10,
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Test the KNX config flow."""
|
||||
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
from knx_telegram_store.connection import ConnectionCheckResult, ConnectionErrorKind
|
||||
import pytest
|
||||
from xknx.exceptions import XKNXException
|
||||
from xknx.exceptions.exception import CommunicationError, InvalidSecureConfiguration
|
||||
@@ -21,6 +23,8 @@ from homeassistant.components.knx.config_flow import (
|
||||
DEFAULT_ENTRY_DATA,
|
||||
DEFAULT_ENTRY_OPTIONS,
|
||||
OPTION_MANUAL_TUNNEL,
|
||||
_build_dsn,
|
||||
_parse_dsn,
|
||||
)
|
||||
from homeassistant.components.knx.const import (
|
||||
CONF_KNX_AUTOMATIC,
|
||||
@@ -41,13 +45,17 @@ from homeassistant.components.knx.const import (
|
||||
CONF_KNX_SECURE_USER_ID,
|
||||
CONF_KNX_SECURE_USER_PASSWORD,
|
||||
CONF_KNX_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
CONF_KNX_TUNNEL_ENDPOINT_IA,
|
||||
CONF_KNX_TUNNELING,
|
||||
CONF_KNX_TUNNELING_TCP,
|
||||
CONF_KNX_TUNNELING_TCP_SECURE,
|
||||
DOMAIN,
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
)
|
||||
@@ -1065,6 +1073,7 @@ async def test_form_with_automatic_connection_handling(
|
||||
CONF_KNX_STATE_UPDATER: True,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
}
|
||||
knx_setup.assert_called_once()
|
||||
|
||||
@@ -1690,6 +1699,7 @@ async def test_options_communication_settings(
|
||||
CONF_KNX_TELEGRAM_STORE_SECTION: {
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -1699,6 +1709,7 @@ async def test_options_communication_settings(
|
||||
CONF_KNX_RATE_LIMIT: 40,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
}
|
||||
assert mock_config_entry.data == initial_data
|
||||
assert mock_config_entry.options == {
|
||||
@@ -1706,5 +1717,313 @@ async def test_options_communication_settings(
|
||||
CONF_KNX_RATE_LIMIT: 40,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
}
|
||||
assert len(knx_setup.mock_calls) == 2
|
||||
|
||||
|
||||
async def _advance_to_postgres_step(
|
||||
hass: HomeAssistant, flow_id: str, *, retention_days: int = 14
|
||||
) -> config_entries.ConfigFlowResult:
|
||||
"""Select the PostgreSQL backend and land on its connection step."""
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
flow_id,
|
||||
user_input={
|
||||
CONF_KNX_STATE_UPDATER: False,
|
||||
CONF_KNX_RATE_LIMIT: 40,
|
||||
CONF_KNX_TELEGRAM_STORE_SECTION: {
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: retention_days,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "telegram_store_postgres"
|
||||
assert not result["errors"]
|
||||
return result
|
||||
|
||||
|
||||
async def test_options_telegram_store_postgres(
|
||||
hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test options flow selecting the PostgreSQL telegram store backend."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
result = await _advance_to_postgres_step(hass, result["flow_id"])
|
||||
with patch(
|
||||
"knx_telegram_store.backends.postgres.PostgresStore.check_config",
|
||||
return_value=ConnectionCheckResult.success(),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"host": "db.local",
|
||||
"port": 5432,
|
||||
"user": "knx",
|
||||
"password": "s3cret",
|
||||
"database": "knx_telegrams",
|
||||
"tls": True,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert (
|
||||
mock_config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND]
|
||||
== KNX_TELEGRAM_BACKEND_POSTGRES
|
||||
)
|
||||
assert mock_config_entry.options[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS] == 14
|
||||
assert (
|
||||
mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN]
|
||||
== "postgresql://knx:s3cret@db.local:5432/knx_telegrams?sslmode=require"
|
||||
)
|
||||
assert len(knx_setup.mock_calls) == 2
|
||||
|
||||
|
||||
async def test_options_telegram_store_postgres_reuses_password(
|
||||
hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test the PostgreSQL store reuses the stored password when left blank."""
|
||||
existing_dsn = "postgresql://olduser:oldpass@old.host:6543/olddb?sslmode=require"
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
options={
|
||||
**mock_config_entry.options,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: existing_dsn,
|
||||
},
|
||||
)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
result = await _advance_to_postgres_step(hass, result["flow_id"], retention_days=7)
|
||||
|
||||
# Submit with an empty password - the existing one (parsed from the DSN)
|
||||
# must be reused.
|
||||
with patch(
|
||||
"knx_telegram_store.backends.postgres.PostgresStore.check_config",
|
||||
return_value=ConnectionCheckResult.success(),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"host": "new.host",
|
||||
"port": 5432,
|
||||
"user": "newuser",
|
||||
"password": "",
|
||||
"database": "newdb",
|
||||
"tls": False,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert (
|
||||
mock_config_entry.options[CONF_KNX_TELEGRAM_DB_POSTGRES_DSN]
|
||||
== "postgresql://newuser:oldpass@new.host:5432/newdb"
|
||||
)
|
||||
assert len(knx_setup.mock_calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error_kind", "expected_error"),
|
||||
[
|
||||
pytest.param(ConnectionErrorKind.AUTH, "invalid_auth", id="invalid_auth"),
|
||||
pytest.param(
|
||||
ConnectionErrorKind.HOST_UNREACHABLE,
|
||||
"host_unreachable",
|
||||
id="host_unreachable",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_options_telegram_store_postgres_connection_failure(
|
||||
hass: HomeAssistant,
|
||||
knx_setup: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
error_kind: ConnectionErrorKind,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test the PostgreSQL step maps connection check failures to form errors."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
result = await _advance_to_postgres_step(hass, result["flow_id"])
|
||||
with patch(
|
||||
"knx_telegram_store.backends.postgres.PostgresStore.check_config",
|
||||
return_value=ConnectionCheckResult.failure(error_kind, "check failed"),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"host": "db.local",
|
||||
"port": 5432,
|
||||
"user": "knx",
|
||||
"password": "wrong_password",
|
||||
"database": "knx_telegrams",
|
||||
"tls": True,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "telegram_store_postgres"
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
|
||||
async def test_options_telegram_store_postgres_timeout(
|
||||
hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test options flow surfaces a timeout when the connection check hangs."""
|
||||
|
||||
async def hanging_check(dsn: str) -> None:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
result = await _advance_to_postgres_step(hass, result["flow_id"])
|
||||
with (
|
||||
patch("homeassistant.components.knx.config_flow.DSN_CHECK_TIMEOUT", 0.05),
|
||||
patch(
|
||||
"knx_telegram_store.backends.postgres.PostgresStore.check_config",
|
||||
side_effect=hanging_check,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"host": "db.local",
|
||||
"port": 5432,
|
||||
"user": "knx",
|
||||
"password": "s3cret",
|
||||
"database": "knx_telegrams",
|
||||
"tls": True,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "telegram_store_postgres"
|
||||
assert result["errors"] == {"base": "timeout"}
|
||||
|
||||
|
||||
async def test_options_telegram_store_postgres_malformed_dsn(
|
||||
hass: HomeAssistant, knx_setup: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test the PostgreSQL step maps a DSN the driver rejects to a form error."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
result = await _advance_to_postgres_step(hass, result["flow_id"])
|
||||
# An unterminated bracketed IPv6 address makes engine creation
|
||||
# raise ValueError before any connection attempt.
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"host": "[::1",
|
||||
"port": 5432,
|
||||
"user": "knx",
|
||||
"password": "s3cret",
|
||||
"database": "knx_telegrams",
|
||||
"tls": False,
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "telegram_store_postgres"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dsn", "expected"),
|
||||
[
|
||||
pytest.param("", {}, id="empty"),
|
||||
# Invalid port makes urlparse.port raise ValueError -> {}
|
||||
pytest.param("postgresql://host:notaport/db", {}, id="invalid_port"),
|
||||
pytest.param(
|
||||
"postgresql://u:p@h:5432/db?sslmode=require",
|
||||
{
|
||||
"user": "u",
|
||||
"password": "p",
|
||||
"host": "h",
|
||||
"port": 5432,
|
||||
"database": "db",
|
||||
"tls": True,
|
||||
},
|
||||
id="full",
|
||||
),
|
||||
pytest.param(
|
||||
"postgresql://user%40domain:p%40ss%25word@h:5432/db",
|
||||
{
|
||||
"user": "user@domain",
|
||||
"password": "p@ss%word",
|
||||
"host": "h",
|
||||
"port": 5432,
|
||||
"database": "db",
|
||||
"tls": False,
|
||||
},
|
||||
id="percent_encoded_credentials",
|
||||
),
|
||||
pytest.param(
|
||||
"postgresql://u:p@[2001:db8::1]:5432/db",
|
||||
{
|
||||
"user": "u",
|
||||
"password": "p",
|
||||
"host": "2001:db8::1",
|
||||
"port": 5432,
|
||||
"database": "db",
|
||||
"tls": False,
|
||||
},
|
||||
id="ipv6_host",
|
||||
),
|
||||
pytest.param(
|
||||
"postgresql://u:p@h:5432/db%3Fquery%23hash",
|
||||
{
|
||||
"user": "u",
|
||||
"password": "p",
|
||||
"host": "h",
|
||||
"port": 5432,
|
||||
"database": "db?query#hash",
|
||||
"tls": False,
|
||||
},
|
||||
id="percent_encoded_database",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_parse_dsn(dsn: str, expected: dict) -> None:
|
||||
"""Test PostgreSQL DSN parsing, including malformed input."""
|
||||
assert _parse_dsn(dsn) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user", "password", "host", "database"),
|
||||
[
|
||||
pytest.param("simple", "plain", "localhost", "knx", id="plain"),
|
||||
pytest.param("user@domain", "p@ss", "localhost", "knx", id="at_sign"),
|
||||
pytest.param("user", "p@ss%word", "localhost", "knx", id="percent_sign"),
|
||||
pytest.param(
|
||||
"us:er", "p/a:s@s", "localhost", "knx", id="multiple_special_chars"
|
||||
),
|
||||
pytest.param("user", "pass", "2001:db8::1", "knx", id="ipv6_host"),
|
||||
pytest.param(
|
||||
"user", "pass", "localhost", "knx?query#hash", id="database_special_chars"
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_dsn_round_trip(user: str, password: str, host: str, database: str) -> None:
|
||||
"""Test _build_dsn -> _parse_dsn -> _build_dsn produces identical DSNs.
|
||||
|
||||
Catches double percent-encoding: urlparse returns percent-encoded values,
|
||||
so _parse_dsn must decode them before they are fed back into _build_dsn.
|
||||
IPv6 hosts must be bracketed in the netloc for the DSN to stay parseable.
|
||||
Database names with URL delimiters are percent-encoded to prevent truncation.
|
||||
"""
|
||||
params = {
|
||||
"user": user,
|
||||
"password": password,
|
||||
"host": host,
|
||||
"port": 5432,
|
||||
"database": database,
|
||||
"tls": False,
|
||||
}
|
||||
dsn1 = _build_dsn(params)
|
||||
parsed = _parse_dsn(dsn1)
|
||||
dsn2 = _build_dsn(parsed)
|
||||
assert dsn1 == dsn2
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.components.knx.const import (
|
||||
CONF_KNX_SECURE_DEVICE_AUTHENTICATION,
|
||||
CONF_KNX_SECURE_USER_PASSWORD,
|
||||
CONF_KNX_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
DEFAULT_ROUTING_IA,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -100,6 +101,11 @@ async def test_diagnostic_redact(
|
||||
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: "device_authentication",
|
||||
CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44bbaacc44bbaacc44",
|
||||
},
|
||||
options={
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: (
|
||||
"postgresql://knx:supersecret@localhost:5432/knx_telegrams"
|
||||
),
|
||||
},
|
||||
)
|
||||
knx: KNXTestKit = KNXTestKit(hass, mock_config_entry, hass_storage)
|
||||
await knx.setup_integration()
|
||||
|
||||
@@ -38,12 +38,14 @@ from homeassistant.components.knx.const import (
|
||||
CONF_KNX_SECURE_USER_ID,
|
||||
CONF_KNX_SECURE_USER_PASSWORD,
|
||||
CONF_KNX_STATE_UPDATER,
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
CONF_KNX_TUNNELING,
|
||||
CONF_KNX_TUNNELING_TCP,
|
||||
CONF_KNX_TUNNELING_TCP_SECURE,
|
||||
DOMAIN,
|
||||
KNX_TELEGRAM_BACKEND_SQLITE,
|
||||
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
|
||||
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
|
||||
KNXConfigEntryData,
|
||||
@@ -437,3 +439,30 @@ async def test_async_migrate_entry_future_version(hass: HomeAssistant) -> None:
|
||||
|
||||
with patch("homeassistant.components.knx.async_setup_entry", return_value=True):
|
||||
assert not await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_async_migrate_entry_v2_to_v2_2(hass: HomeAssistant) -> None:
|
||||
"""Test KNX config entry migration from v2.x to v2.2."""
|
||||
config_entry = MockConfigEntry(
|
||||
title="KNX",
|
||||
domain=DOMAIN,
|
||||
version=2,
|
||||
minor_version=1,
|
||||
data={
|
||||
"other_setting": "some_value",
|
||||
},
|
||||
options={
|
||||
"some_option": "value",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.knx.async_setup_entry", return_value=True):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
assert config_entry.version == 2
|
||||
assert config_entry.minor_version == 2
|
||||
assert (
|
||||
config_entry.options[CONF_KNX_TELEGRAM_DB_BACKEND]
|
||||
== KNX_TELEGRAM_BACKEND_SQLITE
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from copy import copy
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch
|
||||
@@ -11,9 +12,12 @@ from knx_telegram_store import KnxTelegramStoreException, StoredTelegram, Telegr
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.knx.const import (
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
|
||||
DOMAIN,
|
||||
KNX_MODULE_KEY,
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
|
||||
)
|
||||
from homeassistant.components.knx.telegrams import TelegramDict
|
||||
@@ -156,6 +160,34 @@ async def test_store_telegram_history_error_handling(
|
||||
assert issue is not None
|
||||
|
||||
|
||||
async def test_store_telegram_history_needs_migration_timeout(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
) -> None:
|
||||
"""Test that store initialization is aborted when needs_migration times out."""
|
||||
|
||||
async def hanging_probe() -> bool:
|
||||
await asyncio.Event().wait()
|
||||
return False
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.knx.telegrams.STORE_INIT_TIMEOUT", 0.05),
|
||||
patch(
|
||||
"knx_telegram_store.BufferedSqliteStore.needs_migration",
|
||||
side_effect=hanging_probe,
|
||||
),
|
||||
):
|
||||
await knx.setup_integration()
|
||||
|
||||
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
|
||||
assert telegrams_module.store is None
|
||||
|
||||
# Check that the repair issue was created
|
||||
issue_registry = ir.async_get(hass)
|
||||
issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR)
|
||||
assert issue is not None
|
||||
|
||||
|
||||
async def test_migrate_telegrams_from_json(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
@@ -483,3 +515,39 @@ async def test_nightly_eviction_error_handling(
|
||||
assert "Database error evicting expired KNX telegrams" in caplog.text
|
||||
# Store remains operational after the failed eviction
|
||||
assert telegrams_module.store is not None
|
||||
|
||||
|
||||
async def test_postgres_backend_init_error(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
) -> None:
|
||||
"""Test PostgreSQL backend DSN handling and init failure path."""
|
||||
dsn = "postgresql://user:secret@db.local:5432/knx"
|
||||
knx.mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
knx.mock_config_entry,
|
||||
options=knx.mock_config_entry.options
|
||||
| {
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: dsn,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock the store to avoid constructing a real SQLAlchemy engine / connecting.
|
||||
mock_store = AsyncMock()
|
||||
mock_store.needs_migration.return_value = False
|
||||
mock_store.initialize.side_effect = KnxTelegramStoreException("no server")
|
||||
with patch(
|
||||
"homeassistant.components.knx.telegrams.BufferedPostgresStore",
|
||||
return_value=mock_store,
|
||||
):
|
||||
await knx.setup_integration(add_entry_to_hass=False)
|
||||
|
||||
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
|
||||
assert telegrams_module.store is None
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert (
|
||||
issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR)
|
||||
is not None
|
||||
)
|
||||
|
||||
@@ -10,8 +10,11 @@ import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.knx.const import (
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN,
|
||||
KNX_ADDRESS,
|
||||
KNX_MODULE_KEY,
|
||||
KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
SUPPORTED_PLATFORMS_UI,
|
||||
)
|
||||
from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY
|
||||
@@ -37,10 +40,46 @@ async def test_knx_get_base_data_command(
|
||||
assert res["result"]["connection_info"]["version"] is not None
|
||||
assert res["result"]["connection_info"]["connected"]
|
||||
assert res["result"]["connection_info"]["current_address"] == "0.0.0"
|
||||
assert res["result"]["connection_info"]["telegram_backend"] == "sqlite"
|
||||
assert res["result"]["project_info"] is None
|
||||
assert not SUPPORTED_PLATFORMS_UI.difference(res["result"]["supported_platforms"])
|
||||
|
||||
|
||||
async def test_knx_get_base_data_command_postgres(
|
||||
hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test knx/get_base_data reports the PostgreSQL telegram backend."""
|
||||
knx.mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
knx.mock_config_entry,
|
||||
options=knx.mock_config_entry.options
|
||||
| {
|
||||
CONF_KNX_TELEGRAM_DB_BACKEND: KNX_TELEGRAM_BACKEND_POSTGRES,
|
||||
CONF_KNX_TELEGRAM_DB_POSTGRES_DSN: "postgresql://user:pw@db.local:5432/knx",
|
||||
},
|
||||
)
|
||||
# Patch methods on the real class so the isinstance check in the
|
||||
# websocket handler still sees a BufferedPostgresStore instance.
|
||||
with (
|
||||
patch(
|
||||
"knx_telegram_store.BufferedPostgresStore.needs_migration",
|
||||
return_value=False,
|
||||
),
|
||||
patch("knx_telegram_store.BufferedPostgresStore.initialize"),
|
||||
patch(
|
||||
"knx_telegram_store.BufferedPostgresStore.get_last_unique_telegrams",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
await knx.setup_integration(add_entry_to_hass=False)
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id({"type": "knx/get_base_data"})
|
||||
res = await client.receive_json()
|
||||
|
||||
assert res["success"], res
|
||||
assert res["result"]["connection_info"]["telegram_backend"] == "postgres"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("load_knxproj")
|
||||
async def test_knx_get_base_data_command_with_project(
|
||||
hass: HomeAssistant,
|
||||
|
||||
Reference in New Issue
Block a user