ramses-rf 0.51.5__py3-none-any.whl → 0.51.7__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/client.py CHANGED
@@ -483,7 +483,6 @@ async def async_main(command: str, lib_kwargs: dict, **kwargs: Any) -> None:
483
483
  print(f"{COLORS.get(msg.verb)}{dtm} {msg}"[:con_cols])
484
484
 
485
485
  serial_port, lib_kwargs = normalise_config(lib_kwargs)
486
- assert isinstance(lib_kwargs.get(SZ_INPUT_FILE), str)
487
486
 
488
487
  if kwargs["restore_schema"]:
489
488
  print(" - restoring client schema from a HA cache...")
ramses_rf/database.py CHANGED
@@ -29,6 +29,22 @@ class Params(TypedDict):
29
29
  _LOGGER = logging.getLogger(__name__)
30
30
 
31
31
 
32
+ def _setup_db_adapters() -> None:
33
+ """Set up the database adapters and converters."""
34
+
35
+ def adapt_datetime_iso(val: dt) -> str:
36
+ """Adapt datetime.datetime to timezone-naive ISO 8601 datetime."""
37
+ return val.isoformat(timespec="microseconds")
38
+
39
+ sqlite3.register_adapter(dt, adapt_datetime_iso)
40
+
41
+ def convert_datetime(val: bytes) -> dt:
42
+ """Convert ISO 8601 datetime to datetime.datetime object."""
43
+ return dt.fromisoformat(val.decode())
44
+
45
+ sqlite3.register_converter("dtm", convert_datetime)
46
+
47
+
32
48
  class MessageIndex:
33
49
  """A simple in-memory SQLite3 database for indexing messages."""
34
50
 
@@ -40,7 +56,7 @@ class MessageIndex:
40
56
  self._cx = sqlite3.connect(":memory:") # Connect to a SQLite DB in memory
41
57
  self._cu = self._cx.cursor() # Create a cursor
42
58
 
43
- self._setup_db_adapters() # dtm adapter/converter
59
+ _setup_db_adapters() # dtm adapter/converter
44
60
  self._setup_db_schema()
45
61
 
46
62
  self._lock = asyncio.Lock()
@@ -76,23 +92,19 @@ class MessageIndex:
76
92
  """Return the messages in the index in a threadsafe way."""
77
93
  return self._msgs
78
94
 
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())
95
+ def _setup_db_schema(self) -> None:
96
+ """Set up the message database schema.
91
97
 
92
- sqlite3.register_converter("dtm", convert_datetime)
98
+ Fields:
93
99
 
94
- def _setup_db_schema(self) -> None:
95
- """Setup the dayabase schema."""
100
+ - dtm message timestamp
101
+ - verb _I, RQ etc.
102
+ - src message origin address
103
+ - dst message destination address
104
+ - code packet code aka command class e.g. _0005, _31DA
105
+ - ctx message context, created from payload as index + extra markers (Heat)
106
+ - hdr packet header e.g. 000C|RP|01:223036|0208 (see: src/ramses_tx/frame.py)
107
+ """
96
108
 
97
109
  self._cu.execute(
98
110
  """
@@ -120,14 +132,14 @@ class MessageIndex:
120
132
  async def _housekeeping_loop(self) -> None:
121
133
  """Periodically remove stale messages from the index."""
122
134
 
123
- def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
135
+ async def housekeeping(dt_now: dt, _cutoff: td = td(days=1)) -> None:
124
136
  dtm = (dt_now - _cutoff).isoformat(timespec="microseconds")
125
137
 
126
138
  self._cu.execute("SELECT dtm FROM messages WHERE dtm => ?", (dtm,))
127
139
  rows = self._cu.fetchall()
128
140
 
129
141
  try: # make this operation atomic, i.e. update self._msgs only on success
130
- # await self._lock.acquire()
142
+ await self._lock.acquire()
131
143
  self._cu.execute("DELETE FROM messages WHERE dtm < ?", (dtm,))
132
144
  msgs = OrderedDict({row[0]: self._msgs[row[0]] for row in rows})
133
145
  self._cx.commit()
@@ -137,19 +149,19 @@ class MessageIndex:
137
149
  else:
138
150
  self._msgs = msgs
139
151
  finally:
140
- pass # self._lock.release()
152
+ self._lock.release()
141
153
 
142
154
  while True:
143
155
  self._last_housekeeping = dt.now()
144
156
  await asyncio.sleep(3600)
145
- housekeeping(self._last_housekeeping)
157
+ await housekeeping(self._last_housekeeping)
146
158
 
147
159
  def add(self, msg: Message) -> Message | None:
148
160
  """Add a single message to the index.
149
161
 
150
162
  Returns any message that was removed because it had the same header.
151
163
 
152
- Throws a warning is there is a duplicate dtm.
164
+ Throws a warning if there is a duplicate dtm.
153
165
  """ # TODO: eventually, may be better to use SqlAlchemy
154
166
 
155
167
  dup: tuple[Message, ...] = tuple() # avoid UnboundLocalError
@@ -204,7 +216,9 @@ class MessageIndex:
204
216
 
205
217
  return msgs[0] if msgs else None
206
218
 
207
- def rem(self, msg: Message | None = None, **kwargs: str) -> tuple[Message, ...]:
219
+ def rem(
220
+ self, msg: Message | None = None, **kwargs: str
221
+ ) -> tuple[Message, ...] | None:
208
222
  """Remove a set of message(s) from the index.
209
223
 
210
224
  Returns any messages that were removed.
@@ -215,6 +229,7 @@ class MessageIndex:
215
229
  if msg:
216
230
  kwargs["dtm"] = msg.dtm.isoformat(timespec="microseconds")
217
231
 
232
+ msgs = None
218
233
  try: # make this operation atomic, i.e. update self._msgs only on success
219
234
  # await self._lock.acquire()
220
235
  msgs = self._delete_from(**kwargs)
@@ -277,8 +292,8 @@ class MessageIndex:
277
292
  def all(self, include_expired: bool = False) -> tuple[Message, ...]:
278
293
  """Return all messages from the index."""
279
294
 
280
- # self.cursor.execute("SELECT * FROM messages")
281
- # return [self._megs[row[0]] for row in self.cursor.fetchall()]
295
+ # self._cu.execute("SELECT * FROM messages")
296
+ # return tuple(self._msgs[row[0]] for row in self._cu.fetchall())
282
297
 
283
298
  return tuple(
284
299
  m for m in self._msgs.values() if include_expired or not m._expired
ramses_rf/device/base.py CHANGED
@@ -62,7 +62,7 @@ class DeviceBase(Entity):
62
62
 
63
63
  # FIXME: ZZZ entities must know their parent device ID and their own idx
64
64
  self._z_id = dev_addr.id # the responsible device is itself
65
- self._z_idx = None # depends upon it's location in the schema
65
+ self._z_idx = None # depends upon its location in the schema
66
66
 
67
67
  self.id: DeviceIdT = dev_addr.id
68
68
 
@@ -95,7 +95,7 @@ class DeviceBase(Entity):
95
95
 
96
96
  if traits.get(SZ_FAKED): # class & alias are done elsewhere
97
97
  if not isinstance(self, Fakeable):
98
- raise TypeError(f"Device is not fakable: {self} (traits={traits})")
98
+ raise TypeError(f"Device is not fakeable: {self} (traits={traits})")
99
99
  self._make_fake()
100
100
 
101
101
  self._scheme = traits.get(SZ_SCHEME)
ramses_rf/device/hvac.py CHANGED
@@ -449,10 +449,10 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
449
449
  def fan_info(self) -> str | None:
450
450
  """
451
451
  Extract fan info description from _31D9 or _31DA message payload, e.g. "speed 2, medium".
452
- By its name, the result is automatically displayed in HA Climate UI.
452
+ By its name, the result is picked up by a sensor in HA Climate UI.
453
453
  Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).
454
454
 
455
- :return: a string describing mode, speed
455
+ :return: a string describing fan mode, speed
456
456
  """
457
457
  if Code._31D9 in self._msgs:
458
458
  # Itho, Vasco D60 and ClimaRad (MiniBox fan) send mode/speed in _31D9
@@ -466,9 +466,10 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
466
466
  @property
467
467
  def indoor_humidity(self) -> float | None:
468
468
  """
469
- Extract humidity value from _12A0 or _31DA JSON message payload
469
+ Extract indoor_humidity from MessageIndex _12A0 or _31DA payload
470
+ Just a demo for SQLite query helper at the moment.
470
471
 
471
- :return: percentage <= 1.0
472
+ :return: float RH value from 0.0 to 1.0 = 100%
472
473
  """
473
474
  if Code._12A0 in self._msgs and isinstance(
474
475
  self._msgs[Code._12A0].payload, list
ramses_rf/dispatcher.py CHANGED
@@ -268,8 +268,8 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
268
268
 
269
269
  else:
270
270
  logger_xxxx(msg)
271
- if gwy._zzz:
272
- gwy._zzz.add(msg)
271
+ if gwy.msg_db:
272
+ gwy.msg_db.add(msg)
273
273
 
274
274
 
275
275
  # TODO: this needs cleaning up (e.g. handle intervening packet)
ramses_rf/entity_base.py CHANGED
@@ -190,7 +190,7 @@ class _MessageDB(_Entity):
190
190
  def __init__(self, gwy: Gateway) -> None:
191
191
  super().__init__(gwy)
192
192
 
193
- self._msgs_: dict[Code, Message] = {} # code, should be code/ctx? ?deprecate
193
+ self._msgs_: dict[Code, Message] = {} # code, should be code/ctx?
194
194
  self._msgz_: dict[
195
195
  Code, dict[VerbT, dict[bool | str | None, Message]]
196
196
  ] = {} # code/verb/ctx, should be code/ctx/verb?
@@ -205,19 +205,19 @@ class _MessageDB(_Entity):
205
205
  ):
206
206
  return # ZZZ: don't store these
207
207
 
208
+ # Store msg by code in flat self._msgs_ Dict (deprecated since 0.50.3)
208
209
  if msg.verb in (I_, RP):
209
210
  self._msgs_[msg.code] = msg
210
211
 
211
212
  if msg.code not in self._msgz_:
213
+ # Store msg verb + ctx by code in nested self._msgz_ Dict (deprecated)
212
214
  self._msgz_[msg.code] = {msg.verb: {msg._pkt._ctx: msg}}
213
215
  elif msg.verb not in self._msgz_[msg.code]:
216
+ # Same, 1 level deeper
214
217
  self._msgz_[msg.code][msg.verb] = {msg._pkt._ctx: msg}
215
- else: # if not self._gwy._zzz:
218
+ else:
219
+ # Same, replacing previous message
216
220
  self._msgz_[msg.code][msg.verb][msg._pkt._ctx] = msg
217
- # elif (
218
- # self._msgz[msg.code][msg.verb][msg._pkt._ctx] is not msg
219
- # ): # MsgIdx ensures this
220
- # assert False # TODO: remove
221
221
 
222
222
  @property
223
223
  def _msg_db(self) -> list[Message]: # flattened version of _msgz[code][verb][indx]
@@ -228,7 +228,7 @@ class _MessageDB(_Entity):
228
228
  - a compound ctx (e.g. 0005/000C/0418)
229
229
  - True (an array of elements, each with its own idx),
230
230
  - False (no idx, is usu. 00),
231
- - None (not deteminable, rare)
231
+ - None (not determinable, rare)
232
232
  """
233
233
  return [m for c in self._msgz.values() for v in c.values() for m in v.values()]
234
234
 
@@ -239,8 +239,8 @@ class _MessageDB(_Entity):
239
239
 
240
240
  obj: _MessageDB
241
241
 
242
- if self._gwy._zzz:
243
- self._gwy._zzz.rem(msg)
242
+ if self._gwy.msg_db: # central SQLite MessageIndex
243
+ self._gwy.msg_db.rem(msg)
244
244
 
245
245
  entities: list[_MessageDB] = []
246
246
  if isinstance(msg.src, Device):
@@ -261,8 +261,8 @@ class _MessageDB(_Entity):
261
261
  def _get_msg_by_hdr(self, hdr: HeaderT) -> Message | None:
262
262
  """Return a msg, if any, that matches a header."""
263
263
 
264
- # if self._gwy._zzz:
265
- # msgs = self._gwy._zzz.get(hdr=hdr)
264
+ # if self._gwy.msg_db: # central SQLite MessageIndex
265
+ # msgs = self._gwy.msg_db.get(hdr=hdr)
266
266
  # return msgs[0] if msgs else None
267
267
 
268
268
  msg: Message
@@ -394,19 +394,29 @@ class _MessageDB(_Entity):
394
394
 
395
395
  @property
396
396
  def _msgs(self) -> dict[Code, Message]:
397
- if not self._gwy._zzz:
397
+ """
398
+ Get a flat Dict af all I/RP messages logged with this device as src or dst.
399
+
400
+ :return: nested Dict of messages by Code
401
+ """
402
+ if not self._gwy.msg_db: # no central SQLite MessageIndex
398
403
  return self._msgs_
399
404
 
400
405
  sql = """
401
406
  SELECT dtm from messages WHERE verb in (' I', 'RP') AND (src = ? OR dst = ?)
402
407
  """
403
- return { # ? use context instead?
404
- m.code: m for m in self._gwy._zzz.qry(sql, (self.id[:9], self.id[:9]))
408
+ return { # ? use ctx (context) instead of just the address?
409
+ m.code: m for m in self._gwy.msg_db.qry(sql, (self.id[:9], self.id[:9]))
405
410
  } # e.g. 01:123456_HW
406
411
 
407
412
  @property
408
413
  def _msgz(self) -> dict[Code, dict[VerbT, dict[bool | str | None, Message]]]:
409
- if not self._gwy._zzz:
414
+ """
415
+ Get a nested Dict of all I/RP messages logged with this device as src or dst.
416
+
417
+ :return: Dict of messages, nested by Code, Verb, Context
418
+ """
419
+ if not self._gwy.msg_db: # no central SQLite MessageIndex
410
420
  return self._msgz_
411
421
 
412
422
  msgs_1: dict[Code, dict[VerbT, dict[bool | str | None, Message]]] = {}
@@ -437,7 +447,7 @@ class _Discovery(_MessageDB):
437
447
  self._supported_cmds_ctx: dict[str, bool | None] = {}
438
448
 
439
449
  if not gwy.config.disable_discovery:
440
- # self._start_discovery_poller() # Can't use derived classes dont exist yet
450
+ # self._start_discovery_poller() # Can't use derived classes don't exist yet
441
451
  gwy._loop.call_soon(self._start_discovery_poller)
442
452
 
443
453
  @property # TODO: needs tidy up
@@ -464,7 +474,7 @@ class _Discovery(_MessageDB):
464
474
  def _to_data_id(msg_id: MsgId | str) -> OtDataId:
465
475
  return int(msg_id, 16) # type: ignore[return-value]
466
476
 
467
- def _to_msg_id(data_id: OtDataId | int) -> MsgId:
477
+ def _to_msg_id(data_id: OtDataId | int) -> MsgId: # not used
468
478
  return f"{data_id:02X}" # type: ignore[return-value]
469
479
 
470
480
  return {
@@ -639,7 +649,7 @@ class _Discovery(_MessageDB):
639
649
  task[_SZ_NEXT_DUE] = msg.dtm + task[_SZ_INTERVAL]
640
650
 
641
651
  if task[_SZ_NEXT_DUE] > dt_now:
642
- continue # if (most recent) last_msg is is not yet due...
652
+ continue # if (most recent) last_msg is not yet due...
643
653
 
644
654
  # since we may do I/O, check if the code|msg_id is deprecated
645
655
  task[_SZ_NEXT_DUE] = dt_now + task[_SZ_INTERVAL] # might undeprecate later
@@ -733,25 +743,6 @@ class Parent(Entity): # A System, Zone, DhwZone or a UfhController
733
743
  self.child_by_id: dict[str, Child] = {}
734
744
  self.childs: list[Child] = []
735
745
 
736
- # def _handle_msg(self, msg: Message) -> None:
737
- # def eavesdrop_ufh_circuits():
738
- # if msg.code == Code._22C9:
739
- # # .I --- 02:044446 --:------ 02:044446 22C9 024 00-076C0A28-01 01-06720A28-01 02-06A40A28-01 03-06A40A2-801 # NOTE: fragments
740
- # # .I --- 02:044446 --:------ 02:044446 22C9 006 04-07D00A28-01 # [{'ufh_idx': '04',...
741
- # circuit_idxs = [c[SZ_UFH_IDX] for c in msg.payload]
742
-
743
- # for cct_idx in circuit_idxs:
744
- # self.get_circuit(cct_idx, msg=msg)
745
-
746
- # # BUG: this will fail with > 4 circuits, as uses two pkts for this msg
747
- # # if [c for c in self.child_by_id if c not in circuit_idxs]:
748
- # # raise CorruptStateError
749
-
750
- # super()._handle_msg(msg)
751
-
752
- # if self._gwy.config.enable_eavesdrop:
753
- # eavesdrop_ufh_circuits()
754
-
755
746
  @property
756
747
  def zone_idx(self) -> str:
757
748
  """Return the domain id.
ramses_rf/gateway.py CHANGED
@@ -129,7 +129,7 @@ class Gateway(Engine):
129
129
  self.devices: list[Device] = []
130
130
  self.device_by_id: dict[DeviceIdT, Device] = {}
131
131
 
132
- self._zzz: MessageIndex | None = None # MessageIndex()
132
+ self.msg_db: MessageIndex | None = None # MessageIndex()
133
133
 
134
134
  def __repr__(self) -> str:
135
135
  if not self.ser_name:
@@ -196,8 +196,8 @@ class Gateway(Engine):
196
196
  async def stop(self) -> None:
197
197
  """Stop the Gateway and tidy up."""
198
198
 
199
- if self._zzz:
200
- self._zzz.stop()
199
+ if self.msg_db:
200
+ self.msg_db.stop()
201
201
  await super().stop()
202
202
 
203
203
  def _pause(self, *args: Any) -> None:
@@ -255,10 +255,10 @@ class Gateway(Engine):
255
255
  msgs.extend([m for z in system.zones for m in z._msgs.values()])
256
256
  # msgs.extend([m for z in system.dhw for m in z._msgs.values()]) # TODO
257
257
 
258
- if self._zzz:
258
+ if self.msg_db:
259
259
  pkts = {
260
260
  f"{repr(msg._pkt)[:26]}": f"{repr(msg._pkt)[27:]}"
261
- for msg in self._zzz.all(include_expired=True)
261
+ for msg in self.msg_db.all(include_expired=True)
262
262
  if wanted_msg(msg, include_expired=include_expired)
263
263
  }
264
264
 
@@ -405,7 +405,7 @@ class Gateway(Engine):
405
405
  _LOGGER.warning(f"The device is not fakeable: {dev}")
406
406
 
407
407
  # TODO: the exact order of the following may need refining...
408
- # TODO: some will be done my devices themselves?
408
+ # TODO: some will be done by devices themselves?
409
409
 
410
410
  # if schema: # Step 2: Only controllers have a schema...
411
411
  # dev._update_schema(**schema) # TODO: schema/traits
@@ -422,7 +422,7 @@ class Gateway(Engine):
422
422
  self,
423
423
  device_id: DeviceIdT,
424
424
  create_device: bool = False,
425
- ) -> Device:
425
+ ) -> Device | Fakeable:
426
426
  """Create a faked device."""
427
427
 
428
428
  if not is_valid_dev_id(device_id):
@@ -437,7 +437,7 @@ class Gateway(Engine):
437
437
  dev._make_fake()
438
438
  return dev
439
439
 
440
- raise TypeError(f"The device is not fakable: {device_id}")
440
+ raise TypeError(f"The device is not fakeable: {device_id}")
441
441
 
442
442
  @property
443
443
  def tcs(self) -> Evohome | None:
@@ -547,14 +547,6 @@ class Gateway(Engine):
547
547
 
548
548
  def _msg_handler(self, msg: Message) -> None:
549
549
  """A callback to handle messages from the protocol stack."""
550
- # TODO: Remove this
551
- # # HACK: if CLI, double-logging with client.py proc_msg() & setLevel(DEBUG)
552
- # if (log_level := _LOGGER.getEffectiveLevel()) < logging.INFO:
553
- # _LOGGER.info(msg)
554
- # elif log_level <= logging.INFO and not (
555
- # msg.verb == RQ and msg.src.type == DEV_TYPE_MAP.HGI
556
- # ):
557
- # _LOGGER.info(msg)
558
550
 
559
551
  super()._msg_handler(msg)
560
552
 
ramses_rf/helpers.py CHANGED
@@ -67,7 +67,7 @@ def shrink(
67
67
  """Return a minimized dict, after removing all the meaningless items.
68
68
 
69
69
  Specifically, removes items with:
70
- - uwanted keys (starting with '_')
70
+ - unwanted keys (starting with '_')
71
71
  - falsey values
72
72
  """
73
73
 
ramses_rf/system/zones.py CHANGED
@@ -699,8 +699,10 @@ class Zone(ZoneSchedule):
699
699
  def name(self) -> str | None: # 0004
700
700
  """Return the name of the zone."""
701
701
 
702
- if self._gwy._zzz:
703
- msgs = self._gwy._zzz.get(code=Code._0004, src=self._z_id, ctx=self._z_idx)
702
+ if self._gwy.msg_db:
703
+ msgs = self._gwy.msg_db.get(
704
+ code=Code._0004, src=self._z_id, ctx=self._z_idx
705
+ )
704
706
  return msgs[0].payload.get(SZ_NAME) if msgs else None
705
707
 
706
708
  return self._msg_value(Code._0004, key=SZ_NAME) # type: ignore[no-any-return]
ramses_rf/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (application layer)."""
2
2
 
3
- __version__ = "0.51.5"
3
+ __version__ = "0.51.7"
4
4
  VERSION = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ramses_rf
3
- Version: 0.51.5
3
+ Version: 0.51.7
4
4
  Summary: A stateful RAMSES-II protocol decoder & analyser.
5
5
  Project-URL: Homepage, https://github.com/ramses-rf/ramses_rf
6
6
  Project-URL: Bug Tracker, https://github.com/ramses-rf/ramses_rf/issues
@@ -67,6 +67,6 @@ pip install -e .
67
67
 
68
68
  The CLI is called ``client.py`` and is included in the code root.
69
69
  It has options to monitor and parse Ramses-II traffic to screen or a log file, and to parse a file containing Ramses-II messages to the screen.
70
- See the [client.py CLI wiki page](https://github.com/ramses-rf/ramses_rf/wiki/The-client.py-command-line) for instructions.
70
+ See the [client.py CLI wiki page](https://github.com/ramses-rf/ramses_rf/wiki/2.-The-client.py-command-line) for instructions.
71
71
 
72
72
  For code development, some more setup is required. Please follow the steps in our [Developer's Resource](README-developers.md)
@@ -1,5 +1,5 @@
1
1
  ramses_cli/__init__.py,sha256=uvGzWqOf4avvgzxJNSLFWEelIWqSZ-AeLAZzg5x58bc,397
2
- ramses_cli/client.py,sha256=QOmPKjCQHHOZwLBWEB438zabI9k38-ELRwisLvbvxSU,19782
2
+ ramses_cli/client.py,sha256=vbKS3KVPiGsDWLp5cR3SVBtXrs-TinzlxSbTgcb4G2k,19724
3
3
  ramses_cli/debug.py,sha256=vgR0lOHoYjWarN948dI617WZZGNuqHbeq6Tc16Da7b4,608
4
4
  ramses_cli/discovery.py,sha256=81XbmpNiCpUHVZBwo2g1eRwyJG-wZhpSsc44G3hHlFA,12972
5
5
  ramses_cli/utils/cat_slow.py,sha256=AhUpM5gnegCitNKU-JGHn-DrRzSi-49ZR1Qw6lxe_t8,607
@@ -7,49 +7,49 @@ ramses_cli/utils/convert.py,sha256=D_YiCyX5na9pgC-_NhBlW9N1dgRKUK-uLtLBfofjzZM,1
7
7
  ramses_rf/__init__.py,sha256=zONFBiRdf07cPTSxzr2V3t-b3CGokZjT9SGit4JUKRA,1055
8
8
  ramses_rf/binding_fsm.py,sha256=uZAOl3i19KCXqqlaLJWkEqMMP7NJBhVPW3xTikQD1fY,25996
9
9
  ramses_rf/const.py,sha256=L3z31CZ-xqno6oZp_h-67CB_5tDDqTwSWXsqRtsjMcs,5460
10
- ramses_rf/database.py,sha256=6k5MLtK5Lplz8THfluQoQU-eniUkqSwEUMvVW7VyGhI,9880
11
- ramses_rf/dispatcher.py,sha256=L-XQ-mbE3HyyxExkhe5kfD4elKlGZnV0kHT4OHTWzE8,11197
12
- ramses_rf/entity_base.py,sha256=EaIyGIu3fU1Ks30jdqS69nYmcWRKXaWx81oBGDiaXGw,39602
10
+ ramses_rf/database.py,sha256=ZZZgucyuU1IHsSewGukZfDg2gu8KeNaEFriWKM0TUHs,10287
11
+ ramses_rf/dispatcher.py,sha256=JGkqSi1o-YhQ2rj8tNkXwYLLeJIC7F061xpHoH8sSsM,11201
12
+ ramses_rf/entity_base.py,sha256=V9m_Q5SOLP5ko3sok0NDvyz3YdYch1QsxM6tHCIE7cA,39212
13
13
  ramses_rf/exceptions.py,sha256=rzVZDcYxFH7BjUAQ3U1fHWtgBpwk3BgjX1TO1L1iM8c,2538
14
- ramses_rf/gateway.py,sha256=vqoTEb6QXnwaIMa66oed_3LEVvlyQ3flsAAMliEEvVA,20921
15
- ramses_rf/helpers.py,sha256=LcrVLqnF2qJWqXrC7UXKOQE8khCT3OhoTpZ_ZVBjw3A,4249
14
+ ramses_rf/gateway.py,sha256=WdIIGgs87CYfXwSCSVb2YzqOgLC7W4bkpulWQb7PFNw,20564
15
+ ramses_rf/helpers.py,sha256=TNk_QkpIOB3alOp1sqnA9LOzi4fuDCeapNlW3zTzNas,4250
16
16
  ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  ramses_rf/schemas.py,sha256=mYOUZOH5OIDNBxRM2vd8POzDWEEmLhxh5UtqjTpFNek,13287
18
- ramses_rf/version.py,sha256=2CNEExhYqbwGxmDWyifsA3RubG0bCgN6a1NUTKnIfBM,125
18
+ ramses_rf/version.py,sha256=4EfbWfYC4nSL7JCP_xnhFTpV6Yt5Vc4kNosAUW-CKNs,125
19
19
  ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
20
- ramses_rf/device/base.py,sha256=V2YzRhdxrTqfHYrCBq6pJsYdTgAx8gGzfdo8pkbeEo8,17450
20
+ ramses_rf/device/base.py,sha256=WGkBTUNjRUEe-phxdtdiXVCZnTi6-i1i_YT6g689UTM,17450
21
21
  ramses_rf/device/heat.py,sha256=2sCsggySVcuTzyXDmgWy76QbhlU5MQWSejy3zgI5BDE,54242
22
- ramses_rf/device/hvac.py,sha256=fRzFGQD6zrkii0Ns9EV1uqh8MTXHn2CO3LZ1WOXLjhs,23835
22
+ ramses_rf/device/hvac.py,sha256=H_PUfG_jrrvJgtnu6Bco6PLxHn7CHALwebZzZI1ygFo,23917
23
23
  ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
24
24
  ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
25
25
  ramses_rf/system/heat.py,sha256=3jaFEChU-HlWCRMY1y7u09s7AH4hT0pC63hnqwdmZOc,39223
26
26
  ramses_rf/system/schedule.py,sha256=Ts6tdZPTQLV5NkgwA73tPa5QUsnZNIIuYoKC-8VsXDk,18808
27
- ramses_rf/system/zones.py,sha256=2_c1YHrGbObUeEqjcqJDO08Fo2Mr1aYn4VorNmfFaKk,36041
28
- ramses_tx/__init__.py,sha256=wJ7Ntx-0AyJwYwSG8OrFMpxDLXs6GbECbCcYhq98mSA,3162
29
- ramses_tx/address.py,sha256=2640K3sXzogZtd4-tSxwVjYEEXcFE1DgmtvZlTMM5mE,8444
30
- ramses_tx/command.py,sha256=g5PBf9JnuygveyaYrqIuV8wIn7grm0evuqKy9Cp1oaA,53844
31
- ramses_tx/const.py,sha256=ILZvbIp9qI2ZTmGDDJ0YFoxOH3GtQ7g9MT3vAUtHWAE,30291
27
+ ramses_rf/system/zones.py,sha256=9AH7ooN5QfiqvWuor2P1Dn8aILjQb2RWL9rWqDH1IjA,36075
28
+ ramses_tx/__init__.py,sha256=4FsVOzICJ4H80LJ0MknZCN0_V-g0k1nMkHUQ0IdrJW8,3161
29
+ ramses_tx/address.py,sha256=5swDr_SvOs1CxBmT-iJpldf8R00mOb7gKPMiEnxLz84,8452
30
+ ramses_tx/command.py,sha256=y69y9NYgQHuPbm7h6xC0osf3e1YIKY9jwmsfPiJ8N6U,58348
31
+ ramses_tx/const.py,sha256=QmwSS4BIN3ZFrLUiiFScP1RCUHuJ782V3ycRPQTtB_c,30297
32
32
  ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
33
33
  ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
34
34
  ramses_tx/frame.py,sha256=9lUVh8gAMXNRAolfFw2WuWANjn24AWkmscuM9Tm5imE,22036
35
- ramses_tx/gateway.py,sha256=FE5MWA1eIE9JATA2vRoBSQ8fAzqp7TqAm3Ds3k1KnKE,11267
36
- ramses_tx/helpers.py,sha256=qDJTsTU2tfSZrfJuFi1q29efkkHzqRtg85M6ItQH6qA,32247
37
- ramses_tx/logger.py,sha256=7vUpcfOFMW95juMWDx5dhUOqV8DTsindZ-Qz2aCmEoA,11073
38
- ramses_tx/message.py,sha256=J1wvVkLPJQr2ffKCUQYSWwLPzRTZBC0zUU5W9DkN3hU,13190
35
+ ramses_tx/gateway.py,sha256=TXLYwT6tFpmSokD29Qyj1ze7UGCxKidooeyP557Jfoo,11266
36
+ ramses_tx/helpers.py,sha256=0VAJ505kpq4K9b9ZeskWI1o2sWwyCbdnKOKZviKFdgY,32913
37
+ ramses_tx/logger.py,sha256=qYbUoNPnPaFWKVsYvLG6uTVuPTdZ8HsMzBbGx0DpBqc,10177
38
+ ramses_tx/message.py,sha256=hl_gLfwrF79ftUNnsgNt3XGsIhM2Pts0MtZZuGjfaxk,13169
39
39
  ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
40
40
  ramses_tx/packet.py,sha256=NGunaGCkEjhTp9t4mARK5e7kbqT-Z_JKCH7ibMYMJXU,7357
41
- ramses_tx/parsers.py,sha256=uwu_HsMjpsSyMYzoBpVnAxu2pqBJiQDL5ls8_B60V7c,109708
41
+ ramses_tx/parsers.py,sha256=PVTbPqcYPUko3BKDaOQoFDwIo4LWAUx5kfRb2KURAMI,109917
42
42
  ramses_tx/protocol.py,sha256=ifj3qwcQivjQDaQUwM94xp-U8Pmef6zwSH7mav8DLWA,28867
43
43
  ramses_tx/protocol_fsm.py,sha256=YhHkTqbl8w-myimsOjV50uIFgg9HiApwPU7xA_jg5nU,26827
44
44
  ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
- ramses_tx/ramses.py,sha256=GnZwvx-HSVFdjXfUen6aWClDtrAmYaKwbrWl-LsyKO4,52045
45
+ ramses_tx/ramses.py,sha256=9R-JrInORWUNMPklrAPQWwtr_2aaruQmFqQPw5mFkrE,52223
46
46
  ramses_tx/schemas.py,sha256=h2AcArVROy1_C4n6F0Crj4e-2BxXxH74xogFlc6nKHI,12769
47
- ramses_tx/transport.py,sha256=aLpULRSivoJqzH8GDPRDcbehETOhFflEqmHbaniGLvg,56210
48
- ramses_tx/typed_dicts.py,sha256=4ZT50M-Cuwy2ljAIorwoxEJ9c737xUHrUxX9wTh79xE,10834
47
+ ramses_tx/transport.py,sha256=MwPnkQ0L-2qJt4mIJy3-C9XmHwBDjT7Kg-1LthPByVw,58331
48
+ ramses_tx/typed_dicts.py,sha256=w-0V5t2Q3GiNUOrRAWiW9GtSwbta_7luME6GfIb1zhI,10869
49
49
  ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
50
- ramses_tx/version.py,sha256=uQWGeAYKPd3vO8uf3sjQlNE_kdbcsQl2FfC3y2J7NJg,123
51
- ramses_rf-0.51.5.dist-info/METADATA,sha256=5qyM_GCuZGD5rzMMGOjQUfNR4NJAd5TOQwIrBnoSEmY,3906
52
- ramses_rf-0.51.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
- ramses_rf-0.51.5.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
- ramses_rf-0.51.5.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
- ramses_rf-0.51.5.dist-info/RECORD,,
50
+ ramses_tx/version.py,sha256=hWp2I2S7p1_tlItYBqDNAFpsM7kL_J8xYU-6mC2e_Ws,123
51
+ ramses_rf-0.51.7.dist-info/METADATA,sha256=w3QiOIRJncgPc3R3h1MaxjMDOjzm0NYBHFEoeAr6o6A,3909
52
+ ramses_rf-0.51.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
+ ramses_rf-0.51.7.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
+ ramses_rf-0.51.7.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
+ ramses_rf-0.51.7.dist-info/RECORD,,
ramses_tx/__init__.py CHANGED
@@ -138,7 +138,7 @@ if TYPE_CHECKING:
138
138
  async def set_pkt_logging_config(**config: Any) -> Logger:
139
139
  """
140
140
  Set up ramses packet logging to a file or port.
141
- Must runs async in executor to prevent HA blocking call opening packet log file (issue #200)
141
+ Must run async in executor to prevent HA blocking call opening packet log file (issue #200)
142
142
 
143
143
  :param config: if file_name is included, opens packet_log file
144
144
  :return: a logging.Logger
ramses_tx/address.py CHANGED
@@ -45,7 +45,7 @@ class Address:
45
45
  # device_id = NON_DEVICE_ID
46
46
 
47
47
  self.id = device_id # TODO: check is a valid id...
48
- self.type = device_id[:2] # dex, NOTE: remove last
48
+ self.type = device_id[:2] # dex, drops 2nd part, incl. ":"
49
49
  self._hex_id: str = None # type: ignore[assignment]
50
50
 
51
51
  if not self.is_valid(device_id):
ramses_tx/command.py CHANGED
@@ -39,6 +39,10 @@ from .const import (
39
39
  )
40
40
  from .frame import Frame, pkt_header
41
41
  from .helpers import (
42
+ air_quality_code,
43
+ capability_bits,
44
+ fan_info_flags,
45
+ fan_info_to_byte,
42
46
  hex_from_bool,
43
47
  hex_from_double,
44
48
  hex_from_dtm,
@@ -854,7 +858,7 @@ class Command(Frame):
854
858
  def put_indoor_humidity(
855
859
  cls, dev_id: DeviceIdT | str, indoor_humidity: float | None
856
860
  ) -> Command:
857
- """Constructor to announce the current humidity of a sensor (12A0)."""
861
+ """Constructor to announce the current humidity of a sensor or fan (12A0)."""
858
862
  # .I --- 37:039266 --:------ 37:039266 1298 003 000316
859
863
 
860
864
  payload = "00" + hex_from_percent(indoor_humidity, high_res=False)
@@ -1320,6 +1324,109 @@ class Command(Frame):
1320
1324
  dt_str = hex_from_dtm(datetime, is_dst=is_dst, incl_seconds=True)
1321
1325
  return cls.from_attrs(W_, ctl_id, Code._313F, f"0060{dt_str}")
1322
1326
 
1327
+ @classmethod # constructor for I|31DA
1328
+ def get_hvac_fan_31da(
1329
+ cls,
1330
+ dev_id: DeviceIdT | str,
1331
+ hvac_id: str,
1332
+ bypass_position: float | None,
1333
+ air_quality: int | None,
1334
+ co2_level: int | None,
1335
+ indoor_humidity: float | None,
1336
+ outdoor_humidity: float | None,
1337
+ exhaust_temp: float | None,
1338
+ supply_temp: float | None,
1339
+ indoor_temp: float | None,
1340
+ outdoor_temp: float | None,
1341
+ speed_capabilities: list[str],
1342
+ fan_info: str,
1343
+ _unknown_fan_info_flags: list[int], # skip? as starts with _
1344
+ exhaust_fan_speed: float | None,
1345
+ supply_fan_speed: float | None,
1346
+ remaining_mins: int | None,
1347
+ post_heat: int | None,
1348
+ pre_heat: int | None,
1349
+ supply_flow: float | None,
1350
+ exhaust_flow: float | None,
1351
+ **kwargs: Any, # option: air_quality_basis: str | None,
1352
+ ) -> Command:
1353
+ """Constructor to announce hvac fan (state, temps, flows, humidity etc.) of a HRU (31DA)."""
1354
+ # 00 EF00 7FFF 34 33 0898 0898 088A 0882 F800 00 15 14 14 0000 EF EF 05F5 0613:
1355
+ # {"hvac_id": '00', 'bypass_position': 0.000, 'air_quality': None,
1356
+ # 'co2_level': None, 'indoor_humidity': 0.52, 'outdoor_humidity': 0.51,
1357
+ # 'exhaust_temp': 22.0, 'supply_temp': 22.0, 'indoor_temp': 21.86,
1358
+ # 'outdoor_temp': 21.78, 'speed_capabilities': ['off', 'low_med_high',
1359
+ # 'timer', 'boost', 'auto'], 'fan_info': 'away',
1360
+ # '_unknown_fan_info_flags': [0, 0, 0], 'exhaust_fan_speed': 0.1,
1361
+ # 'supply_fan_speed': 0.1, 'remaining_mins': 0, 'post_heat': None,
1362
+ # 'pre_heat': None, 'supply_flow': 15.25, 'exhaust_flow': 15.55},
1363
+
1364
+ air_quality_basis: str = kwargs.pop("air_quality_basis", "00")
1365
+ extra: str = kwargs.pop("_extra", "")
1366
+ assert not kwargs, kwargs
1367
+
1368
+ payload = hvac_id
1369
+ payload += (
1370
+ f"{(int(air_quality * 200)):02X}" if air_quality is not None else "EF"
1371
+ )
1372
+ payload += (
1373
+ f"{air_quality_code(air_quality_basis)}"
1374
+ if air_quality_basis is not None
1375
+ else "00"
1376
+ )
1377
+ payload += f"{co2_level:04X}" if co2_level is not None else "7FFF"
1378
+ payload += (
1379
+ hex_from_percent(indoor_humidity, high_res=False)
1380
+ if indoor_humidity is not None
1381
+ else "EF"
1382
+ )
1383
+ payload += (
1384
+ hex_from_percent(outdoor_humidity, high_res=False)
1385
+ if outdoor_humidity is not None
1386
+ else "EF"
1387
+ )
1388
+ payload += hex_from_temp(exhaust_temp) if exhaust_temp is not None else "7FFF"
1389
+ payload += hex_from_temp(supply_temp) if supply_temp is not None else "7FFF"
1390
+ payload += hex_from_temp(indoor_temp) if indoor_temp is not None else "7FFF"
1391
+ payload += hex_from_temp(outdoor_temp) if outdoor_temp is not None else "7FFF"
1392
+ payload += (
1393
+ f"{capability_bits(speed_capabilities):04X}"
1394
+ if speed_capabilities is not None
1395
+ else "7FFF"
1396
+ )
1397
+ payload += (
1398
+ hex_from_percent(bypass_position, high_res=True)
1399
+ if bypass_position is not None
1400
+ else "EF"
1401
+ )
1402
+ payload += (
1403
+ f"{(fan_info_to_byte(fan_info) | fan_info_flags(_unknown_fan_info_flags)):02X}"
1404
+ if fan_info is not None
1405
+ else "EF"
1406
+ )
1407
+ payload += (
1408
+ hex_from_percent(exhaust_fan_speed, high_res=True)
1409
+ if exhaust_fan_speed is not None
1410
+ else "FF"
1411
+ )
1412
+ payload += (
1413
+ hex_from_percent(supply_fan_speed, high_res=True)
1414
+ if supply_fan_speed is not None
1415
+ else "FF"
1416
+ )
1417
+ payload += f"{remaining_mins:04X}" if remaining_mins is not None else "7FFF"
1418
+ payload += f"{int(post_heat * 200):02X}" if post_heat is not None else "EF"
1419
+ payload += f"{int(pre_heat * 200):02X}" if pre_heat is not None else "EF"
1420
+ payload += (
1421
+ f"{(int(supply_flow * 100)):04X}" if supply_flow is not None else "7FFF"
1422
+ )
1423
+ payload += (
1424
+ f"{(int(exhaust_flow * 100)):04X}" if exhaust_flow is not None else "7FFF"
1425
+ )
1426
+ payload += extra
1427
+
1428
+ return cls._from_attrs(I_, Code._31DA, payload, addr0=dev_id, addr2=dev_id)
1429
+
1323
1430
  @classmethod # constructor for RQ|3220
1324
1431
  def get_opentherm_data(cls, otb_id: DeviceIdT | str, msg_id: int | str) -> Command:
1325
1432
  """Constructor to get (Read-Data) opentherm msg value (c.f. parser_3220)."""
@@ -1412,7 +1519,7 @@ CODE_API_MAP = {
1412
1519
  f"{I_}|{Code._1FC9}": Command.put_bind,
1413
1520
  f"{W_}|{Code._1FC9}": Command.put_bind, # NOTE: same class method as I|1FC9
1414
1521
  f"{W_}|{Code._22F7}": Command.set_bypass_position,
1415
- f"{I_}|{Code._1298}": Command.put_co2_level,
1522
+ f"{I_}|{Code._1298}": Command.put_co2_level, # . has a test
1416
1523
  f"{RQ}|{Code._1F41}": Command.get_dhw_mode,
1417
1524
  f"{W_}|{Code._1F41}": Command.set_dhw_mode, # . has a test
1418
1525
  f"{RQ}|{Code._10A0}": Command.get_dhw_params,
@@ -1421,7 +1528,7 @@ CODE_API_MAP = {
1421
1528
  f"{I_}|{Code._1260}": Command.put_dhw_temp, # . has a test (empty)
1422
1529
  f"{I_}|{Code._22F1}": Command.set_fan_mode,
1423
1530
  f"{W_}|{Code._2411}": Command.set_fan_param,
1424
- f"{I_}|{Code._12A0}": Command.put_indoor_humidity,
1531
+ f"{I_}|{Code._12A0}": Command.put_indoor_humidity, # . has a test
1425
1532
  f"{RQ}|{Code._1030}": Command.get_mix_valve_params,
1426
1533
  f"{W_}|{Code._1030}": Command.set_mix_valve_params, # . has a test
1427
1534
  f"{RQ}|{Code._3220}": Command.get_opentherm_data,
@@ -1451,4 +1558,5 @@ CODE_API_MAP = {
1451
1558
  f"{W_}|{Code._2309}": Command.set_zone_setpoint, # . has a test
1452
1559
  f"{RQ}|{Code._30C9}": Command.get_zone_temp,
1453
1560
  f"{RQ}|{Code._12B0}": Command.get_zone_window_state,
1561
+ f"{I_}|{Code._31DA}": Command.get_hvac_fan_31da, # . has a test
1454
1562
  } # TODO: RQ|0404 (Zone & DHW)
ramses_tx/const.py CHANGED
@@ -442,7 +442,7 @@ DEV_TYPE_MAP = attr_dict_factory(
442
442
  "HEAT_ZONE_ACTUATORS": ("00", "02", "04", "13"),
443
443
  "THM_DEVICES": ("03", "12", "22", "34"),
444
444
  "TRV_DEVICES": ("00", "04"),
445
- "CONTROLLERS": ("01", "12", "22", "23", "34"), # potentially controllers
445
+ "CONTROLLERS": ("01", "02", "12", "22", "23", "34"), # potentially controllers
446
446
  "PROMOTABLE_SLUGS": (DevType.DEV, DevType.HEA, DevType.HVC),
447
447
  "HVAC_SLUGS": {
448
448
  DevType.CO2: "co2_sensor",
ramses_tx/gateway.py CHANGED
@@ -331,7 +331,7 @@ class Engine:
331
331
  ) # may: raise ProtocolError/ProtocolSendFailed
332
332
 
333
333
  def _msg_handler(self, msg: Message) -> None:
334
- # HACK: This is one consequence of an unpleaseant anachronism
334
+ # HACK: This is one consequence of an unpleasant anachronism
335
335
  msg.__class__ = Message # HACK (next line too)
336
336
  msg._gwy = self # type: ignore[assignment]
337
337
 
ramses_tx/helpers.py CHANGED
@@ -470,7 +470,7 @@ def parse_valve_demand(
470
470
  if int(value, 16) & 0xF0 == 0xF0:
471
471
  return _faulted_device(SZ_HEAT_DEMAND, value)
472
472
 
473
- result = int(value, 16) / 200 # c.f. hex_to_percentage
473
+ result = int(value, 16) / 200 # c.f. hex_to_percent
474
474
  if result == 1.01: # HACK - does it mean maximum?
475
475
  result = 1.0
476
476
  elif result > 1.0:
@@ -479,6 +479,13 @@ def parse_valve_demand(
479
479
  return {SZ_HEAT_DEMAND: result}
480
480
 
481
481
 
482
+ AIR_QUALITY_BASIS: dict[str, str] = {
483
+ "10": "voc", # volatile compounds
484
+ "20": "co2", # carbon dioxide
485
+ "40": "rel_humidity", # relative humidity
486
+ }
487
+
488
+
482
489
  # 31DA[2:6] and 12C8[2:6]
483
490
  def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
484
491
  """Return the air quality (%): poor (0.0) to excellent (1.0).
@@ -505,15 +512,21 @@ def parse_air_quality(value: HexStr4) -> PayDictT.AIR_QUALITY:
505
512
  assert level <= 1.0, value[:2] # TODO: raise exception
506
513
 
507
514
  assert value[2:] in ("10", "20", "40"), value[2:] # TODO: remove assert
508
- basis = {
509
- "10": "voc", # volatile compounds
510
- "20": "co2", # carbon dioxide
511
- "40": "rel_humidity", # relative humidity
512
- }.get(value[2:], f"unknown_{value[2:]}") # TODO: remove get/unknown
515
+
516
+ basis: str = AIR_QUALITY_BASIS.get(
517
+ value[2:], f"unknown_{value[2:]}"
518
+ ) # TODO: remove get/unknown
513
519
 
514
520
  return {SZ_AIR_QUALITY: level, SZ_AIR_QUALITY_BASIS: basis}
515
521
 
516
522
 
523
+ def air_quality_code(desc: str) -> str:
524
+ for k, v in AIR_QUALITY_BASIS.items():
525
+ if v == desc:
526
+ return k
527
+ return "00"
528
+
529
+
517
530
  # 31DA[6:10] and 1298[2:6]
518
531
  def parse_co2_level(value: HexStr4) -> PayDictT.CO2_LEVEL:
519
532
  """Return the co2 level (ppm).
@@ -593,12 +606,9 @@ def _parse_hvac_humidity(
593
606
  if int(value, 16) & 0xF0 == 0xF0:
594
607
  return _faulted_sensor(param_name, value)
595
608
 
596
- percentage = int(value, 16) / 100 # TODO: confirm not 200
597
- assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
609
+ percentage = hex_to_percent(value, False) # TODO: confirm not /200
598
610
 
599
- result: dict[str, float | str | None] = {
600
- param_name: percentage
601
- } # was: percent_from_hex(value, high_res=False)
611
+ result: dict[str, float | str | None] = {param_name: percentage}
602
612
  if temp:
603
613
  result |= {SZ_TEMPERATURE: hex_to_temp(temp)}
604
614
  if dewpoint:
@@ -657,6 +667,26 @@ def _parse_hvac_temp(param_name: str, value: HexStr4) -> Mapping[str, float | No
657
667
  return {param_name: temp}
658
668
 
659
669
 
670
+ ABILITIES = {
671
+ 15: "off",
672
+ 14: "low_med_high", # 3,2,1 = high,med,low?
673
+ 13: "timer",
674
+ 12: "boost",
675
+ 11: "auto",
676
+ 10: "speed_4",
677
+ 9: "speed_5",
678
+ 8: "speed_6",
679
+ 7: "speed_7",
680
+ 6: "speed_8",
681
+ 5: "speed_9",
682
+ 4: "speed_10",
683
+ 3: "auto_night",
684
+ 2: "reserved",
685
+ 1: "post_heater",
686
+ 0: "pre_heater",
687
+ }
688
+
689
+
660
690
  # 31DA[30:34]
661
691
  def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
662
692
  """Return the speed capabilities (a bitmask).
@@ -672,25 +702,6 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
672
702
  if value == "7FFF": # TODO: Not implemented???
673
703
  return {SZ_SPEED_CAPABILITIES: None}
674
704
 
675
- ABILITIES = {
676
- 15: "off",
677
- 14: "low_med_high", # 3,2,1 = high,med,low?
678
- 13: "timer",
679
- 12: "boost",
680
- 11: "auto",
681
- 10: "speed_4",
682
- 9: "speed_5",
683
- 8: "speed_6",
684
- 7: "speed_7",
685
- 6: "speed_8",
686
- 5: "speed_9",
687
- 4: "speed_10",
688
- 3: "auto_night",
689
- 2: "reserved",
690
- 1: "post_heater",
691
- 0: "pre_heater",
692
- }
693
-
694
705
  # assert value in ("0002", "4000", "4808", "F000", "F001", "F800", "F808"), value
695
706
 
696
707
  return {
@@ -700,6 +711,16 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
700
711
  }
701
712
 
702
713
 
714
+ def capability_bits(cap_list: list[str]) -> int:
715
+ # 0xF800 = 0b1111100000000000
716
+ cap_res: int = 0
717
+ for cap in cap_list:
718
+ for k, v in ABILITIES.items():
719
+ if v == cap:
720
+ cap_res |= 2**k # set bit
721
+ return cap_res
722
+
723
+
703
724
  # 31DA[34:36]
704
725
  def parse_bypass_position(value: HexStr2) -> PayDictT.BYPASS_POSITION:
705
726
  """Return the bypass position (%), usually fully open or closed (0%, no bypass).
@@ -757,6 +778,21 @@ def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
757
778
  }
758
779
 
759
780
 
781
+ def fan_info_to_byte(info: str) -> int:
782
+ for k, v in _31DA_FAN_INFO.items():
783
+ if v == info:
784
+ return int(k) & 0x1F
785
+ return 0x0000
786
+
787
+
788
+ def fan_info_flags(flags_list: list[int]) -> int:
789
+ flag_res: int = 0
790
+ for index, shft in enumerate(range(7, 4, -1)): # index = 7, 6 and 5
791
+ if flags_list[index] == 1:
792
+ flag_res |= 1 << shft # set bits
793
+ return flag_res
794
+
795
+
760
796
  # 31DA[38:40], also 2210
761
797
  def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
762
798
  """Return the exhaust fan speed (% of max speed)."""
@@ -842,7 +878,7 @@ def _parse_fan_heater(param_name: str, value: HexStr2) -> Mapping[str, float | N
842
878
  if int(value, 16) & 0xF0 == 0xF0:
843
879
  return _faulted_sensor(param_name, value) # type: ignore[return-value]
844
880
 
845
- percentage = int(value, 16) / 200 # Siber DF EVO 2 is /200, not /100 (?Others)
881
+ percentage = int(value, 16) / 200 # Siber DF EVO 2 is /200, not /100 (Others?)
846
882
  assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
847
883
 
848
884
  return {param_name: percentage} # was: percent_from_hex(value, high_res=False)
ramses_tx/logger.py CHANGED
@@ -7,7 +7,6 @@ This module wraps logger to provide bespoke functionality, especially for timest
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
- import os
11
10
  import re
12
11
  import shutil
13
12
  import sys
@@ -174,29 +173,6 @@ class TimedRotatingFileHandler(_TimedRotatingFileHandler):
174
173
  # self.doRollover()
175
174
  # return super().emit(record)
176
175
 
177
- def getFilesToDelete(self) -> list[str]: # zxdavb: my version
178
- """Determine the files to delete when rolling over.
179
-
180
- Overridden as old log files not being deleted.
181
- """
182
- # See bpo-44753 (this code is as was before that commit), bpo45628, bpo-46063
183
- dirName, baseName = os.path.split(self.baseFilename)
184
- fileNames = os.listdir(dirName)
185
- result = []
186
- prefix = baseName + "."
187
- plen = len(prefix)
188
- for fileName in fileNames:
189
- if fileName[:plen] == prefix:
190
- suffix = fileName[plen:]
191
- if self.extMatch.match(suffix):
192
- result.append(os.path.join(dirName, fileName))
193
- if len(result) < self.backupCount:
194
- result = []
195
- else:
196
- result.sort()
197
- result = result[: len(result) - self.backupCount]
198
- return result
199
-
200
176
 
201
177
  def getLogger( # permits a bespoke Logger class
202
178
  name: str | None = None, pkt_log: bool = False
ramses_tx/message.py CHANGED
@@ -281,7 +281,7 @@ class MessageBase:
281
281
  raise exc.PacketInvalid from err
282
282
 
283
283
 
284
- class Message(MessageBase): # add _expired attr
284
+ class Message(MessageBase):
285
285
  """Extend the Message class, so is useful to a stateful Gateway.
286
286
 
287
287
  Adds _expired attr to the Message class.
ramses_tx/parsers.py CHANGED
@@ -1648,21 +1648,17 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
1648
1648
  _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1649
1649
 
1650
1650
  new_speed = { # from now, until timer expiry
1651
- 0x00: "fan_boost", # # set fan off, or 'boost' mode?
1652
- 0x01: "per_request", # # set fan as per payload[6:10]?
1653
- 0x02: "per_vent_speed", # set fan as per current fan mode/speed?
1651
+ 0x00: "fan_boost", # set fan off, or 'boost' mode?
1652
+ 0x01: "per_request?", # set fan as per payload[6:10]?
1653
+ 0x02: "per_request", # set fan as per payload[6:10]
1654
1654
  }.get(int(payload[2:4], 0x10) & 0x07) # 0b0000-0111
1655
1655
 
1656
- fallback_speed: str | None
1657
- if msg.len == 7 and payload[9:10] == "06": # Vasco and ClimaRad REM
1658
- fallback_speed = "per_vent_speed" # after timer expiry
1659
- # set fan as per current fan mode/speed
1660
- else:
1661
- fallback_speed = { # after timer expiry
1662
- 0x08: "fan_off", # # set fan off?
1663
- 0x10: "per_request", # # set fan as per payload[6:10], or payload[10:]?
1664
- 0x18: "per_vent_speed", # set fan as per current fan mode/speed?
1665
- }.get(int(payload[2:4], 0x10) & 0x38) # 0b0011-1000
1656
+ fallback_speed = { # after timer expiry
1657
+ 0x00: "per_vent_speed", # set fan as per current fan mode
1658
+ 0x08: "fan_off", # set fan off?
1659
+ 0x10: "per_request", # set fan as per payload[10:14]
1660
+ 0x18: "per_vent_speed?", # set fan as per current fan mode/speed?
1661
+ }.get(int(payload[2:4], 0x10) & 0x38) # 0b0011-1000
1666
1662
 
1667
1663
  units = {
1668
1664
  0x00: "minutes",
@@ -1677,15 +1673,21 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
1677
1673
  result = {
1678
1674
  "minutes" if units != "index" else "index": duration,
1679
1675
  "flags": hex_to_flag8(payload[2:4]),
1680
- "_new_speed_mode": new_speed,
1681
- "_fallback_speed_mode": fallback_speed,
1676
+ "new_speed_mode": new_speed,
1677
+ "fallback_speed_mode": fallback_speed,
1682
1678
  }
1683
1679
 
1684
- if msg.len >= 5 and payload[6:10] != "0000": # new speed?
1685
- result["rate"] = parser_22f1(f"00{payload[6:10]}", msg).get("rate")
1680
+ if msg._addrs[0] == NON_DEV_ADDR and msg.len <= 3:
1681
+ result["_scheme"] = "itho"
1682
+
1683
+ if msg.len >= 5 and payload[6:10] != "0000": # new speed
1684
+ mode_info = parser_22f1(f"00{payload[6:10]}", msg)
1685
+ result["_scheme"] = mode_info.get("_scheme")
1686
+ result["fan_mode"] = mode_info.get("fan_mode")
1686
1687
 
1687
- if msg.len >= 7: # fallback speed?
1688
- result.update({"_unknown_5": payload[10:]})
1688
+ if msg.len >= 7 and payload[10:14] != "0000": # fallback speed
1689
+ mode_info = parser_22f1(f"00{payload[10:14]}", msg)
1690
+ result["fallback_fan_mode"] = mode_info.get("fan_mode")
1689
1691
 
1690
1692
  return result
1691
1693
 
@@ -2220,10 +2222,9 @@ def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
2220
2222
  # ventilation state (extended), HVAC
2221
2223
  def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
2222
2224
  # see: https://github.com/python/typing/issues/1445
2223
- return { # type: ignore[typeddict-unknown-key]
2225
+ result = {
2224
2226
  **parse_exhaust_fan_speed(payload[38:40]), # maybe 31D9[4:6] for some?
2225
2227
  **parse_fan_info(payload[36:38]), # 22F3-ish
2226
- #
2227
2228
  **parse_air_quality(payload[2:6]), # 12C8[2:6]
2228
2229
  **parse_co2_level(payload[6:10]), # 1298[2:6]
2229
2230
  **parse_indoor_humidity(payload[10:12]), # 12A0?
@@ -2241,6 +2242,11 @@ def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
2241
2242
  **parse_supply_flow(payload[50:54]), # NOTE: is supply, not exhaust
2242
2243
  **parse_exhaust_flow(payload[54:58]), # NOTE: order switched from others
2243
2244
  }
2245
+ if len(payload) == 58:
2246
+ return result # type: ignore[return-value]
2247
+
2248
+ result.update({"_extra": payload[58:]}) # sporadic [58:60] always 00
2249
+ return result # type: ignore[return-value]
2244
2250
 
2245
2251
  # From an Orcon 15RF Display
2246
2252
  # 1 Software version
ramses_tx/ramses.py CHANGED
@@ -871,11 +871,14 @@ _DEV_KLASSES_HEAT: dict[str, dict[Code, dict[VerbT, Any]]] = {
871
871
  Code._000A: {RP: {}},
872
872
  Code._000C: {RP: {}},
873
873
  Code._1FC9: {I_: {}},
874
+ Code._1FD4: {I_: {}}, # Spider Autotemp, slave 'ticker'
874
875
  Code._10E0: {I_: {}, RP: {}},
875
876
  Code._22C9: {I_: {}}, # NOTE: No RP
876
877
  Code._22D0: {I_: {}, RP: {}},
877
878
  Code._2309: {RP: {}},
879
+ Code._3110: {I_: {}}, # Spider Autotemp
878
880
  Code._3150: {I_: {}},
881
+ Code._4E01: {I_: {}}, # Spider Autotemp Zone controller
879
882
  },
880
883
  DevType.TRV: { # e.g. HR92/HR91: Radiator Controller
881
884
  Code._0001: {W_: {r"^0[0-9A-F]"}},
@@ -1411,7 +1414,6 @@ _31DA_FAN_INFO: dict[int, str] = {
1411
1414
  0x1F: "-unknown 0x1F-", # static field, used as filter in parser_31da so keep same
1412
1415
  }
1413
1416
 
1414
-
1415
1417
  #
1416
1418
  ########################################################################################
1417
1419
  # CODES_BY_ZONE_TYPE
ramses_tx/transport.py CHANGED
@@ -1033,6 +1033,8 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1033
1033
  self._topic_base = validate_topic_path(self._broker_url.path)
1034
1034
  self._topic_pub = ""
1035
1035
  self._topic_sub = ""
1036
+ # Track if we've subscribed to a wildcard data topic (e.g. ".../+/rx")
1037
+ self._data_wildcard_topic = ""
1036
1038
 
1037
1039
  self._mqtt_qos = int(parse_qs(self._broker_url.query).get("qos", ["0"])[0])
1038
1040
 
@@ -1143,17 +1145,30 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1143
1145
  # Subscribe to base topic to see 'online' messages
1144
1146
  self.client.subscribe(self._topic_base) # hope to see 'online' message
1145
1147
 
1146
- # Also subscribe to data topics with wildcard for reliability after reconnect
1147
- # This ensures we get data even if we miss the 'online' message
1148
- if self._topic_base.endswith("/+"):
1148
+ # Also subscribe to data topics with wildcard for reliability, but only
1149
+ # until a specific device topic is known. Once _topic_sub is set, avoid
1150
+ # overlapping subscriptions that would duplicate messages.
1151
+ if self._topic_base.endswith("/+") and not (
1152
+ hasattr(self, "_topic_sub") and self._topic_sub
1153
+ ):
1149
1154
  data_wildcard = self._topic_base.replace("/+", "/+/rx")
1150
1155
  self.client.subscribe(data_wildcard, qos=self._mqtt_qos)
1156
+ self._data_wildcard_topic = data_wildcard
1151
1157
  _LOGGER.debug(f"Subscribed to data wildcard: {data_wildcard}")
1152
1158
 
1153
1159
  # If we already have specific topics, re-subscribe to them
1154
1160
  if hasattr(self, "_topic_sub") and self._topic_sub:
1155
1161
  self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
1156
1162
  _LOGGER.debug(f"Re-subscribed to specific topic: {self._topic_sub}")
1163
+ # If we had a wildcard subscription, drop it to prevent duplicates
1164
+ if getattr(self, "_data_wildcard_topic", ""):
1165
+ try:
1166
+ self.client.unsubscribe(self._data_wildcard_topic)
1167
+ _LOGGER.debug(
1168
+ f"Unsubscribed data wildcard after specific subscribe: {self._data_wildcard_topic}"
1169
+ )
1170
+ finally:
1171
+ self._data_wildcard_topic = ""
1157
1172
 
1158
1173
  def _on_connect_fail(
1159
1174
  self,
@@ -1225,6 +1240,17 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1225
1240
 
1226
1241
  self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
1227
1242
 
1243
+ # If we previously subscribed to a wildcard data topic, unsubscribe now
1244
+ # to avoid duplicate delivery (wildcard and specific both matching)
1245
+ if getattr(self, "_data_wildcard_topic", ""):
1246
+ try:
1247
+ self.client.unsubscribe(self._data_wildcard_topic)
1248
+ _LOGGER.debug(
1249
+ f"Unsubscribed data wildcard after device online: {self._data_wildcard_topic}"
1250
+ )
1251
+ finally:
1252
+ self._data_wildcard_topic = ""
1253
+
1228
1254
  # Only call connection_made on first connection, not reconnections
1229
1255
  if not self._connection_established:
1230
1256
  self._connection_established = True
@@ -1295,6 +1321,21 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1295
1321
  self._connection_established = True
1296
1322
  self._make_connection(gwy_id=gateway_id) # type: ignore[arg-type]
1297
1323
 
1324
+ # Ensure we subscribe specifically to the device topic and drop the
1325
+ # wildcard subscription to prevent duplicates
1326
+ try:
1327
+ self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
1328
+ except Exception as err: # pragma: no cover - defensive
1329
+ _LOGGER.debug(f"Error subscribing specific topic: {err}")
1330
+ if getattr(self, "_data_wildcard_topic", ""):
1331
+ try:
1332
+ self.client.unsubscribe(self._data_wildcard_topic)
1333
+ _LOGGER.debug(
1334
+ f"Unsubscribed data wildcard after inferring device: {self._data_wildcard_topic}"
1335
+ )
1336
+ finally:
1337
+ self._data_wildcard_topic = ""
1338
+
1298
1339
  try:
1299
1340
  payload = json.loads(msg.payload)
1300
1341
  except json.JSONDecodeError:
ramses_tx/typed_dicts.py CHANGED
@@ -143,6 +143,8 @@ class ExhaustFlow(TypedDict):
143
143
 
144
144
 
145
145
  class _VentilationState(
146
+ ExhaustFanSpeed,
147
+ FanInfo,
146
148
  AirQuality,
147
149
  Co2Level,
148
150
  ExhaustTemp,
@@ -151,8 +153,6 @@ class _VentilationState(
151
153
  OutdoorTemp,
152
154
  Capabilities,
153
155
  BypassPosition,
154
- FanInfo,
155
- ExhaustFanSpeed,
156
156
  SupplyFanSpeed,
157
157
  RemainingMins,
158
158
  PostHeater,
@@ -162,6 +162,7 @@ class _VentilationState(
162
162
  ):
163
163
  indoor_humidity: _HexToTempT
164
164
  outdoor_humidity: _HexToTempT
165
+ extra: NotRequired[str | None]
165
166
 
166
167
 
167
168
  # These are payload-specific...
ramses_tx/version.py CHANGED
@@ -1,4 +1,4 @@
1
1
  """RAMSES RF - a RAMSES-II protocol decoder & analyser (transport layer)."""
2
2
 
3
- __version__ = "0.51.5"
3
+ __version__ = "0.51.7"
4
4
  VERSION = __version__