diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index db369d00d0a9..701933000487 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -14,7 +14,7 @@ "xknx==3.17.0", "xknxproject==3.9.0", "knx-frontend==2026.7.23.145751", - "knx-telegram-store[sqlite,postgres]==0.10.2" + "knx-telegram-store[sqlite,postgres]==0.11.1" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 7bc079cd3ab4..92af9e26573b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1438,7 +1438,7 @@ knocki==0.4.2 knx-frontend==2026.7.23.145751 # homeassistant.components.knx -knx-telegram-store[sqlite,postgres]==0.10.2 +knx-telegram-store[sqlite,postgres]==0.11.1 # homeassistant.components.kraken krakenex==2.2.2 diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 5cfab33adf34..0a93216f8efb 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -3,10 +3,16 @@ from __future__ import annotations import asyncio +from collections.abc import Generator, Iterator +import contextlib from typing import Any from unittest.mock import DEFAULT, AsyncMock, Mock, patch -from knx_telegram_store import BufferedSqliteStore +from knx_telegram_store import ( + BufferedMemoryStore, + BufferedPostgresStore, + BufferedSqliteStore, +) import pytest from xknx import XKNX from xknx.core import XknxConnectionState, XknxConnectionType @@ -22,6 +28,7 @@ from xknx.telegram.apci import ( SecureAPDU, ) +from homeassistant.components.knx import telegrams as knx_telegrams from homeassistant.components.knx.const import ( CONF_KNX_AUTOMATIC, CONF_KNX_CONNECTION_TYPE, @@ -57,6 +64,58 @@ from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.typing import WebSocketGenerator +class _TestTelegramStore(BufferedMemoryStore): + """Drop-in for the SQL-backed stores that keeps telegrams in memory. + + Avoids the per-test cost of creating a SQLAlchemy engine and an aiosqlite + background thread. Accepts (and ignores) the ``db_path``/``dsn`` and + ``retention_days`` arguments the integration passes to the SQL stores. + """ + + def __init__( + self, + *_args: Any, + flush_interval: float = 1.0, + max_buffer_size: int = 10000, + **_kwargs: Any, + ) -> None: + """Initialize the in-memory test store.""" + super().__init__(flush_interval=flush_interval, max_buffer_size=max_buffer_size) + + +@contextlib.contextmanager +def _patch_telegram_store(*, real_store: bool) -> Generator: + """Choose the telegram store the integration builds during setup. + + Default (``real_store=False``): swap in the fast in-memory store so tests + that don't care about telegram history skip SQLite engine and aiosqlite + thread setup. ``real_store=True``: build the real SQLite store (kept + in-memory) and the real PostgreSQL store, for tests that exercise the + database backends. + """ + if not real_store: + with ( + patch.object(knx_telegrams, "BufferedSqliteStore", _TestTelegramStore), + patch.object(knx_telegrams, "BufferedPostgresStore", _TestTelegramStore), + ): + yield + return + + original_init = BufferedSqliteStore.__init__ + + def mocked_init(self, db_path: str, *args: Any, **kwargs: Any) -> None: + original_init(self, ":memory:", *args, **kwargs) + + # Restore the real store classes (the autouse fixture points them at the + # in-memory store) and keep the SQLite database in-memory. + with ( + patch.object(knx_telegrams, "BufferedSqliteStore", BufferedSqliteStore), + patch.object(knx_telegrams, "BufferedPostgresStore", BufferedPostgresStore), + patch.object(BufferedSqliteStore, "__init__", mocked_init), + ): + yield + + class KNXTestKit: """Test helper for the KNX integration.""" @@ -90,9 +149,14 @@ class KNXTestKit: config_store_fixture: str | None = None, add_entry_to_hass: bool = True, state_updater: bool = True, + real_telegram_store: bool = False, ) -> None: - """Create the KNX integration.""" - # Force an in-memory telegram store will be done via autouse fixture. + """Create the KNX integration. + + By default a fast in-memory telegram store is used. Tests that exercise + the SQLite/PostgreSQL telegram backends pass ``real_telegram_store=True`` + to build the real SQLite store (kept in-memory). + """ async def patch_xknx_start(): """Patch `xknx.start` for unittests.""" @@ -139,10 +203,13 @@ class KNXTestKit: ).start() # keep patched for the whole test run knx_config = {DOMAIN: yaml_config or {}} - with patch( - "xknx.xknx.knx_interface_factory", - return_value=knx_ip_interface_mock(), - side_effect=fish_xknx, + with ( + _patch_telegram_store(real_store=real_telegram_store), + patch( + "xknx.xknx.knx_interface_factory", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, + ), ): state_updater_patcher = patch( "xknx.xknx.StateUpdater.register_remote_value" @@ -445,12 +512,14 @@ async def create_ui_entity( @pytest.fixture(autouse=True) -def mock_knx_telegram_store(): - """Mock knx-telegram-store to always use an in-memory database.""" - original_init = BufferedSqliteStore.__init__ +def mock_knx_telegram_store() -> Iterator[None]: + """Default every integration setup to the fast in-memory telegram store. - def mocked_init(self, db_path: str, *args: Any, **kwargs: Any) -> None: - original_init(self, ":memory:", *args, **kwargs) - - with patch.object(BufferedSqliteStore, "__init__", mocked_init): + The real SQLite store creates a SQLAlchemy engine and an aiosqlite thread + on every setup/teardown, which the hundreds of tests that never touch + telegram history shouldn't pay for. Tests that do exercise the database + backends pass ``real_telegram_store=True`` to ``setup_integration``, which + overrides this for the duration of setup. + """ + with _patch_telegram_store(real_store=False): yield diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index d2a88df2b8ac..71e6db9ebf94 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -90,7 +90,7 @@ async def test_store_telegram_history( knx: KNXTestKit, ) -> None: """Test storing telegram history.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams await knx.receive_write("1/3/4", True) @@ -118,7 +118,7 @@ async def test_store_telegram_history_sqlite( knx: KNXTestKit, ) -> None: """Test storing telegram history in SQLite.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams await knx.receive_write("1/3/4", True) @@ -151,7 +151,7 @@ async def test_store_telegram_history_error_handling( "knx_telegram_store.BufferedSqliteStore.initialize", side_effect=side_effect, ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is None @@ -180,7 +180,7 @@ async def test_store_telegram_history_needs_migration_timeout( side_effect=hanging_probe, ), ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is None @@ -209,7 +209,7 @@ async def test_store_init_timeout_retries_and_succeeds( return_value=[], ), ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams @@ -245,7 +245,7 @@ async def test_store_init_timeout_exhausts_retries_and_aborts( side_effect=TimeoutError(), ), ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams @@ -277,7 +277,7 @@ async def test_stop_cancels_pending_retry_timer( "knx_telegram_store.BufferedSqliteStore.initialize", side_effect=TimeoutError(), ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) assert hass.data[KNX_MODULE_KEY].telegrams.store is None assert await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) @@ -309,7 +309,7 @@ async def test_stop_cancels_in_flight_retry_task( "knx_telegram_store.BufferedSqliteStore.initialize", side_effect=initialize_side_effect, ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) freezer.tick(STORE_INIT_RETRY_BACKOFF[0] + 1) async_fire_time_changed(hass) @@ -338,7 +338,7 @@ async def test_migrate_telegrams_from_json( "data": json_telegrams, } - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -361,7 +361,7 @@ async def test_stop_error_handling( side_effect: Exception, ) -> None: """Test that errors while stopping the store are swallowed.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -377,7 +377,7 @@ async def test_model_to_dict_resolution( knx: KNXTestKit, ) -> None: """Test model_to_dict name resolution and DPT handling.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.project.loaded @@ -458,7 +458,7 @@ async def test_load_history_needs_migration( "knx_telegram_store.BufferedSqliteStore.needs_migration", return_value=True, ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -481,7 +481,7 @@ async def test_load_history_hydrate_error( "knx_telegram_store.BufferedSqliteStore.get_last_unique_telegrams", side_effect=side_effect, ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -493,7 +493,7 @@ async def test_migrate_telegrams_no_json( knx: KNXTestKit, ) -> None: """Test migration is a no-op when there is no legacy JSON history.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -514,7 +514,7 @@ async def test_migrate_telegrams_unexpected_format( "data": "not a list or dict", } - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -556,7 +556,7 @@ async def test_migrate_telegrams_store_error( "knx_telegram_store.BufferedSqliteStore.store_many", side_effect=KnxTelegramStoreException("write failed"), ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) # Setup still succeeds even though migration failed telegrams_module = hass.data[KNX_MODULE_KEY].telegrams @@ -571,7 +571,7 @@ async def test_nightly_eviction_calls_evict_expired( """Test expired telegrams are evicted on the nightly 3 AM run.""" await hass.config.async_set_time_zone("UTC") freezer.move_to("2024-01-01 12:00:00+00:00") - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -606,7 +606,7 @@ async def test_nightly_eviction_zero_retention_deletes_all( options=knx.mock_config_entry.options | {CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 0}, ) - await knx.setup_integration(add_entry_to_hass=False) + await knx.setup_integration(add_entry_to_hass=False, real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -632,7 +632,7 @@ async def test_nightly_eviction_error_handling( """Test a store error during nightly eviction is logged and does not raise.""" await hass.config.async_set_time_zone("UTC") freezer.move_to("2024-01-01 12:00:00+00:00") - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is not None @@ -674,7 +674,7 @@ async def test_postgres_backend_init_error( "homeassistant.components.knx.telegrams.BufferedPostgresStore", return_value=mock_store, ): - await knx.setup_integration(add_entry_to_hass=False) + await knx.setup_integration(add_entry_to_hass=False, real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams assert telegrams_module.store is None diff --git a/tests/components/knx/test_telegrams_migration.py b/tests/components/knx/test_telegrams_migration.py index 5421974e9de3..e27cc79ceaf1 100644 --- a/tests/components/knx/test_telegrams_migration.py +++ b/tests/components/knx/test_telegrams_migration.py @@ -32,7 +32,7 @@ async def test_migrate_telegrams_json_to_sqlite( # returns the list. Save the inner list, not the fixture wrapper dict. await store.async_save(legacy_data["data"]) - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams await hass.async_block_till_done() @@ -110,7 +110,7 @@ async def test_migrate_telegrams_json_missing_keys( await store.async_save(legacy_data) - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) telegrams_module = hass.data[KNX_MODULE_KEY].telegrams await hass.async_block_till_done() diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 124122d99117..c7f970b53aaf 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -31,7 +31,7 @@ async def test_knx_get_base_data_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/get_base_data command.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "knx/get_base_data"}) @@ -71,7 +71,7 @@ async def test_knx_get_base_data_command_postgres( return_value=[], ), ): - await knx.setup_integration(add_entry_to_hass=False) + await knx.setup_integration(add_entry_to_hass=False, real_telegram_store=True) client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "knx/get_base_data"}) res = await client.receive_json() @@ -236,7 +236,7 @@ async def test_knx_group_monitor_info_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/group_monitor_info command.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) client = await hass_ws_client(hass) await client.send_json_auto_id({"type": "knx/group_monitor_info"}) @@ -251,7 +251,7 @@ async def test_knx_query_telegrams_command( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator ) -> None: """Test knx/query_telegrams command.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) client = await hass_ws_client(hass) # get some telegrams to populate the store @@ -312,7 +312,7 @@ async def test_telegram_store_not_initialized( "knx_telegram_store.BufferedSqliteStore.initialize", side_effect=KnxTelegramStoreException("init failed"), ): - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].telegrams.store is None @@ -334,7 +334,7 @@ async def test_telegram_store_query_database_error( command: str, ) -> None: """Test telegram commands when the store query raises a database error.""" - await knx.setup_integration() + await knx.setup_integration(real_telegram_store=True) client = await hass_ws_client(hass) store = hass.data[KNX_MODULE_KEY].telegrams.store @@ -387,7 +387,8 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams( CONF_NAME: "test", KNX_ADDRESS: "1/2/4", } - } + }, + real_telegram_store=True, ) # send incoming telegram