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.
- knx_telegram_store-0.1.0/LICENSE +21 -0
- knx_telegram_store-0.1.0/PKG-INFO +95 -0
- knx_telegram_store-0.1.0/README.md +63 -0
- knx_telegram_store-0.1.0/pyproject.toml +74 -0
- knx_telegram_store-0.1.0/setup.cfg +4 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/__init__.py +11 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/backends/base_sql.py +244 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/backends/memory.py +109 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/backends/postgres.py +106 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/backends/sqlite.py +59 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/model.py +46 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/py.typed +0 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/query.py +52 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store/store.py +67 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store.egg-info/PKG-INFO +95 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store.egg-info/SOURCES.txt +18 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store.egg-info/dependency_links.txt +1 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store.egg-info/requires.txt +16 -0
- knx_telegram_store-0.1.0/src/knx_telegram_store.egg-info/top_level.txt +1 -0
- knx_telegram_store-0.1.0/tests/test_generic_store.py +135 -0
|
@@ -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,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 = ""
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|