ramses-rf 0.22.40__py3-none-any.whl → 0.51.1__py3-none-any.whl
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.
- ramses_cli/__init__.py +18 -0
- ramses_cli/client.py +597 -0
- ramses_cli/debug.py +20 -0
- ramses_cli/discovery.py +405 -0
- ramses_cli/utils/cat_slow.py +17 -0
- ramses_cli/utils/convert.py +60 -0
- ramses_rf/__init__.py +31 -10
- ramses_rf/binding_fsm.py +787 -0
- ramses_rf/const.py +124 -105
- ramses_rf/database.py +297 -0
- ramses_rf/device/__init__.py +69 -39
- ramses_rf/device/base.py +187 -376
- ramses_rf/device/heat.py +540 -552
- ramses_rf/device/hvac.py +286 -171
- ramses_rf/dispatcher.py +153 -177
- ramses_rf/entity_base.py +478 -361
- ramses_rf/exceptions.py +82 -0
- ramses_rf/gateway.py +377 -513
- ramses_rf/helpers.py +57 -19
- ramses_rf/py.typed +0 -0
- ramses_rf/schemas.py +148 -194
- ramses_rf/system/__init__.py +16 -23
- ramses_rf/system/faultlog.py +363 -0
- ramses_rf/system/heat.py +295 -302
- ramses_rf/system/schedule.py +312 -198
- ramses_rf/system/zones.py +318 -238
- ramses_rf/version.py +2 -8
- ramses_rf-0.51.1.dist-info/METADATA +72 -0
- ramses_rf-0.51.1.dist-info/RECORD +55 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
- ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
- {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
- ramses_tx/__init__.py +160 -0
- {ramses_rf/protocol → ramses_tx}/address.py +65 -59
- ramses_tx/command.py +1454 -0
- ramses_tx/const.py +903 -0
- ramses_tx/exceptions.py +92 -0
- {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
- {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
- ramses_tx/gateway.py +338 -0
- ramses_tx/helpers.py +883 -0
- {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
- {ramses_rf/protocol → ramses_tx}/message.py +155 -191
- ramses_tx/opentherm.py +1260 -0
- ramses_tx/packet.py +210 -0
- {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
- ramses_tx/protocol.py +801 -0
- ramses_tx/protocol_fsm.py +672 -0
- ramses_tx/py.typed +0 -0
- {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
- {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
- ramses_tx/transport.py +1471 -0
- ramses_tx/typed_dicts.py +492 -0
- ramses_tx/typing.py +181 -0
- ramses_tx/version.py +4 -0
- ramses_rf/discovery.py +0 -398
- ramses_rf/protocol/__init__.py +0 -59
- ramses_rf/protocol/backports.py +0 -42
- ramses_rf/protocol/command.py +0 -1576
- ramses_rf/protocol/const.py +0 -697
- ramses_rf/protocol/exceptions.py +0 -111
- ramses_rf/protocol/helpers.py +0 -390
- ramses_rf/protocol/opentherm.py +0 -1170
- ramses_rf/protocol/packet.py +0 -235
- ramses_rf/protocol/protocol.py +0 -613
- ramses_rf/protocol/transport.py +0 -1011
- ramses_rf/protocol/version.py +0 -10
- ramses_rf/system/hvac.py +0 -82
- ramses_rf-0.22.40.dist-info/METADATA +0 -64
- ramses_rf-0.22.40.dist-info/RECORD +0 -42
- ramses_rf-0.22.40.dist-info/top_level.txt +0 -1
ramses_rf/const.py
CHANGED
|
@@ -1,123 +1,142 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
#
|
|
4
2
|
"""RAMSES RF - a RAMSES-II protocol decoder & analyser."""
|
|
3
|
+
|
|
5
4
|
from __future__ import annotations
|
|
6
5
|
|
|
7
|
-
from
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from typing import TYPE_CHECKING, Final
|
|
8
|
+
|
|
9
|
+
from ramses_tx.const import ( # noqa: F401
|
|
10
|
+
DEFAULT_MAX_ZONES as DEFAULT_MAX_ZONES,
|
|
11
|
+
DEVICE_ID_REGEX as DEVICE_ID_REGEX,
|
|
12
|
+
DOMAIN_TYPE_MAP as DOMAIN_TYPE_MAP,
|
|
13
|
+
FAN_MODE as FAN_MODE, # deprecated, use SZ_FAN_MODE, to be removed in Q1 2026
|
|
14
|
+
SYS_MODE_MAP as SYS_MODE_MAP,
|
|
15
|
+
SZ_ACCEPT as SZ_ACCEPT,
|
|
16
|
+
SZ_ACTUATORS as SZ_ACTUATORS,
|
|
17
|
+
SZ_AIR_QUALITY as SZ_AIR_QUALITY,
|
|
18
|
+
SZ_AIR_QUALITY_BASIS as SZ_AIR_QUALITY_BASIS,
|
|
19
|
+
SZ_BOOST_TIMER as SZ_BOOST_TIMER,
|
|
20
|
+
SZ_BYPASS_MODE as SZ_BYPASS_MODE,
|
|
21
|
+
SZ_BYPASS_POSITION as SZ_BYPASS_POSITION,
|
|
22
|
+
SZ_BYPASS_STATE as SZ_BYPASS_STATE,
|
|
23
|
+
SZ_CHANGE_COUNTER as SZ_CHANGE_COUNTER,
|
|
24
|
+
SZ_CO2_LEVEL as SZ_CO2_LEVEL,
|
|
25
|
+
SZ_CONFIRM as SZ_CONFIRM,
|
|
26
|
+
SZ_DATETIME as SZ_DATETIME,
|
|
27
|
+
SZ_DEVICE_ID as SZ_DEVICE_ID,
|
|
28
|
+
SZ_DEVICE_ROLE as SZ_DEVICE_ROLE,
|
|
29
|
+
SZ_DEVICES as SZ_DEVICES,
|
|
30
|
+
SZ_DHW_IDX as SZ_DHW_IDX,
|
|
31
|
+
SZ_DOMAIN_ID as SZ_DOMAIN_ID,
|
|
32
|
+
SZ_DURATION as SZ_DURATION,
|
|
33
|
+
SZ_EXHAUST_FAN_SPEED as SZ_EXHAUST_FAN_SPEED,
|
|
34
|
+
SZ_EXHAUST_FLOW as SZ_EXHAUST_FLOW,
|
|
35
|
+
SZ_EXHAUST_TEMP as SZ_EXHAUST_TEMP,
|
|
36
|
+
SZ_FAN_INFO as SZ_FAN_INFO,
|
|
37
|
+
SZ_FAN_MODE as SZ_FAN_MODE,
|
|
38
|
+
SZ_FAN_RATE as SZ_FAN_RATE,
|
|
39
|
+
SZ_FILTER_REMAINING as SZ_FILTER_REMAINING,
|
|
40
|
+
SZ_FRAG_LENGTH as SZ_FRAG_LENGTH,
|
|
41
|
+
SZ_FRAG_NUMBER as SZ_FRAG_NUMBER,
|
|
42
|
+
SZ_FRAGMENT as SZ_FRAGMENT,
|
|
43
|
+
SZ_HEAT_DEMAND as SZ_HEAT_DEMAND,
|
|
44
|
+
SZ_INDOOR_HUMIDITY as SZ_INDOOR_HUMIDITY,
|
|
45
|
+
SZ_INDOOR_TEMP as SZ_INDOOR_TEMP,
|
|
46
|
+
SZ_LANGUAGE as SZ_LANGUAGE,
|
|
47
|
+
SZ_MODE as SZ_MODE,
|
|
48
|
+
SZ_NAME as SZ_NAME,
|
|
49
|
+
SZ_OEM_CODE as SZ_OEM_CODE,
|
|
50
|
+
SZ_OFFER as SZ_OFFER,
|
|
51
|
+
SZ_OUTDOOR_HUMIDITY as SZ_OUTDOOR_HUMIDITY,
|
|
52
|
+
SZ_OUTDOOR_TEMP as SZ_OUTDOOR_TEMP,
|
|
53
|
+
SZ_PAYLOAD as SZ_PAYLOAD,
|
|
54
|
+
SZ_PHASE as SZ_PHASE,
|
|
55
|
+
SZ_POST_HEAT as SZ_POST_HEAT,
|
|
56
|
+
SZ_PRE_HEAT as SZ_PRE_HEAT,
|
|
57
|
+
SZ_PRESENCE_DETECTED as SZ_PRESENCE_DETECTED,
|
|
58
|
+
SZ_PRESSURE as SZ_PRESSURE,
|
|
59
|
+
SZ_RELAY_DEMAND as SZ_RELAY_DEMAND,
|
|
60
|
+
SZ_RELAY_FAILSAFE as SZ_RELAY_FAILSAFE,
|
|
61
|
+
SZ_REMAINING_DAYS as SZ_REMAINING_DAYS,
|
|
62
|
+
SZ_REMAINING_MINS as SZ_REMAINING_MINS,
|
|
63
|
+
SZ_REMAINING_PERCENT as SZ_REMAINING_PERCENT,
|
|
64
|
+
SZ_SCHEDULE as SZ_SCHEDULE,
|
|
65
|
+
SZ_SENSOR as SZ_SENSOR,
|
|
66
|
+
SZ_SETPOINT as SZ_SETPOINT,
|
|
67
|
+
SZ_SPEED_CAPABILITIES as SZ_SPEED_CAPABILITIES,
|
|
68
|
+
SZ_SUPPLY_FAN_SPEED as SZ_SUPPLY_FAN_SPEED,
|
|
69
|
+
SZ_SUPPLY_FLOW as SZ_SUPPLY_FLOW,
|
|
70
|
+
SZ_SUPPLY_TEMP as SZ_SUPPLY_TEMP,
|
|
71
|
+
SZ_SYSTEM_MODE as SZ_SYSTEM_MODE,
|
|
72
|
+
SZ_TEMPERATURE as SZ_TEMPERATURE,
|
|
73
|
+
SZ_TOTAL_FRAGS as SZ_TOTAL_FRAGS,
|
|
74
|
+
SZ_UFH_IDX as SZ_UFH_IDX,
|
|
75
|
+
SZ_UNKNOWN as SZ_UNKNOWN,
|
|
76
|
+
SZ_UNTIL as SZ_UNTIL,
|
|
77
|
+
SZ_VALUE as SZ_VALUE,
|
|
78
|
+
SZ_WINDOW_OPEN as SZ_WINDOW_OPEN,
|
|
79
|
+
SZ_ZONE_CLASS as SZ_ZONE_CLASS,
|
|
80
|
+
SZ_ZONE_IDX as SZ_ZONE_IDX,
|
|
81
|
+
SZ_ZONE_MASK as SZ_ZONE_MASK,
|
|
82
|
+
SZ_ZONE_TYPE as SZ_ZONE_TYPE,
|
|
83
|
+
SZ_ZONES as SZ_ZONES,
|
|
84
|
+
ZON_MODE_MAP as ZON_MODE_MAP,
|
|
85
|
+
SystemType as SystemType,
|
|
86
|
+
)
|
|
8
87
|
|
|
9
|
-
from .
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
SZ_AIR_QUALITY_BASE,
|
|
18
|
-
SZ_BOOST_TIMER,
|
|
19
|
-
SZ_BYPASS_POSITION,
|
|
20
|
-
SZ_CHANGE_COUNTER,
|
|
21
|
-
SZ_CO2_LEVEL,
|
|
22
|
-
SZ_DATETIME,
|
|
23
|
-
SZ_DEVICE_ID,
|
|
24
|
-
SZ_DEVICE_ROLE,
|
|
25
|
-
SZ_DEVICES,
|
|
26
|
-
SZ_DHW_IDX,
|
|
27
|
-
SZ_DOMAIN_ID,
|
|
28
|
-
SZ_DURATION,
|
|
29
|
-
SZ_EXHAUST_FAN_SPEED,
|
|
30
|
-
SZ_EXHAUST_FLOW,
|
|
31
|
-
SZ_EXHAUST_TEMPERATURE,
|
|
32
|
-
SZ_FAN_INFO,
|
|
33
|
-
SZ_FAN_MODE,
|
|
34
|
-
SZ_FRAG_LENGTH,
|
|
35
|
-
SZ_FRAG_NUMBER,
|
|
36
|
-
SZ_FRAGMENT,
|
|
37
|
-
SZ_HEAT_DEMAND,
|
|
38
|
-
SZ_INDOOR_HUMIDITY,
|
|
39
|
-
SZ_INDOOR_TEMPERATURE,
|
|
40
|
-
SZ_LANGUAGE,
|
|
41
|
-
SZ_MODE,
|
|
42
|
-
SZ_NAME,
|
|
43
|
-
SZ_OUTDOOR_HUMIDITY,
|
|
44
|
-
SZ_OUTDOOR_TEMPERATURE,
|
|
45
|
-
SZ_PAYLOAD,
|
|
46
|
-
SZ_POST_HEAT,
|
|
47
|
-
SZ_PRE_HEAT,
|
|
48
|
-
SZ_PRESENCE_DETECTED,
|
|
49
|
-
SZ_PRESSURE,
|
|
50
|
-
SZ_PRIORITY,
|
|
51
|
-
SZ_RELAY_DEMAND,
|
|
52
|
-
SZ_RELAY_FAILSAFE,
|
|
53
|
-
SZ_REMAINING_TIME,
|
|
54
|
-
SZ_RETRIES,
|
|
55
|
-
SZ_SCHEDULE,
|
|
56
|
-
SZ_SENSOR,
|
|
57
|
-
SZ_SETPOINT,
|
|
58
|
-
SZ_SPEED_CAP,
|
|
59
|
-
SZ_SUPPLY_FAN_SPEED,
|
|
60
|
-
SZ_SUPPLY_FLOW,
|
|
61
|
-
SZ_SUPPLY_TEMPERATURE,
|
|
62
|
-
SZ_SYSTEM_MODE,
|
|
63
|
-
SZ_TEMPERATURE,
|
|
64
|
-
SZ_TOTAL_FRAGS,
|
|
65
|
-
SZ_UFH_IDX,
|
|
66
|
-
SZ_UNKNOWN,
|
|
67
|
-
SZ_UNTIL,
|
|
68
|
-
SZ_VALUE,
|
|
69
|
-
SZ_WINDOW_OPEN,
|
|
70
|
-
SZ_ZONE_CLASS,
|
|
71
|
-
SZ_ZONE_IDX,
|
|
72
|
-
SZ_ZONE_MASK,
|
|
73
|
-
SZ_ZONE_TYPE,
|
|
74
|
-
SZ_ZONES,
|
|
75
|
-
ZON_MODE_MAP,
|
|
76
|
-
SystemType,
|
|
88
|
+
from ramses_tx.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
89
|
+
I_ as I_,
|
|
90
|
+
RP as RP,
|
|
91
|
+
RQ as RQ,
|
|
92
|
+
W_ as W_,
|
|
93
|
+
Code as Code,
|
|
94
|
+
IndexT as IndexT,
|
|
95
|
+
VerbT as VerbT,
|
|
77
96
|
)
|
|
78
97
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
DEV_ROLE_MAP,
|
|
91
|
-
DEV_TYPE,
|
|
92
|
-
DEV_TYPE_MAP,
|
|
93
|
-
ZON_ROLE,
|
|
94
|
-
ZON_ROLE_MAP,
|
|
95
|
-
Code,
|
|
98
|
+
from ramses_tx.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import
|
|
99
|
+
F9 as F9,
|
|
100
|
+
FA as FA,
|
|
101
|
+
FC as FC,
|
|
102
|
+
FF as FF,
|
|
103
|
+
DEV_ROLE_MAP as DEV_ROLE_MAP,
|
|
104
|
+
DEV_TYPE_MAP as DEV_TYPE_MAP,
|
|
105
|
+
ZON_ROLE_MAP as ZON_ROLE_MAP,
|
|
106
|
+
DevRole as DevRole,
|
|
107
|
+
DevType as DevType,
|
|
108
|
+
ZoneRole as ZoneRole,
|
|
96
109
|
)
|
|
97
110
|
|
|
111
|
+
if TYPE_CHECKING:
|
|
112
|
+
from ramses_tx.const import ( # noqa: F401, pylint: disable=unused-import
|
|
113
|
+
IndexT,
|
|
114
|
+
VerbT,
|
|
115
|
+
)
|
|
98
116
|
|
|
99
|
-
__dev_mode__ = False
|
|
100
|
-
# DEV_MODE = __dev_mode__
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
__dev_mode__ = False # NOTE: this is const.py
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Discover(IntEnum):
|
|
122
|
+
NOTHING = 0
|
|
123
|
+
SCHEMA = 1
|
|
124
|
+
PARAMS = 2
|
|
125
|
+
STATUS = 4
|
|
126
|
+
FAULTS = 8
|
|
127
|
+
SCHEDS = 16
|
|
128
|
+
TRAITS = 32
|
|
129
|
+
DEFAULT = 1 + 2 + 4
|
|
130
|
+
|
|
112
131
|
|
|
113
|
-
DONT_CREATE_MESSAGES = 3
|
|
114
|
-
DONT_CREATE_ENTITIES = 2
|
|
115
|
-
DONT_UPDATE_ENTITIES = 1
|
|
132
|
+
DONT_CREATE_MESSAGES: Final[int] = 3
|
|
133
|
+
DONT_CREATE_ENTITIES: Final[int] = 2
|
|
134
|
+
DONT_UPDATE_ENTITIES: Final[int] = 1
|
|
116
135
|
|
|
117
|
-
SCHED_REFRESH_INTERVAL = 3 # minutes
|
|
136
|
+
SCHED_REFRESH_INTERVAL: Final[int] = 3 # minutes
|
|
118
137
|
|
|
119
138
|
# Status codes for Worcester Bosch boilers - OT|OEM diagnostic code
|
|
120
|
-
WB_STATUS_CODES = {
|
|
139
|
+
WB_STATUS_CODES: Final[dict[str, str]] = {
|
|
121
140
|
"200": "CH system is being heated.",
|
|
122
141
|
"201": "DHW system is being heated.",
|
|
123
142
|
"202": "Anti rapid cycle mode. The boiler has commenced anti-cycle period for CH.",
|
ramses_rf/database.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""RAMSES RF - Message database and index."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from collections import OrderedDict
|
|
10
|
+
from datetime import datetime as dt, timedelta as td
|
|
11
|
+
from typing import NewType, TypedDict
|
|
12
|
+
|
|
13
|
+
from ramses_tx import Message
|
|
14
|
+
|
|
15
|
+
DtmStrT = NewType("DtmStrT", str)
|
|
16
|
+
MsgDdT = OrderedDict[DtmStrT, Message]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Params(TypedDict):
|
|
20
|
+
dtm: dt | str | None
|
|
21
|
+
verb: str | None
|
|
22
|
+
src: str | None
|
|
23
|
+
dst: str | None
|
|
24
|
+
code: str | None
|
|
25
|
+
ctx: str | None
|
|
26
|
+
hdr: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_LOGGER = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MessageIndex:
|
|
33
|
+
"""A simple in-memory SQLite3 database for indexing messages."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
"""Instantiate a message database/index."""
|
|
37
|
+
|
|
38
|
+
self._msgs: MsgDdT = OrderedDict()
|
|
39
|
+
|
|
40
|
+
self._cx = sqlite3.connect(":memory:") # Connect to a SQLite DB in memory
|
|
41
|
+
self._cu = self._cx.cursor() # Create a cursor
|
|
42
|
+
|
|
43
|
+
self._setup_db_adapters() # dtm adapter/converter
|
|
44
|
+
self._setup_db_schema()
|
|
45
|
+
|
|
46
|
+
self._lock = asyncio.Lock()
|
|
47
|
+
self._last_housekeeping: dt = None # type: ignore[assignment]
|
|
48
|
+
self._housekeeping_task: asyncio.Task[None] = None # type: ignore[assignment]
|
|
49
|
+
|
|
50
|
+
self.start()
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"MessageIndex({len(self._msgs)} messages)"
|
|
54
|
+
|
|
55
|
+
def start(self) -> None:
|
|
56
|
+
"""Start the housekeeper loop."""
|
|
57
|
+
|
|
58
|
+
if self._housekeeping_task and not self._housekeeping_task.done():
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
self._housekeeping_task = asyncio.create_task(
|
|
62
|
+
self._housekeeping_loop(), name=f"{self.__class__.__name__}.housekeeper"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
"""Stop the housekeeper loop."""
|
|
67
|
+
|
|
68
|
+
if self._housekeeping_task and not self._housekeeping_task.done():
|
|
69
|
+
self._housekeeping_task.cancel() # stop the housekeeper
|
|
70
|
+
|
|
71
|
+
self._cx.commit() # just in case
|
|
72
|
+
# self._cx.close() # may still need to do queries after engine has stopped?
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def msgs(self) -> MsgDdT:
|
|
76
|
+
"""Return the messages in the index in a threadsafe way."""
|
|
77
|
+
return self._msgs
|
|
78
|
+
|
|
79
|
+
def _setup_db_adapters(self) -> None:
|
|
80
|
+
"""Setup the database adapters and converters."""
|
|
81
|
+
|
|
82
|
+
def adapt_datetime_iso(val: dt) -> str:
|
|
83
|
+
"""Adapt datetime.datetime to timezone-naive ISO 8601 datetime."""
|
|
84
|
+
return val.isoformat(timespec="microseconds")
|
|
85
|
+
|
|
86
|
+
sqlite3.register_adapter(dt, adapt_datetime_iso)
|
|
87
|
+
|
|
88
|
+
def convert_datetime(val: bytes) -> dt:
|
|
89
|
+
"""Convert ISO 8601 datetime to datetime.datetime object."""
|
|
90
|
+
return dt.fromisoformat(val.decode())
|
|
91
|
+
|
|
92
|
+
sqlite3.register_converter("dtm", convert_datetime)
|
|
93
|
+
|
|
94
|
+
def _setup_db_schema(self) -> None:
|
|
95
|
+
"""Setup the dayabase schema."""
|
|
96
|
+
|
|
97
|
+
self._cu.execute(
|
|
98
|
+
"""
|
|
99
|
+
CREATE TABLE messages (
|
|
100
|
+
dtm TEXT(26) NOT NULL PRIMARY KEY,
|
|
101
|
+
verb TEXT(2) NOT NULL,
|
|
102
|
+
src TEXT(9) NOT NULL,
|
|
103
|
+
dst TEXT(9) NOT NULL,
|
|
104
|
+
code TEXT(4) NOT NULL,
|
|
105
|
+
ctx TEXT NOT NULL,
|
|
106
|
+
hdr TEXT NOT NULL UNIQUE
|
|
107
|
+
)
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._cu.execute("CREATE INDEX idx_verb ON messages (verb)")
|
|
112
|
+
self._cu.execute("CREATE INDEX idx_src ON messages (src)")
|
|
113
|
+
self._cu.execute("CREATE INDEX idx_dst ON messages (dst)")
|
|
114
|
+
self._cu.execute("CREATE INDEX idx_code ON messages (code)")
|
|
115
|
+
self._cu.execute("CREATE INDEX idx_ctx ON messages (ctx)")
|
|
116
|
+
self._cu.execute("CREATE INDEX idx_hdr ON messages (hdr)")
|
|
117
|
+
|
|
118
|
+
self._cx.commit()
|
|
119
|
+
|
|
120
|
+
async def _housekeeping_loop(self) -> None:
|
|
121
|
+
"""Periodically remove stale messages from the index."""
|
|
122
|
+
|
|
123
|
+
def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
|
|
124
|
+
dtm = (dt_now - _cutoff).isoformat(timespec="microseconds")
|
|
125
|
+
|
|
126
|
+
self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
|
|
127
|
+
rows = self._cu.fetchall()
|
|
128
|
+
|
|
129
|
+
try: # make this operation atomic, i.e. update self._msgs only on success
|
|
130
|
+
# await self._lock.acquire()
|
|
131
|
+
self._cu.execute("DELETE FROM messages WHERE dtm < ?", (dtm,))
|
|
132
|
+
msgs = OrderedDict({row[0]: self._msgs[row[0]] for row in rows})
|
|
133
|
+
self._cx.commit()
|
|
134
|
+
|
|
135
|
+
except sqlite3.Error: # need to tighten?
|
|
136
|
+
self._cx.rollback()
|
|
137
|
+
else:
|
|
138
|
+
self._msgs = msgs
|
|
139
|
+
finally:
|
|
140
|
+
pass # self._lock.release()
|
|
141
|
+
|
|
142
|
+
while True:
|
|
143
|
+
self._last_housekeeping = dt.now()
|
|
144
|
+
await asyncio.sleep(3600)
|
|
145
|
+
housekeeping(self._last_housekeeping)
|
|
146
|
+
|
|
147
|
+
def add(self, msg: Message) -> Message | None:
|
|
148
|
+
"""Add a single message to the index.
|
|
149
|
+
|
|
150
|
+
Returns any message that was removed because it had the same header.
|
|
151
|
+
|
|
152
|
+
Throws a warning is there is a duplicate dtm.
|
|
153
|
+
""" # TODO: eventually, may be better to use SqlAlchemy
|
|
154
|
+
|
|
155
|
+
dup: tuple[Message, ...] = tuple() # avoid UnboundLocalError
|
|
156
|
+
old: Message | None = None # avoid UnboundLocalError
|
|
157
|
+
|
|
158
|
+
try: # TODO: remove, or use only when source is a packet log?
|
|
159
|
+
# await self._lock.acquire()
|
|
160
|
+
dup = self._delete_from( # HACK: because of contrived pkt logs
|
|
161
|
+
dtm=msg.dtm.isoformat(timespec="microseconds")
|
|
162
|
+
)
|
|
163
|
+
old = self._insert_into(msg) # will delete old msg by hdr
|
|
164
|
+
|
|
165
|
+
except sqlite3.Error: # UNIQUE constraint failed: ? messages.dtm (so: HACK)
|
|
166
|
+
self._cx.rollback()
|
|
167
|
+
|
|
168
|
+
else:
|
|
169
|
+
dtm: DtmStrT = msg.dtm.isoformat(timespec="microseconds") # type: ignore[assignment]
|
|
170
|
+
self._msgs[dtm] = msg
|
|
171
|
+
|
|
172
|
+
finally:
|
|
173
|
+
pass # self._lock.release()
|
|
174
|
+
|
|
175
|
+
if dup:
|
|
176
|
+
_LOGGER.warning(
|
|
177
|
+
"Overwrote dtm for %s: %s (contrived log?)", msg._pkt._hdr, dup[0]._pkt
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return old
|
|
181
|
+
|
|
182
|
+
def _insert_into(self, msg: Message) -> Message | None:
|
|
183
|
+
"""Insert a message into the index (and return any message replaced by hdr)."""
|
|
184
|
+
|
|
185
|
+
msgs = self._delete_from(hdr=msg._pkt._hdr)
|
|
186
|
+
|
|
187
|
+
sql = """
|
|
188
|
+
INSERT INTO messages (dtm, verb, src, dst, code, ctx, hdr)
|
|
189
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
self._cu.execute(
|
|
193
|
+
sql,
|
|
194
|
+
(
|
|
195
|
+
msg.dtm,
|
|
196
|
+
msg.verb,
|
|
197
|
+
msg.src.id,
|
|
198
|
+
msg.dst.id,
|
|
199
|
+
msg.code,
|
|
200
|
+
msg._pkt._ctx,
|
|
201
|
+
msg._pkt._hdr,
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return msgs[0] if msgs else None
|
|
206
|
+
|
|
207
|
+
def rem(self, msg: Message | None = None, **kwargs: str) -> tuple[Message, ...]:
|
|
208
|
+
"""Remove a set of message(s) from the index.
|
|
209
|
+
|
|
210
|
+
Returns any messages that were removed.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
if bool(msg) ^ bool(kwargs):
|
|
214
|
+
raise ValueError("Either a Message or kwargs should be provided, not both")
|
|
215
|
+
if msg:
|
|
216
|
+
kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
|
|
217
|
+
|
|
218
|
+
try: # make this operation atomic, i.e. update self._msgs only on success
|
|
219
|
+
# await self._lock.acquire()
|
|
220
|
+
msgs = self._delete_from(**kwargs)
|
|
221
|
+
|
|
222
|
+
except sqlite3.Error: # need to tighten?
|
|
223
|
+
self._cx.rollback()
|
|
224
|
+
|
|
225
|
+
else:
|
|
226
|
+
for msg in msgs:
|
|
227
|
+
dtm: DtmStrT = msg.dtm.isoformat(timespec="microseconds") # type: ignore[assignment]
|
|
228
|
+
self._msgs.pop(dtm)
|
|
229
|
+
|
|
230
|
+
finally:
|
|
231
|
+
pass # self._lock.release()
|
|
232
|
+
|
|
233
|
+
return msgs
|
|
234
|
+
|
|
235
|
+
def _delete_from(self, **kwargs: str) -> tuple[Message, ...]:
|
|
236
|
+
"""Remove message(s) from the index (and return any messages removed)."""
|
|
237
|
+
|
|
238
|
+
msgs = self._select_from(**kwargs)
|
|
239
|
+
|
|
240
|
+
sql = "DELETE FROM messages WHERE "
|
|
241
|
+
sql += " AND ".join(f"{k} = ?" for k in kwargs)
|
|
242
|
+
|
|
243
|
+
self._cu.execute(sql, tuple(kwargs.values()))
|
|
244
|
+
|
|
245
|
+
return msgs
|
|
246
|
+
|
|
247
|
+
def get(self, msg: Message | None = None, **kwargs: str) -> tuple[Message, ...]:
|
|
248
|
+
"""Return a set of message(s) from the index."""
|
|
249
|
+
|
|
250
|
+
if not (bool(msg) ^ bool(kwargs)):
|
|
251
|
+
raise ValueError("Either a Message or kwargs should be provided, not both")
|
|
252
|
+
if msg:
|
|
253
|
+
kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
|
|
254
|
+
|
|
255
|
+
return self._select_from(**kwargs)
|
|
256
|
+
|
|
257
|
+
def _select_from(self, **kwargs: str) -> tuple[Message, ...]:
|
|
258
|
+
"""Select message(s) from the index (and return any such messages)."""
|
|
259
|
+
|
|
260
|
+
sql = "SELECT dtm FROM messages WHERE "
|
|
261
|
+
sql += " AND ".join(f"{k} = ?" for k in kwargs)
|
|
262
|
+
|
|
263
|
+
self._cu.execute(sql, tuple(kwargs.values()))
|
|
264
|
+
|
|
265
|
+
return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
|
|
266
|
+
|
|
267
|
+
def qry(self, sql: str, parameters: tuple[str, ...]) -> tuple[Message, ...]:
|
|
268
|
+
"""Return a set of message(s) from the index, given sql and parameters."""
|
|
269
|
+
|
|
270
|
+
if "SELECT" not in sql:
|
|
271
|
+
raise ValueError(f"{self}: Only SELECT queries are allowed")
|
|
272
|
+
|
|
273
|
+
self._cu.execute(sql, parameters)
|
|
274
|
+
|
|
275
|
+
return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
|
|
276
|
+
|
|
277
|
+
def all(self, include_expired: bool = False) -> tuple[Message, ...]:
|
|
278
|
+
"""Return all messages from the index."""
|
|
279
|
+
|
|
280
|
+
# self.cursor.execute("SELECT * FROM messages")
|
|
281
|
+
# return [self._megs[row[0]] for row in self.cursor.fetchall()]
|
|
282
|
+
|
|
283
|
+
return tuple(
|
|
284
|
+
m for m in self._msgs.values() if include_expired or not m._expired
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def clr(self) -> None:
|
|
288
|
+
"""Clear the message index (remove all messages)."""
|
|
289
|
+
|
|
290
|
+
self._cu.execute("DELETE FROM messages")
|
|
291
|
+
self._cx.commit()
|
|
292
|
+
|
|
293
|
+
self._msgs.clear()
|
|
294
|
+
|
|
295
|
+
# def _msgs(self, device_id: DeviceIdT) -> tuple[Message, ...]:
|
|
296
|
+
# msgs = [msg for msg in self._msgs.values() if msg.src.id == device_id]
|
|
297
|
+
# return msgs
|