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.
Files changed (71) hide show
  1. ramses_cli/__init__.py +18 -0
  2. ramses_cli/client.py +597 -0
  3. ramses_cli/debug.py +20 -0
  4. ramses_cli/discovery.py +405 -0
  5. ramses_cli/utils/cat_slow.py +17 -0
  6. ramses_cli/utils/convert.py +60 -0
  7. ramses_rf/__init__.py +31 -10
  8. ramses_rf/binding_fsm.py +787 -0
  9. ramses_rf/const.py +124 -105
  10. ramses_rf/database.py +297 -0
  11. ramses_rf/device/__init__.py +69 -39
  12. ramses_rf/device/base.py +187 -376
  13. ramses_rf/device/heat.py +540 -552
  14. ramses_rf/device/hvac.py +286 -171
  15. ramses_rf/dispatcher.py +153 -177
  16. ramses_rf/entity_base.py +478 -361
  17. ramses_rf/exceptions.py +82 -0
  18. ramses_rf/gateway.py +377 -513
  19. ramses_rf/helpers.py +57 -19
  20. ramses_rf/py.typed +0 -0
  21. ramses_rf/schemas.py +148 -194
  22. ramses_rf/system/__init__.py +16 -23
  23. ramses_rf/system/faultlog.py +363 -0
  24. ramses_rf/system/heat.py +295 -302
  25. ramses_rf/system/schedule.py +312 -198
  26. ramses_rf/system/zones.py +318 -238
  27. ramses_rf/version.py +2 -8
  28. ramses_rf-0.51.1.dist-info/METADATA +72 -0
  29. ramses_rf-0.51.1.dist-info/RECORD +55 -0
  30. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info}/WHEEL +1 -2
  31. ramses_rf-0.51.1.dist-info/entry_points.txt +2 -0
  32. {ramses_rf-0.22.40.dist-info → ramses_rf-0.51.1.dist-info/licenses}/LICENSE +1 -1
  33. ramses_tx/__init__.py +160 -0
  34. {ramses_rf/protocol → ramses_tx}/address.py +65 -59
  35. ramses_tx/command.py +1454 -0
  36. ramses_tx/const.py +903 -0
  37. ramses_tx/exceptions.py +92 -0
  38. {ramses_rf/protocol → ramses_tx}/fingerprints.py +56 -15
  39. {ramses_rf/protocol → ramses_tx}/frame.py +132 -131
  40. ramses_tx/gateway.py +338 -0
  41. ramses_tx/helpers.py +883 -0
  42. {ramses_rf/protocol → ramses_tx}/logger.py +67 -53
  43. {ramses_rf/protocol → ramses_tx}/message.py +155 -191
  44. ramses_tx/opentherm.py +1260 -0
  45. ramses_tx/packet.py +210 -0
  46. {ramses_rf/protocol → ramses_tx}/parsers.py +1266 -1003
  47. ramses_tx/protocol.py +801 -0
  48. ramses_tx/protocol_fsm.py +672 -0
  49. ramses_tx/py.typed +0 -0
  50. {ramses_rf/protocol → ramses_tx}/ramses.py +262 -185
  51. {ramses_rf/protocol → ramses_tx}/schemas.py +150 -133
  52. ramses_tx/transport.py +1471 -0
  53. ramses_tx/typed_dicts.py +492 -0
  54. ramses_tx/typing.py +181 -0
  55. ramses_tx/version.py +4 -0
  56. ramses_rf/discovery.py +0 -398
  57. ramses_rf/protocol/__init__.py +0 -59
  58. ramses_rf/protocol/backports.py +0 -42
  59. ramses_rf/protocol/command.py +0 -1576
  60. ramses_rf/protocol/const.py +0 -697
  61. ramses_rf/protocol/exceptions.py +0 -111
  62. ramses_rf/protocol/helpers.py +0 -390
  63. ramses_rf/protocol/opentherm.py +0 -1170
  64. ramses_rf/protocol/packet.py +0 -235
  65. ramses_rf/protocol/protocol.py +0 -613
  66. ramses_rf/protocol/transport.py +0 -1011
  67. ramses_rf/protocol/version.py +0 -10
  68. ramses_rf/system/hvac.py +0 -82
  69. ramses_rf-0.22.40.dist-info/METADATA +0 -64
  70. ramses_rf-0.22.40.dist-info/RECORD +0 -42
  71. 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 types import SimpleNamespace
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 .protocol.const import ( # noqa: F401
10
- DEFAULT_MAX_ZONES,
11
- DEVICE_ID_REGEX,
12
- DOMAIN_TYPE_MAP,
13
- FAN_MODE,
14
- SYS_MODE_MAP,
15
- SZ_ACTUATORS,
16
- SZ_AIR_QUALITY,
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
- # skipcq: PY-W2000
80
- from .protocol import ( # noqa: F401, isort: skip, pylint: disable=unused-import
81
- I_,
82
- RP,
83
- RQ,
84
- W_,
85
- F9,
86
- FA,
87
- FC,
88
- FF,
89
- DEV_ROLE,
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
- Discover = SimpleNamespace(
103
- NOTHING=0,
104
- SCHEMA=1,
105
- PARAMS=2,
106
- STATUS=4,
107
- FAULTS=8,
108
- SCHEDS=16,
109
- TRAITS=32,
110
- DEFAULT=(1 + 2 + 4),
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