knx-telegram-store 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Höfling
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: knx-telegram-store
3
+ Version: 0.1.0
4
+ Summary: A standalone, host-agnostic Python library for KNX telegram persistence.
5
+ Author: Martin Hoefling
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/XKNX/knx-telegram-store
8
+ Project-URL: Bug-Tracker, https://github.com/XKNX/knx-telegram-store/issues
9
+ Keywords: knx,home-assistant,persistence,storage,telegram
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Home Automation
14
+ Classifier: Framework :: AsyncIO
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: sqlite
19
+ Requires-Dist: aiosqlite>=0.20; extra == "sqlite"
20
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == "sqlite"
21
+ Provides-Extra: postgres
22
+ Requires-Dist: asyncpg>=0.29; extra == "postgres"
23
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == "postgres"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
27
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
28
+ Requires-Dist: ruff>=0.3; extra == "dev"
29
+ Requires-Dist: mypy>=1.9; extra == "dev"
30
+ Requires-Dist: aiosqlite>=0.20; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # knx-telegram-store
34
+
35
+ A standalone, host-agnostic Python library for KNX telegram persistence.
36
+
37
+ ## Features
38
+
39
+ - **Canonical Data Model**: A unified model for KNX telegrams shared between Home Assistant and SpectrumKNX.
40
+ - **Pluggable Backends**:
41
+ - **In-Memory**: Fast, deque-based storage with full filtering support.
42
+ - **SQLite**: Lightweight persistent storage with SQL-based filtering.
43
+ - **PostgreSQL + TimescaleDB**: Full-scale time-series storage.
44
+ - **Unified Query Model**: Powerful declarative filtering including time-delta context windows and pagination.
45
+ - **Zero Runtime Dependencies**: Core library (model, interface, in-memory) has no dependencies.
46
+ - **Automated Schema Management**: SQL backends handle their own creation and upgrades.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install knx-telegram-store
52
+ ```
53
+
54
+ For SQL support:
55
+
56
+ ```bash
57
+ pip install knx-telegram-store[sqlite]
58
+ pip install knx-telegram-store[postgres]
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ from datetime import datetime
65
+ from knx_telegram_store import StoredTelegram, TelegramQuery
66
+ from knx_telegram_store.backends.memory import MemoryStore
67
+
68
+ async def main():
69
+ store = MemoryStore(max_size=1000)
70
+ await store.initialize()
71
+
72
+ telegram = StoredTelegram(
73
+ timestamp=datetime.now(),
74
+ source="1.1.1",
75
+ destination="1/1/1",
76
+ telegramtype="GroupValueWrite",
77
+ direction="Incoming",
78
+ value=22.5,
79
+ unit="°C"
80
+ )
81
+
82
+ await store.store(telegram)
83
+
84
+ query = TelegramQuery(destinations=["1/1/1"])
85
+ result = await store.query(query)
86
+
87
+ for t in result.telegrams:
88
+ print(f"{t.timestamp}: {t.source} -> {t.destination} | {t.value} {t.unit}")
89
+
90
+ await store.close()
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,63 @@
1
+ # knx-telegram-store
2
+
3
+ A standalone, host-agnostic Python library for KNX telegram persistence.
4
+
5
+ ## Features
6
+
7
+ - **Canonical Data Model**: A unified model for KNX telegrams shared between Home Assistant and SpectrumKNX.
8
+ - **Pluggable Backends**:
9
+ - **In-Memory**: Fast, deque-based storage with full filtering support.
10
+ - **SQLite**: Lightweight persistent storage with SQL-based filtering.
11
+ - **PostgreSQL + TimescaleDB**: Full-scale time-series storage.
12
+ - **Unified Query Model**: Powerful declarative filtering including time-delta context windows and pagination.
13
+ - **Zero Runtime Dependencies**: Core library (model, interface, in-memory) has no dependencies.
14
+ - **Automated Schema Management**: SQL backends handle their own creation and upgrades.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install knx-telegram-store
20
+ ```
21
+
22
+ For SQL support:
23
+
24
+ ```bash
25
+ pip install knx-telegram-store[sqlite]
26
+ pip install knx-telegram-store[postgres]
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from datetime import datetime
33
+ from knx_telegram_store import StoredTelegram, TelegramQuery
34
+ from knx_telegram_store.backends.memory import MemoryStore
35
+
36
+ async def main():
37
+ store = MemoryStore(max_size=1000)
38
+ await store.initialize()
39
+
40
+ telegram = StoredTelegram(
41
+ timestamp=datetime.now(),
42
+ source="1.1.1",
43
+ destination="1/1/1",
44
+ telegramtype="GroupValueWrite",
45
+ direction="Incoming",
46
+ value=22.5,
47
+ unit="°C"
48
+ )
49
+
50
+ await store.store(telegram)
51
+
52
+ query = TelegramQuery(destinations=["1/1/1"])
53
+ result = await store.query(query)
54
+
55
+ for t in result.telegrams:
56
+ print(f"{t.timestamp}: {t.source} -> {t.destination} | {t.value} {t.unit}")
57
+
58
+ await store.close()
59
+ ```
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "knx-telegram-store"
7
+ version = "0.1.0"
8
+ description = "A standalone, host-agnostic Python library for KNX telegram persistence."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [
13
+ {name = "Martin Hoefling"}
14
+ ]
15
+ keywords = ["knx", "home-assistant", "persistence", "storage", "telegram"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Home Automation",
21
+ "Framework :: AsyncIO",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/XKNX/knx-telegram-store"
27
+ Bug-Tracker = "https://github.com/XKNX/knx-telegram-store/issues"
28
+
29
+ [tool.setuptools]
30
+ include-package-data = true
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["src"]
34
+ include = ["knx_telegram_store*"]
35
+
36
+ [tool.setuptools.package-data]
37
+ knx_telegram_store = ["py.typed"]
38
+
39
+ [project.optional-dependencies]
40
+ sqlite = ["aiosqlite>=0.20", "sqlalchemy[asyncio]>=2.0"]
41
+ postgres = ["asyncpg>=0.29", "sqlalchemy[asyncio]>=2.0"]
42
+ dev = [
43
+ "pytest>=8.0",
44
+ "pytest-asyncio>=0.23",
45
+ "pytest-cov>=4.1",
46
+ "ruff>=0.3",
47
+ "mypy>=1.9",
48
+ "aiosqlite>=0.20"
49
+ ]
50
+
51
+ [tool.ruff]
52
+ target-version = "py312"
53
+ line-length = 120
54
+ exclude = [
55
+ ".git",
56
+ "__pycache__",
57
+ ".venv",
58
+ "venv",
59
+ "dist",
60
+ ]
61
+
62
+ [tool.ruff.lint]
63
+ select = ["E", "F", "B", "I", "U", "UP"]
64
+ ignore = [
65
+ "E501", # Line too long
66
+ ]
67
+
68
+ [tool.ruff.lint.mccabe]
69
+ max-complexity = 15
70
+
71
+ [tool.pytest.ini_options]
72
+ asyncio_mode = "auto"
73
+ testpaths = ["tests"]
74
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ from .model import StoredTelegram
2
+ from .query import TelegramQuery, TelegramQueryResult
3
+ from .store import StoreCapabilities, TelegramStore
4
+
5
+ __all__ = [
6
+ "StoredTelegram",
7
+ "TelegramQuery",
8
+ "TelegramQueryResult",
9
+ "StoreCapabilities",
10
+ "TelegramStore",
11
+ ]
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import abstractmethod
4
+ from collections.abc import Sequence
5
+ from datetime import timedelta
6
+ from typing import Any
7
+
8
+ from sqlalchemy import (
9
+ JSON,
10
+ Boolean,
11
+ Column,
12
+ DateTime,
13
+ Double,
14
+ Integer,
15
+ MetaData,
16
+ String,
17
+ Table,
18
+ Text,
19
+ and_,
20
+ func,
21
+ select,
22
+ )
23
+ from sqlalchemy.ext.asyncio import AsyncEngine
24
+
25
+ from ..model import StoredTelegram
26
+ from ..query import TelegramQuery, TelegramQueryResult
27
+ from ..store import StoreCapabilities, TelegramStore
28
+
29
+
30
+ class BaseSQLStore(TelegramStore):
31
+ """Base class for SQLAlchemy-based telegram stores."""
32
+
33
+ def __init__(self, engine: AsyncEngine, max_telegrams: int | None = None) -> None:
34
+ """Initialize the SQL store."""
35
+ self.engine = engine
36
+ self._max_telegrams = max_telegrams
37
+ self._metadata = MetaData()
38
+ self.telegrams = Table(
39
+ "telegrams",
40
+ self._metadata,
41
+ Column("timestamp", DateTime(timezone=True), nullable=False, index=True),
42
+ Column("source", String(20), nullable=False),
43
+ Column("destination", String(20), nullable=False, index=True),
44
+ Column("telegramtype", String(50), nullable=False),
45
+ Column("direction", String(20), nullable=False, server_default=""),
46
+ Column("payload", JSON, nullable=True),
47
+ Column("dpt_main", Integer, nullable=True),
48
+ Column("dpt_sub", Integer, nullable=True),
49
+ Column("dpt_name", String(100), nullable=True),
50
+ Column("unit", String(20), nullable=True),
51
+ Column("value", JSON, nullable=True),
52
+ Column("value_numeric", Double, nullable=True),
53
+ Column("raw_data", Text, nullable=True), # Hex encoded string
54
+ Column("data_secure", Boolean, nullable=True),
55
+ Column("source_name", String(255), server_default=""),
56
+ Column("destination_name", String(255), server_default=""),
57
+ )
58
+ self._capabilities = StoreCapabilities(
59
+ supports_time_range=True,
60
+ supports_time_delta=True,
61
+ supports_pagination=True,
62
+ supports_count=True,
63
+ max_storage=max_telegrams,
64
+ )
65
+
66
+ @property
67
+ def capabilities(self) -> StoreCapabilities:
68
+ """Return the capabilities of this backend."""
69
+ return self._capabilities
70
+
71
+ @abstractmethod
72
+ async def initialize(self) -> None:
73
+ """Set up the store (create tables, upgrades)."""
74
+
75
+ async def close(self) -> None:
76
+ """Close the engine."""
77
+ await self.engine.dispose()
78
+
79
+ async def store(self, telegram: StoredTelegram) -> None:
80
+ """Persist a single telegram."""
81
+ await self.store_many([telegram])
82
+
83
+ async def store_many(self, telegrams: Sequence[StoredTelegram]) -> None:
84
+ """Persist multiple telegrams."""
85
+ if not telegrams:
86
+ return
87
+
88
+ values = [
89
+ {
90
+ "timestamp": t.timestamp,
91
+ "source": t.source,
92
+ "destination": t.destination,
93
+ "telegramtype": t.telegramtype,
94
+ "direction": t.direction,
95
+ "payload": t.payload,
96
+ "dpt_main": t.dpt_main,
97
+ "dpt_sub": t.dpt_sub,
98
+ "dpt_name": t.dpt_name,
99
+ "unit": t.unit,
100
+ "value": t.value,
101
+ "value_numeric": t.value_numeric,
102
+ "raw_data": t.raw_data,
103
+ "data_secure": t.data_secure,
104
+ "source_name": t.source_name,
105
+ "destination_name": t.destination_name,
106
+ }
107
+ for t in telegrams
108
+ ]
109
+
110
+ async with self.engine.begin() as conn:
111
+ await conn.execute(self.telegrams.insert(), values)
112
+ if self._max_telegrams:
113
+ await self._prune(conn)
114
+
115
+ async def _prune(self, conn: Any) -> None:
116
+ """Prune old telegrams if max_storage is exceeded."""
117
+ # Simple pruning: delete oldest rows beyond max_telegrams
118
+ # Note: This could be optimized for large databases
119
+ count_stmt = select(func.count()).select_from(self.telegrams)
120
+ count = await conn.scalar(count_stmt)
121
+ if count > self._max_telegrams:
122
+ to_delete = count - self._max_telegrams
123
+ # Use a subquery to find the IDs to delete (SQLite/Postgres compatible)
124
+ # Since we don't have a PK, we use the timestamp as a proxy or just delete N oldest
125
+ # In PostgreSQL/TimescaleDB we might want a different strategy, but for now:
126
+ subq = (
127
+ select(self.telegrams.c.timestamp)
128
+ .order_by(self.telegrams.c.timestamp.asc())
129
+ .limit(to_delete)
130
+ )
131
+ delete_stmt = self.telegrams.delete().where(
132
+ self.telegrams.c.timestamp.in_(subq)
133
+ )
134
+ await conn.execute(delete_stmt)
135
+
136
+ async def query(self, query: TelegramQuery) -> TelegramQueryResult:
137
+ """Retrieve telegrams matching the given query."""
138
+ stmt = select(self.telegrams)
139
+
140
+ # 1. Base Filters
141
+ filters: list[Any] = []
142
+ if query.sources:
143
+ filters.append(self.telegrams.c.source.in_(query.sources))
144
+ if query.destinations:
145
+ filters.append(self.telegrams.c.destination.in_(query.destinations))
146
+ if query.telegram_types:
147
+ filters.append(self.telegrams.c.telegramtype.in_(query.telegram_types))
148
+ if query.directions:
149
+ filters.append(self.telegrams.c.direction.in_(query.directions))
150
+ if query.dpt_mains:
151
+ filters.append(self.telegrams.c.dpt_main.in_(query.dpt_mains))
152
+
153
+ if query.start_time:
154
+ filters.append(self.telegrams.c.timestamp >= query.start_time)
155
+ if query.end_time:
156
+ filters.append(self.telegrams.c.timestamp <= query.end_time)
157
+
158
+ # 2. Time-Delta Context Logic
159
+ if (query.delta_before_ms > 0 or query.delta_after_ms > 0) and filters:
160
+ # Create a subquery for the matching pivots
161
+ pivots = select(self.telegrams.c.timestamp).where(and_(*filters)).alias("pivots")
162
+
163
+ if self.engine.dialect.name == "sqlite":
164
+ # SQLite specific date math
165
+ before_str = f"-{query.delta_before_ms / 1000.0} seconds"
166
+ after_str = f"+{query.delta_after_ms / 1000.0} seconds"
167
+
168
+ cond = and_(
169
+ self.telegrams.c.timestamp >= func.datetime(pivots.c.timestamp, before_str),
170
+ self.telegrams.c.timestamp <= func.datetime(pivots.c.timestamp, after_str)
171
+ )
172
+ else:
173
+ # Standard math (Postgres, etc.)
174
+ delta_before = timedelta(milliseconds=query.delta_before_ms)
175
+ delta_after = timedelta(milliseconds=query.delta_after_ms)
176
+ cond = and_(
177
+ self.telegrams.c.timestamp >= pivots.c.timestamp - delta_before,
178
+ self.telegrams.c.timestamp <= pivots.c.timestamp + delta_after
179
+ )
180
+
181
+ # Use EXISTS to find rows within range of any pivot
182
+ stmt = select(self.telegrams).where(
183
+ select(pivots).where(cond).exists()
184
+ )
185
+ else:
186
+ if filters:
187
+ stmt = stmt.where(and_(*filters))
188
+
189
+ # 3. Total Count (before pagination)
190
+ count_stmt = select(func.count()).select_from(stmt.alias("unpaginated"))
191
+
192
+ # 4. Ordering
193
+ if query.order_descending:
194
+ stmt = stmt.order_by(self.telegrams.c.timestamp.desc())
195
+ else:
196
+ stmt = stmt.order_by(self.telegrams.c.timestamp.asc())
197
+
198
+ # 5. Pagination
199
+ stmt = stmt.offset(query.offset).limit(query.limit)
200
+
201
+ async with self.engine.connect() as conn:
202
+ total_count = await conn.scalar(count_stmt)
203
+ result = await conn.execute(stmt)
204
+ rows = result.fetchall()
205
+
206
+ telegrams = [
207
+ StoredTelegram(
208
+ timestamp=row.timestamp,
209
+ source=row.source,
210
+ destination=row.destination,
211
+ telegramtype=row.telegramtype,
212
+ direction=row.direction,
213
+ payload=row.payload,
214
+ dpt_main=row.dpt_main,
215
+ dpt_sub=row.dpt_sub,
216
+ dpt_name=row.dpt_name,
217
+ unit=row.unit,
218
+ value=row.value,
219
+ value_numeric=row.value_numeric,
220
+ raw_data=row.raw_data,
221
+ data_secure=row.data_secure,
222
+ source_name=row.source_name,
223
+ destination_name=row.destination_name,
224
+ )
225
+ for row in rows
226
+ ]
227
+
228
+ limit_reached = (total_count or 0) > (query.offset + query.limit)
229
+
230
+ return TelegramQueryResult(
231
+ telegrams=telegrams,
232
+ total_count=total_count or 0,
233
+ limit_reached=limit_reached,
234
+ )
235
+
236
+ async def count(self) -> int:
237
+ """Return the total number of stored telegrams."""
238
+ async with self.engine.connect() as conn:
239
+ return await conn.scalar(select(func.count()).select_from(self.telegrams)) or 0
240
+
241
+ async def clear(self) -> None:
242
+ """Remove all stored telegrams."""
243
+ async with self.engine.begin() as conn:
244
+ await conn.execute(self.telegrams.delete())
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from collections.abc import Sequence
5
+ from datetime import timedelta
6
+
7
+ from ..model import StoredTelegram
8
+ from ..query import TelegramQuery, TelegramQueryResult
9
+ from ..store import StoreCapabilities, TelegramStore
10
+
11
+
12
+ class MemoryStore(TelegramStore):
13
+ """In-memory implementation of TelegramStore using a deque."""
14
+
15
+ def __init__(self, max_telegrams: int = 500) -> None:
16
+ """Initialize the memory store."""
17
+ self._max_telegrams = max_telegrams
18
+ self._telegrams: deque[StoredTelegram] = deque(maxlen=max_telegrams)
19
+ self._capabilities = StoreCapabilities(
20
+ supports_time_range=True,
21
+ supports_time_delta=True,
22
+ supports_pagination=True,
23
+ supports_count=True,
24
+ max_storage=max_telegrams,
25
+ )
26
+
27
+ @property
28
+ def capabilities(self) -> StoreCapabilities:
29
+ """Return the capabilities of this backend."""
30
+ return self._capabilities
31
+
32
+ async def initialize(self) -> None:
33
+ """Set up the store. Idempotent."""
34
+
35
+ async def close(self) -> None:
36
+ """Tear down the store."""
37
+
38
+ async def store(self, telegram: StoredTelegram) -> None:
39
+ """Persist a single telegram."""
40
+ self._telegrams.append(telegram)
41
+
42
+ async def store_many(self, telegrams: Sequence[StoredTelegram]) -> None:
43
+ """Persist multiple telegrams in a single batch."""
44
+ self._telegrams.extend(telegrams)
45
+
46
+ async def query(self, query: TelegramQuery) -> TelegramQueryResult:
47
+ """Retrieve telegrams matching the given query."""
48
+ results = list(self._telegrams)
49
+
50
+ # 1. Multi-value filters (AND across, OR within)
51
+ if query.sources:
52
+ results = [t for t in results if t.source in query.sources]
53
+ if query.destinations:
54
+ results = [t for t in results if t.destination in query.destinations]
55
+ if query.telegram_types:
56
+ results = [t for t in results if t.telegramtype in query.telegram_types]
57
+ if query.directions:
58
+ results = [t for t in results if t.direction in query.directions]
59
+ if query.dpt_mains:
60
+ results = [t for t in results if t.dpt_main in query.dpt_mains]
61
+
62
+ # 2. Time range
63
+ if query.start_time:
64
+ results = [t for t in results if t.timestamp >= query.start_time]
65
+ if query.end_time:
66
+ results = [t for t in results if t.timestamp <= query.end_time]
67
+
68
+ # 3. Time-delta context window
69
+ if query.delta_before_ms > 0 or query.delta_after_ms > 0:
70
+ pivot_timestamps = [t.timestamp for t in results]
71
+
72
+ delta_before = timedelta(milliseconds=query.delta_before_ms)
73
+ delta_after = timedelta(milliseconds=query.delta_after_ms)
74
+
75
+ # Re-collect all telegrams within any pivot's window
76
+ # This implementation is O(N*M) but N is small for MemoryStore (500)
77
+ context_results = set()
78
+ for t in self._telegrams:
79
+ for pivot_ts in pivot_timestamps:
80
+ if (pivot_ts - delta_before) <= t.timestamp <= (pivot_ts + delta_after):
81
+ context_results.add(t)
82
+ break
83
+ results = list(context_results)
84
+
85
+ # 4. Ordering
86
+ results.sort(key=lambda t: t.timestamp, reverse=query.order_descending)
87
+
88
+ total_count = len(results)
89
+
90
+ # 5. Pagination
91
+ start = query.offset
92
+ end = query.offset + query.limit
93
+ paginated_results = results[start:end]
94
+
95
+ limit_reached = len(results) > end
96
+
97
+ return TelegramQueryResult(
98
+ telegrams=paginated_results,
99
+ total_count=total_count,
100
+ limit_reached=limit_reached,
101
+ )
102
+
103
+ async def count(self) -> int:
104
+ """Return the total number of stored telegrams."""
105
+ return len(self._telegrams)
106
+
107
+ async def clear(self) -> None:
108
+ """Remove all stored telegrams."""
109
+ self._telegrams.clear()
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from sqlalchemy import inspect, text
4
+ from sqlalchemy.ext.asyncio import create_async_engine
5
+
6
+ from .base_sql import BaseSQLStore
7
+
8
+
9
+ class PostgresStore(BaseSQLStore):
10
+ """PostgreSQL + TimescaleDB implementation of TelegramStore."""
11
+
12
+ def __init__(
13
+ self,
14
+ dsn: str,
15
+ max_telegrams: int | None = None
16
+ ) -> None:
17
+ """Initialize the Postgres store."""
18
+ # Ensure we use asyncpg
19
+ if dsn.startswith("postgresql://"):
20
+ dsn = dsn.replace("postgresql://", "postgresql+asyncpg://", 1)
21
+
22
+ engine = create_async_engine(dsn)
23
+ super().__init__(engine, max_telegrams)
24
+
25
+ async def initialize(self) -> None:
26
+ """Set up the database schema and perform upgrades."""
27
+ async with self.engine.begin() as conn:
28
+ # 1. Enable TimescaleDB extension
29
+ await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"))
30
+
31
+ # 2. Create table if not exists
32
+ await conn.run_sync(self._metadata.create_all)
33
+
34
+ # 3. Perform column-level upgrades
35
+ await conn.run_sync(self._upgrade_schema)
36
+
37
+ # 4. Convert to hypertable (idempotent)
38
+ await conn.execute(text(
39
+ "SELECT create_hypertable('telegrams', 'timestamp', if_not_exists => TRUE)"
40
+ ))
41
+
42
+ def _upgrade_schema(self, connection) -> None:
43
+ """Synchronous part of schema upgrade (run via run_sync)."""
44
+ inspector = inspect(connection)
45
+ try:
46
+ columns = inspector.get_columns("telegrams")
47
+ except Exception:
48
+ # Table might not exist yet
49
+ return
50
+ existing_columns = {col["name"] for col in columns}
51
+
52
+ # 1. Handle renames from legacy SpectrumKNX schema
53
+ renames = {
54
+ "source_address": "source",
55
+ "target_address": "destination",
56
+ "telegram_type": "telegramtype",
57
+ "value_json": "payload",
58
+ "value": "value_numeric" # Legacy value was FLOAT, library value is JSONB
59
+ }
60
+ for old, new in renames.items():
61
+ if old in existing_columns:
62
+ if new not in existing_columns:
63
+ connection.execute(text(f'ALTER TABLE telegrams RENAME COLUMN "{old}" TO "{new}"'))
64
+ existing_columns.remove(old)
65
+ existing_columns.add(new)
66
+ elif old == "value":
67
+ # Special case: 'value' (float) and 'value_numeric' (float) both exist.
68
+ # We must move 'value' out of the way so it can be recreated as JSONB.
69
+ is_float = any(c["name"] == "value" and "double" in str(c["type"]).lower() for c in columns)
70
+ if is_float:
71
+ connection.execute(text('ALTER TABLE telegrams RENAME COLUMN "value" TO "value_legacy_float"'))
72
+ existing_columns.remove("value")
73
+ existing_columns.add("value_legacy_float")
74
+
75
+ # Migrate raw_data from bytea to text (hex encoded)
76
+ if "raw_data" in existing_columns:
77
+ for col in columns:
78
+ if col["name"] == "raw_data" and "bytea" in str(col["type"]).lower():
79
+ connection.execute(text("ALTER TABLE telegrams ALTER COLUMN raw_data TYPE TEXT USING encode(raw_data, 'hex')"))
80
+
81
+ # 2. Ensure all library columns exist
82
+ expected_columns = {
83
+ "direction": "VARCHAR(20) DEFAULT 'Incoming'",
84
+ "value": "JSONB",
85
+ "value_numeric": "FLOAT",
86
+ "payload": "JSONB",
87
+ "dpt_name": "VARCHAR(100)",
88
+ "unit": "VARCHAR(20)",
89
+ "data_secure": "BOOLEAN",
90
+ "source_name": "VARCHAR(255) DEFAULT ''",
91
+ "destination_name": "VARCHAR(255) DEFAULT ''",
92
+ }
93
+
94
+ for col_name, col_type in expected_columns.items():
95
+ if col_name not in existing_columns:
96
+ connection.execute(text(f"ALTER TABLE telegrams ADD COLUMN {col_name} {col_type}"))
97
+ existing_columns.add(col_name)
98
+
99
+ # 3. Data migrations for incorrect previous renames
100
+ if "value_legacy_float" in existing_columns and "value_numeric" in existing_columns:
101
+ connection.execute(text('UPDATE telegrams SET value_numeric = value_legacy_float WHERE value_numeric IS NULL AND value_legacy_float IS NOT NULL'))
102
+
103
+ if "payload" in existing_columns and "value" in existing_columns:
104
+ connection.execute(text('UPDATE telegrams SET value = payload WHERE value IS NULL AND payload IS NOT NULL'))
105
+ # Clear payload where it was incorrectly used for JSON data
106
+ connection.execute(text('UPDATE telegrams SET payload = NULL WHERE value = payload'))
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from sqlalchemy import inspect, text
6
+ from sqlalchemy.ext.asyncio import create_async_engine
7
+
8
+ from .base_sql import BaseSQLStore
9
+
10
+
11
+ class SqliteStore(BaseSQLStore):
12
+ """Async SQLite implementation of TelegramStore."""
13
+
14
+ def __init__(
15
+ self,
16
+ db_path: str | Path,
17
+ max_telegrams: int | None = None
18
+ ) -> None:
19
+ """Initialize the SQLite store."""
20
+ # Ensure parent directory exists (unless in-memory)
21
+ if str(db_path) != ":memory:":
22
+ path = Path(db_path)
23
+ path.parent.mkdir(parents=True, exist_ok=True)
24
+ url = f"sqlite+aiosqlite:///{path}"
25
+ else:
26
+ url = "sqlite+aiosqlite:///:memory:"
27
+
28
+ engine = create_async_engine(url)
29
+ super().__init__(engine, max_telegrams)
30
+
31
+ async def initialize(self) -> None:
32
+ """Set up the database schema and perform upgrades."""
33
+ async with self.engine.begin() as conn:
34
+ # 1. Create table if not exists
35
+ await conn.run_sync(self._metadata.create_all)
36
+
37
+ # 2. Perform column-level upgrades
38
+ await conn.run_sync(self._upgrade_schema)
39
+
40
+ def _upgrade_schema(self, connection) -> None:
41
+ """Synchronous part of schema upgrade (run via run_sync)."""
42
+ inspector = inspect(connection)
43
+ existing_columns = {col["name"] for col in inspector.get_columns("telegrams")}
44
+
45
+ # Define missing columns that should be added to existing schemas
46
+ # (e.g. from early SpectrumKNX or HA versions)
47
+ expected_columns = {
48
+ "direction": "VARCHAR(20) DEFAULT ''",
49
+ "payload": "JSON",
50
+ "dpt_name": "VARCHAR(100)",
51
+ "unit": "VARCHAR(20)",
52
+ "data_secure": "BOOLEAN",
53
+ "source_name": "VARCHAR(255) DEFAULT ''",
54
+ "destination_name": "VARCHAR(255) DEFAULT ''",
55
+ }
56
+
57
+ for col_name, col_type in expected_columns.items():
58
+ if col_name not in existing_columns:
59
+ connection.execute(text(f"ALTER TABLE telegrams ADD COLUMN {col_name} {col_type}"))
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class StoredTelegram:
10
+ """A KNX telegram in its stored/serialized form."""
11
+
12
+ # ── Core identity ─────────────────────────────────────────────
13
+ timestamp: datetime # timezone-aware UTC
14
+
15
+ # ── Addressing ────────────────────────────────────────────────
16
+ source: str # Individual address, e.g. "1.2.3"
17
+ destination: str # Group address, e.g. "1/2/3"
18
+
19
+ # ── Telegram classification ───────────────────────────────────
20
+ telegramtype: str # "GroupValueWrite" | "GroupValueRead" | "GroupValueResponse"
21
+ direction: str # "Incoming" | "Outgoing"
22
+
23
+ # ── Payload ───────────────────────────────────────────────────
24
+ payload: int | tuple[int, ...] | None = None # Raw KNX payload (DPTBinary int or DPTArray tuple)
25
+
26
+ # ── DPT metadata ─────────────────────────────────────────────
27
+ dpt_main: int | None = None
28
+ dpt_sub: int | None = None
29
+ dpt_name: str | None = None
30
+ unit: str | None = None
31
+
32
+ # ── Decoded value (consumer-enriched at write time) ───────────
33
+ value: bool | str | int | float | dict[str, Any] | None = None
34
+
35
+ # ── Numeric value for time-series queries (SQL backends) ──────
36
+ value_numeric: float | None = None
37
+
38
+ # ── Raw bytes (hex-encoded string for JSON safety) ────────────
39
+ raw_data: str | None = None # e.g. "0a1b2c"
40
+
41
+ # ── Security ──────────────────────────────────────────────────
42
+ data_secure: bool | None = None
43
+
44
+ # ── Display names (consumer-enriched at write time) ───────────
45
+ source_name: str = ""
46
+ destination_name: str = ""
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .model import StoredTelegram
9
+
10
+ @dataclass(frozen=True, kw_only=True, slots=True)
11
+ class TelegramQuery:
12
+ """Declarative query for telegram retrieval.
13
+
14
+ Filter semantics:
15
+ - Empty list = no restriction (pass-through)
16
+ - Within a category = OR logic (any match passes)
17
+ - Across categories = AND logic (must pass all active categories)
18
+ """
19
+
20
+ # ── Multi-value filters (OR within, AND across) ───────────────
21
+ sources: list[str] = field(default_factory=list)
22
+ destinations: list[str] = field(default_factory=list)
23
+ telegram_types: list[str] = field(default_factory=list)
24
+ directions: list[str] = field(default_factory=list)
25
+ dpt_mains: list[int] = field(default_factory=list)
26
+
27
+ # ── Time range ────────────────────────────────────────────────
28
+ start_time: datetime | None = None
29
+ end_time: datetime | None = None
30
+
31
+ # ── Time-delta context window (milliseconds) ──────────────────
32
+ # When set, rows matching the filters are found first, then
33
+ # ALL rows within ±delta of any matching row's timestamp are
34
+ # included — even if they don't match the filters themselves.
35
+ delta_before_ms: int = 0
36
+ delta_after_ms: int = 0
37
+
38
+ # ── Pagination ────────────────────────────────────────────────
39
+ limit: int = 25_000
40
+ offset: int = 0
41
+
42
+ # ── Ordering ──────────────────────────────────────────────────
43
+ order_descending: bool = True # newest first by default
44
+
45
+
46
+ @dataclass(frozen=True, kw_only=True, slots=True)
47
+ class TelegramQueryResult:
48
+ """Result of a telegram query."""
49
+
50
+ telegrams: list[StoredTelegram]
51
+ total_count: int
52
+ limit_reached: bool # True = more results exist beyond limit
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+
7
+ from .model import StoredTelegram
8
+ from .query import TelegramQuery, TelegramQueryResult
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class StoreCapabilities:
13
+ """Declares what a backend can do natively."""
14
+ supports_time_range: bool = False
15
+ supports_time_delta: bool = False
16
+ supports_pagination: bool = False
17
+ supports_count: bool = False
18
+ max_storage: int | None = None # None = unlimited
19
+
20
+
21
+ class TelegramStore(ABC):
22
+ """Abstract interface for KNX telegram persistence."""
23
+
24
+ @property
25
+ @abstractmethod
26
+ def capabilities(self) -> StoreCapabilities:
27
+ """Return the capabilities of this backend."""
28
+
29
+ @abstractmethod
30
+ async def initialize(self) -> None:
31
+ """Set up the store (create tables, open connections, etc.).
32
+
33
+ Called once at startup. Must be idempotent.
34
+ """
35
+
36
+ @abstractmethod
37
+ async def close(self) -> None:
38
+ """Tear down the store (close connections, flush buffers).
39
+
40
+ Called once at shutdown.
41
+ """
42
+
43
+ @abstractmethod
44
+ async def store(self, telegram: StoredTelegram) -> None:
45
+ """Persist a single telegram."""
46
+
47
+ @abstractmethod
48
+ async def store_many(self, telegrams: Sequence[StoredTelegram]) -> None:
49
+ """Persist multiple telegrams in a single batch."""
50
+
51
+ @abstractmethod
52
+ async def query(self, query: TelegramQuery) -> TelegramQueryResult:
53
+ """Retrieve telegrams matching the given query.
54
+
55
+ All backends MUST implement full filtering as defined in TelegramQuery.
56
+ """
57
+
58
+ @abstractmethod
59
+ async def count(self) -> int:
60
+ """Return the total number of stored telegrams."""
61
+
62
+ async def clear(self) -> None:
63
+ """Remove all stored telegrams.
64
+
65
+ Optional — backends may raise NotImplementedError.
66
+ """
67
+ raise NotImplementedError
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: knx-telegram-store
3
+ Version: 0.1.0
4
+ Summary: A standalone, host-agnostic Python library for KNX telegram persistence.
5
+ Author: Martin Hoefling
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/XKNX/knx-telegram-store
8
+ Project-URL: Bug-Tracker, https://github.com/XKNX/knx-telegram-store/issues
9
+ Keywords: knx,home-assistant,persistence,storage,telegram
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Home Automation
14
+ Classifier: Framework :: AsyncIO
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: sqlite
19
+ Requires-Dist: aiosqlite>=0.20; extra == "sqlite"
20
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == "sqlite"
21
+ Provides-Extra: postgres
22
+ Requires-Dist: asyncpg>=0.29; extra == "postgres"
23
+ Requires-Dist: sqlalchemy[asyncio]>=2.0; extra == "postgres"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
27
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
28
+ Requires-Dist: ruff>=0.3; extra == "dev"
29
+ Requires-Dist: mypy>=1.9; extra == "dev"
30
+ Requires-Dist: aiosqlite>=0.20; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # knx-telegram-store
34
+
35
+ A standalone, host-agnostic Python library for KNX telegram persistence.
36
+
37
+ ## Features
38
+
39
+ - **Canonical Data Model**: A unified model for KNX telegrams shared between Home Assistant and SpectrumKNX.
40
+ - **Pluggable Backends**:
41
+ - **In-Memory**: Fast, deque-based storage with full filtering support.
42
+ - **SQLite**: Lightweight persistent storage with SQL-based filtering.
43
+ - **PostgreSQL + TimescaleDB**: Full-scale time-series storage.
44
+ - **Unified Query Model**: Powerful declarative filtering including time-delta context windows and pagination.
45
+ - **Zero Runtime Dependencies**: Core library (model, interface, in-memory) has no dependencies.
46
+ - **Automated Schema Management**: SQL backends handle their own creation and upgrades.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install knx-telegram-store
52
+ ```
53
+
54
+ For SQL support:
55
+
56
+ ```bash
57
+ pip install knx-telegram-store[sqlite]
58
+ pip install knx-telegram-store[postgres]
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ from datetime import datetime
65
+ from knx_telegram_store import StoredTelegram, TelegramQuery
66
+ from knx_telegram_store.backends.memory import MemoryStore
67
+
68
+ async def main():
69
+ store = MemoryStore(max_size=1000)
70
+ await store.initialize()
71
+
72
+ telegram = StoredTelegram(
73
+ timestamp=datetime.now(),
74
+ source="1.1.1",
75
+ destination="1/1/1",
76
+ telegramtype="GroupValueWrite",
77
+ direction="Incoming",
78
+ value=22.5,
79
+ unit="°C"
80
+ )
81
+
82
+ await store.store(telegram)
83
+
84
+ query = TelegramQuery(destinations=["1/1/1"])
85
+ result = await store.query(query)
86
+
87
+ for t in result.telegrams:
88
+ print(f"{t.timestamp}: {t.source} -> {t.destination} | {t.value} {t.unit}")
89
+
90
+ await store.close()
91
+ ```
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/knx_telegram_store/__init__.py
5
+ src/knx_telegram_store/model.py
6
+ src/knx_telegram_store/py.typed
7
+ src/knx_telegram_store/query.py
8
+ src/knx_telegram_store/store.py
9
+ src/knx_telegram_store.egg-info/PKG-INFO
10
+ src/knx_telegram_store.egg-info/SOURCES.txt
11
+ src/knx_telegram_store.egg-info/dependency_links.txt
12
+ src/knx_telegram_store.egg-info/requires.txt
13
+ src/knx_telegram_store.egg-info/top_level.txt
14
+ src/knx_telegram_store/backends/base_sql.py
15
+ src/knx_telegram_store/backends/memory.py
16
+ src/knx_telegram_store/backends/postgres.py
17
+ src/knx_telegram_store/backends/sqlite.py
18
+ tests/test_generic_store.py
@@ -0,0 +1,16 @@
1
+
2
+ [dev]
3
+ pytest>=8.0
4
+ pytest-asyncio>=0.23
5
+ pytest-cov>=4.1
6
+ ruff>=0.3
7
+ mypy>=1.9
8
+ aiosqlite>=0.20
9
+
10
+ [postgres]
11
+ asyncpg>=0.29
12
+ sqlalchemy[asyncio]>=2.0
13
+
14
+ [sqlite]
15
+ aiosqlite>=0.20
16
+ sqlalchemy[asyncio]>=2.0
@@ -0,0 +1 @@
1
+ knx_telegram_store
@@ -0,0 +1,135 @@
1
+ from datetime import UTC, datetime, timedelta
2
+
3
+ import pytest
4
+
5
+ from knx_telegram_store import StoredTelegram, TelegramQuery
6
+
7
+
8
+ @pytest.fixture
9
+ def sample_telegrams():
10
+ now = datetime.now(UTC)
11
+ return [
12
+ StoredTelegram(
13
+ timestamp=now - timedelta(minutes=5),
14
+ source="1.1.1",
15
+ destination="1/1/1",
16
+ telegramtype="GroupValueWrite",
17
+ direction="Incoming",
18
+ value=20.0,
19
+ dpt_main=9
20
+ ),
21
+ StoredTelegram(
22
+ timestamp=now - timedelta(minutes=4),
23
+ source="1.1.2",
24
+ destination="1/1/1",
25
+ telegramtype="GroupValueWrite",
26
+ direction="Incoming",
27
+ value=21.0,
28
+ dpt_main=9
29
+ ),
30
+ StoredTelegram(
31
+ timestamp=now - timedelta(minutes=3),
32
+ source="1.1.1",
33
+ destination="1/1/2",
34
+ telegramtype="GroupValueRead",
35
+ direction="Outgoing",
36
+ value=None,
37
+ dpt_main=1
38
+ ),
39
+ StoredTelegram(
40
+ timestamp=now - timedelta(minutes=2),
41
+ source="1.1.3",
42
+ destination="1/1/1",
43
+ telegramtype="GroupValueResponse",
44
+ direction="Incoming",
45
+ value=22.5,
46
+ dpt_main=9
47
+ ),
48
+ ]
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_store_and_count(store, sample_telegrams):
52
+ await store.store(sample_telegrams[0])
53
+ assert await store.count() == 1
54
+
55
+ await store.store_many(sample_telegrams[1:])
56
+ assert await store.count() == 4
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_query_all(store, sample_telegrams):
60
+ await store.store_many(sample_telegrams)
61
+ result = await store.query(TelegramQuery())
62
+ assert len(result.telegrams) == 4
63
+ assert result.total_count == 4
64
+ # Default order is descending
65
+ assert result.telegrams[0].timestamp > result.telegrams[-1].timestamp
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_query_filters(store, sample_telegrams):
69
+ await store.store_many(sample_telegrams)
70
+
71
+ # Filter by destination
72
+ result = await store.query(TelegramQuery(destinations=["1/1/1"]))
73
+ assert len(result.telegrams) == 3
74
+
75
+ # Filter by source
76
+ result = await store.query(TelegramQuery(sources=["1.1.1"]))
77
+ assert len(result.telegrams) == 2
78
+
79
+ # Filter by type
80
+ result = await store.query(TelegramQuery(telegram_types=["GroupValueRead"]))
81
+ assert len(result.telegrams) == 1
82
+
83
+ # Combined filter
84
+ result = await store.query(TelegramQuery(destinations=["1/1/1"], dpt_mains=[9]))
85
+ assert len(result.telegrams) == 3
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_query_time_range(store, sample_telegrams):
89
+ await store.store_many(sample_telegrams)
90
+ now = datetime.now(UTC)
91
+
92
+ # Start time (now - 3.5 mins) should include t2 (now-3) and t3 (now-2)
93
+ result = await store.query(TelegramQuery(start_time=now - timedelta(minutes=3.5)))
94
+ assert len(result.telegrams) == 2
95
+
96
+ # End time (now - 3.5 mins) should include t0 (now-5) and t1 (now-4)
97
+ result = await store.query(TelegramQuery(end_time=now - timedelta(minutes=3.5)))
98
+ assert len(result.telegrams) == 2
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_query_time_delta(store, sample_telegrams):
102
+ await store.store_many(sample_telegrams)
103
+
104
+ # Find the Read telegram (3 mins ago) and everything within 1.5 mins before/after
105
+ # This should include the telegram 4 mins ago (t1) and 2 mins ago (t3).
106
+ query = TelegramQuery(
107
+ telegram_types=["GroupValueRead"],
108
+ delta_before_ms=90000, # 1.5 mins
109
+ delta_after_ms=90000 # 1.5 mins
110
+ )
111
+ result = await store.query(query)
112
+ # Pivot is t2 (3 mins ago).
113
+ # t1 (4 mins ago) is 1 min before (included)
114
+ # t3 (2 mins ago) is 1 min after (included)
115
+ # t0 (5 mins ago) is 2 mins before (excluded)
116
+ assert len(result.telegrams) == 3
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_pagination(store, sample_telegrams):
120
+ await store.store_many(sample_telegrams)
121
+
122
+ result = await store.query(TelegramQuery(limit=2, offset=0))
123
+ assert len(result.telegrams) == 2
124
+ assert result.limit_reached is True
125
+
126
+ result = await store.query(TelegramQuery(limit=2, offset=2))
127
+ assert len(result.telegrams) == 2
128
+ assert result.limit_reached is False
129
+
130
+ @pytest.mark.asyncio
131
+ async def test_clear(store, sample_telegrams):
132
+ await store.store_many(sample_telegrams)
133
+ assert await store.count() == 4
134
+ await store.clear()
135
+ assert await store.count() == 0