ramses-rf 0.51.3__py3-none-any.whl → 0.51.5__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_rf/const.py CHANGED
@@ -61,6 +61,8 @@ from ramses_tx.const import ( # noqa: F401
61
61
  SZ_REMAINING_DAYS as SZ_REMAINING_DAYS,
62
62
  SZ_REMAINING_MINS as SZ_REMAINING_MINS,
63
63
  SZ_REMAINING_PERCENT as SZ_REMAINING_PERCENT,
64
+ SZ_REQ_REASON as SZ_REQ_REASON,
65
+ SZ_REQ_SPEED as SZ_REQ_SPEED,
64
66
  SZ_SCHEDULE as SZ_SCHEDULE,
65
67
  SZ_SENSOR as SZ_SENSOR,
66
68
  SZ_SETPOINT as SZ_SETPOINT,
ramses_rf/device/hvac.py CHANGED
@@ -31,6 +31,8 @@ from ramses_rf.const import (
31
31
  SZ_REMAINING_DAYS,
32
32
  SZ_REMAINING_MINS,
33
33
  SZ_REMAINING_PERCENT,
34
+ SZ_REQ_REASON,
35
+ SZ_REQ_SPEED,
34
36
  SZ_SPEED_CAPABILITIES,
35
37
  SZ_SUPPLY_FAN_SPEED,
36
38
  SZ_SUPPLY_FLOW,
@@ -506,6 +508,18 @@ class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A]
506
508
  def remaining_mins(self) -> int | None:
507
509
  return self._msg_value(Code._31DA, key=SZ_REMAINING_MINS)
508
510
 
511
+ @property
512
+ def request_fan_speed(self) -> float | None:
513
+ return self._msg_value(Code._2210, key=SZ_REQ_SPEED)
514
+
515
+ @property
516
+ def request_src(self) -> str | None:
517
+ """
518
+ Orcon, others?
519
+ :return: source sensor of auto speed request: IDL, CO2 or HUM
520
+ """
521
+ return self._msg_value(Code._2210, key=SZ_REQ_REASON)
522
+
509
523
  @property
510
524
  def speed_cap(self) -> int | None:
511
525
  return self._msg_value(Code._31DA, key=SZ_SPEED_CAPABILITIES)
ramses_rf/dispatcher.py CHANGED
@@ -91,7 +91,7 @@ def _create_devices_from_addrs(gwy: Gateway, this: Message) -> None:
91
91
  if not gwy.config.enable_eavesdrop:
92
92
  return
93
93
 
94
- if not isinstance(this.dst, Device) and this.src is not gwy.hgi: # type: ignore[unreachable]
94
+ if not isinstance(this.dst, Device) and this.src != gwy.hgi: # type: ignore[unreachable]
95
95
  with contextlib.suppress(LookupError):
96
96
  this.dst = gwy.get_device(this.dst.id) # type: ignore[assignment]
97
97
 
@@ -188,9 +188,9 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
188
188
  def logger_xxxx(msg: Message) -> None:
189
189
  if _DBG_FORCE_LOG_MESSAGES:
190
190
  _LOGGER.warning(msg)
191
- elif msg.src is not gwy.hgi or (msg.code != Code._PUZZ and msg.verb != RQ):
191
+ elif msg.src != gwy.hgi or (msg.code != Code._PUZZ and msg.verb != RQ):
192
192
  _LOGGER.info(msg)
193
- elif msg.src is not gwy.hgi or msg.verb != RQ:
193
+ elif msg.src != gwy.hgi or msg.verb != RQ:
194
194
  _LOGGER.info(msg)
195
195
  elif _LOGGER.getEffectiveLevel() == logging.DEBUG:
196
196
  _LOGGER.info(msg)
@@ -215,7 +215,7 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
215
215
  if (
216
216
  msg.src._SLUG != DevType.HGI # avoid: msg.src.id != gwy.hgi.id
217
217
  and msg.verb != I_
218
- and msg.dst is not msg.src
218
+ and msg.dst != msg.src
219
219
  ):
220
220
  # HGI80 can do what it likes
221
221
  # receiving an I isn't currently in the schema & so can't yet be tested
@@ -234,10 +234,10 @@ def process_msg(gwy: Gateway, msg: Message) -> None:
234
234
  # TODO: only be for fully-faked (not Fakable) dst (it picks up via RF if not)
235
235
 
236
236
  if msg.code == Code._1FC9 and msg.payload[SZ_PHASE] == SZ_OFFER:
237
- devices = [d for d in gwy.devices if d is not msg.src and d._is_binding]
237
+ devices = [d for d in gwy.devices if d != msg.src and d._is_binding]
238
238
 
239
239
  elif msg.dst == ALL_DEV_ADDR: # some offers use dst=63:, so after 1FC9 offer
240
- devices = [d for d in gwy.devices if d is not msg.src and d.is_faked]
240
+ devices = [d for d in gwy.devices if d != msg.src and d.is_faked]
241
241
 
242
242
  elif msg.dst is not msg.src and isinstance(msg.dst, Fakeable): # type: ignore[unreachable]
243
243
  # to eavesdrop pkts from other devices, but relevant to this device
ramses_rf/entity_base.py CHANGED
@@ -387,7 +387,7 @@ class _MessageDB(_Entity):
387
387
  codes = {
388
388
  k: (CODES_SCHEMA[k][SZ_NAME] if k in CODES_SCHEMA else None)
389
389
  for k in sorted(self._msgs)
390
- if self._msgs[k].src is (self if hasattr(self, "addr") else self.ctl)
390
+ if self._msgs[k].src == (self if hasattr(self, "addr") else self.ctl)
391
391
  }
392
392
 
393
393
  return {"_sent": list(codes.keys())}
ramses_rf/system/heat.py CHANGED
@@ -193,19 +193,19 @@ class SystemBase(Parent, Entity): # 3B00 (multi-relay)
193
193
  this.code in (Code._22D9, Code._3220) and this.verb == RQ
194
194
  ): # TODO: RPs too?
195
195
  # dst could be an Address...
196
- if this.src is self.ctl and isinstance(this.dst, OtbGateway): # type: ignore[unreachable]
196
+ if this.src == self.ctl and isinstance(this.dst, OtbGateway): # type: ignore[unreachable]
197
197
  app_cntrl = this.dst # type: ignore[unreachable]
198
198
 
199
199
  elif this.code == Code._3EF0 and this.verb == RQ:
200
200
  # dst could be an Address...
201
- if this.src is self.ctl and isinstance(
201
+ if this.src == self.ctl and isinstance(
202
202
  this.dst, # type: ignore[unreachable]
203
203
  BdrSwitch | OtbGateway,
204
204
  ):
205
205
  app_cntrl = this.dst # type: ignore[unreachable]
206
206
 
207
207
  elif this.code == Code._3B00 and this.verb == I_ and prev is not None:
208
- if this.src is self.ctl and isinstance(prev.src, BdrSwitch): # type: ignore[unreachable]
208
+ if this.src == self.ctl and isinstance(prev.src, BdrSwitch): # type: ignore[unreachable]
209
209
  if prev.code == this.code and prev.verb == this.verb: # type: ignore[unreachable]
210
210
  app_cntrl = prev.src
211
211
 
ramses_rf/system/zones.py CHANGED
@@ -266,7 +266,7 @@ class DhwZone(ZoneSchedule): # CS92A
266
266
  # self._get_dhw(sensor=this.dst)
267
267
 
268
268
  assert (
269
- msg.src is self.ctl
269
+ msg.src == self.ctl
270
270
  and msg.code in (Code._0005, Code._000C, Code._10A0, Code._1260, Code._1F41)
271
271
  or msg.payload.get(SZ_DOMAIN_ID) in (F9, FA)
272
272
  or msg.payload.get(SZ_ZONE_IDX) == "HW"
@@ -633,13 +633,13 @@ class Zone(ZoneSchedule):
633
633
  self._update_schema(**{SZ_CLASS: ZON_ROLE_MAP[ZoneRole.UFH]})
634
634
 
635
635
  assert (
636
- msg.src is self.ctl or msg.src.type == DEV_TYPE_MAP.UFC
636
+ msg.src == self.ctl or msg.src.type == DEV_TYPE_MAP.UFC
637
637
  ) and ( # DEX
638
638
  isinstance(msg.payload, dict)
639
639
  or [d for d in msg.payload if d.get(SZ_ZONE_IDX) == self.idx]
640
640
  ), f"msg inappropriately routed to {self}"
641
641
 
642
- assert (msg.src is self.ctl or msg.src.type == DEV_TYPE_MAP.UFC) and ( # DEX
642
+ assert (msg.src == self.ctl or msg.src.type == DEV_TYPE_MAP.UFC) and ( # DEX
643
643
  isinstance(msg.payload, list)
644
644
  or msg.code == Code._0005
645
645
  or msg.payload.get(SZ_ZONE_IDX) == self.idx
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.3"
3
+ __version__ = "0.51.5"
4
4
  VERSION = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ramses_rf
3
- Version: 0.51.3
3
+ Version: 0.51.5
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
@@ -6,50 +6,50 @@ ramses_cli/utils/cat_slow.py,sha256=AhUpM5gnegCitNKU-JGHn-DrRzSi-49ZR1Qw6lxe_t8,
6
6
  ramses_cli/utils/convert.py,sha256=D_YiCyX5na9pgC-_NhBlW9N1dgRKUK-uLtLBfofjzZM,1804
7
7
  ramses_rf/__init__.py,sha256=zONFBiRdf07cPTSxzr2V3t-b3CGokZjT9SGit4JUKRA,1055
8
8
  ramses_rf/binding_fsm.py,sha256=uZAOl3i19KCXqqlaLJWkEqMMP7NJBhVPW3xTikQD1fY,25996
9
- ramses_rf/const.py,sha256=DSo4ROWDlOlcdXQdrpAF17vOsTLgmf2u0UppjYa5qJI,5390
9
+ ramses_rf/const.py,sha256=L3z31CZ-xqno6oZp_h-67CB_5tDDqTwSWXsqRtsjMcs,5460
10
10
  ramses_rf/database.py,sha256=6k5MLtK5Lplz8THfluQoQU-eniUkqSwEUMvVW7VyGhI,9880
11
- ramses_rf/dispatcher.py,sha256=b7Cg1vAP6FECC6GeZsJ0BZVqy-ZjJTXhZquzcwE87WI,11221
12
- ramses_rf/entity_base.py,sha256=vH4lmwXnylSM-1MWmat0_QRSNVCRi3iVhcqj9O41Pms,39602
11
+ ramses_rf/dispatcher.py,sha256=L-XQ-mbE3HyyxExkhe5kfD4elKlGZnV0kHT4OHTWzE8,11197
12
+ ramses_rf/entity_base.py,sha256=EaIyGIu3fU1Ks30jdqS69nYmcWRKXaWx81oBGDiaXGw,39602
13
13
  ramses_rf/exceptions.py,sha256=rzVZDcYxFH7BjUAQ3U1fHWtgBpwk3BgjX1TO1L1iM8c,2538
14
14
  ramses_rf/gateway.py,sha256=vqoTEb6QXnwaIMa66oed_3LEVvlyQ3flsAAMliEEvVA,20921
15
15
  ramses_rf/helpers.py,sha256=LcrVLqnF2qJWqXrC7UXKOQE8khCT3OhoTpZ_ZVBjw3A,4249
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=H7dGP9Kn7kENpYvpC0mlcipijKfHZqlzlTauaoFe_8k,125
18
+ ramses_rf/version.py,sha256=2CNEExhYqbwGxmDWyifsA3RubG0bCgN6a1NUTKnIfBM,125
19
19
  ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
20
20
  ramses_rf/device/base.py,sha256=V2YzRhdxrTqfHYrCBq6pJsYdTgAx8gGzfdo8pkbeEo8,17450
21
21
  ramses_rf/device/heat.py,sha256=2sCsggySVcuTzyXDmgWy76QbhlU5MQWSejy3zgI5BDE,54242
22
- ramses_rf/device/hvac.py,sha256=QAlWx6jBArbHzZD5Mm1wZPmIbrQrw4ljbTDV0P4IH3I,23438
22
+ ramses_rf/device/hvac.py,sha256=fRzFGQD6zrkii0Ns9EV1uqh8MTXHn2CO3LZ1WOXLjhs,23835
23
23
  ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
24
24
  ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
25
- ramses_rf/system/heat.py,sha256=dARzcwL39JGwOBJkKJBi0_i7rr8IvY-qaNmWmgJLpdo,39223
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=QwRtSHY5c-Amcs6JD16uQcimOsEQTZcMm1dW-pqEFqM,36041
27
+ ramses_rf/system/zones.py,sha256=2_c1YHrGbObUeEqjcqJDO08Fo2Mr1aYn4VorNmfFaKk,36041
28
28
  ramses_tx/__init__.py,sha256=wJ7Ntx-0AyJwYwSG8OrFMpxDLXs6GbECbCcYhq98mSA,3162
29
29
  ramses_tx/address.py,sha256=2640K3sXzogZtd4-tSxwVjYEEXcFE1DgmtvZlTMM5mE,8444
30
30
  ramses_tx/command.py,sha256=g5PBf9JnuygveyaYrqIuV8wIn7grm0evuqKy9Cp1oaA,53844
31
- ramses_tx/const.py,sha256=B2db8Yxks-lMNsQAK1DoPkF1gvwNIacLmKwXuApUyLk,30221
31
+ ramses_tx/const.py,sha256=ILZvbIp9qI2ZTmGDDJ0YFoxOH3GtQ7g9MT3vAUtHWAE,30291
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
35
  ramses_tx/gateway.py,sha256=FE5MWA1eIE9JATA2vRoBSQ8fAzqp7TqAm3Ds3k1KnKE,11267
36
- ramses_tx/helpers.py,sha256=WJ5JtAT9iyhkcW53AIPNPuvGEUWFwLumZc-mCG2kIOc,32236
36
+ ramses_tx/helpers.py,sha256=qDJTsTU2tfSZrfJuFi1q29efkkHzqRtg85M6ItQH6qA,32247
37
37
  ramses_tx/logger.py,sha256=7vUpcfOFMW95juMWDx5dhUOqV8DTsindZ-Qz2aCmEoA,11073
38
38
  ramses_tx/message.py,sha256=J1wvVkLPJQr2ffKCUQYSWwLPzRTZBC0zUU5W9DkN3hU,13190
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=R-oFcRUe7HPsm9n4196hFUD1tULbFdKBAWo8HvzGyjw,109218
41
+ ramses_tx/parsers.py,sha256=uwu_HsMjpsSyMYzoBpVnAxu2pqBJiQDL5ls8_B60V7c,109708
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=yPSdhxGRYQ9AuTWoKs9Cb3YQn5YnEKZVs7BYkQFmqCw,52037
45
+ ramses_tx/ramses.py,sha256=GnZwvx-HSVFdjXfUen6aWClDtrAmYaKwbrWl-LsyKO4,52045
46
46
  ramses_tx/schemas.py,sha256=h2AcArVROy1_C4n6F0Crj4e-2BxXxH74xogFlc6nKHI,12769
47
- ramses_tx/transport.py,sha256=28CtiqNltcJhLr4VIHCW9uCohWXrvxmx6ySUSIuRQ9c,52892
47
+ ramses_tx/transport.py,sha256=aLpULRSivoJqzH8GDPRDcbehETOhFflEqmHbaniGLvg,56210
48
48
  ramses_tx/typed_dicts.py,sha256=4ZT50M-Cuwy2ljAIorwoxEJ9c737xUHrUxX9wTh79xE,10834
49
49
  ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
50
- ramses_tx/version.py,sha256=EK6Cr3XBdGkYKI-L4TW-S8rBw478PNtR_ckrbfQTCYM,123
51
- ramses_rf-0.51.3.dist-info/METADATA,sha256=eLSMlCmKZZ66ImYx16vDtejxWCYIkWFfYRc5ZYqZom8,3906
52
- ramses_rf-0.51.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
- ramses_rf-0.51.3.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
- ramses_rf-0.51.3.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
- ramses_rf-0.51.3.dist-info/RECORD,,
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,,
ramses_tx/const.py CHANGED
@@ -88,6 +88,8 @@ SZ_REL_HUMIDITY: Final = "rel_humidity"
88
88
  SZ_REMAINING_DAYS: Final = "days_remaining"
89
89
  SZ_REMAINING_MINS: Final = "remaining_mins"
90
90
  SZ_REMAINING_PERCENT: Final = "percent_remaining"
91
+ SZ_REQ_REASON: Final = "req_reason"
92
+ SZ_REQ_SPEED: Final = "req_speed"
91
93
  SZ_SUPPLY_FAN_SPEED: Final = "supply_fan_speed"
92
94
  SZ_SUPPLY_FLOW: Final = "supply_flow"
93
95
  SZ_SUPPLY_TEMP: Final = "supply_temp"
ramses_tx/helpers.py CHANGED
@@ -757,7 +757,7 @@ def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
757
757
  }
758
758
 
759
759
 
760
- # 31DA[38:40]
760
+ # 31DA[38:40], also 2210
761
761
  def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
762
762
  """Return the exhaust fan speed (% of max speed)."""
763
763
  return _parse_fan_speed(SZ_EXHAUST_FAN_SPEED, value) # type: ignore[return-value]
ramses_tx/parsers.py CHANGED
@@ -72,6 +72,7 @@ from .const import (
72
72
  SZ_RELAY_DEMAND,
73
73
  SZ_REMAINING_DAYS,
74
74
  SZ_REMAINING_PERCENT,
75
+ SZ_REQ_REASON,
75
76
  SZ_SETPOINT,
76
77
  SZ_SETPOINT_BOUNDS,
77
78
  SZ_SYSTEM_MODE,
@@ -1378,29 +1379,42 @@ def parser_1fd4(payload: str, msg: Message) -> PayDictT._1FD4:
1378
1379
  return {"ticker": int(payload[2:], 16)}
1379
1380
 
1380
1381
 
1381
- # WIP: unknown, HVAC
1382
+ # WIP: HVAC auto requests (confirmed for Orcon, others?)
1382
1383
  def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1383
1384
  try:
1384
1385
  assert msg.verb in (RP, I_) or payload == "00"
1385
- assert payload[10:12] == payload[38:40] and payload[10:12] in (
1386
- "58",
1387
- "96",
1388
- "FF",
1389
- ), f"expected (58|96|FF), not {payload[10:12]}"
1386
+ assert payload[10:12] == payload[38:40], (
1387
+ f"expected byte 19 {payload[10:12]}, not {payload[38:40]}"
1388
+ ) # auto requested fan speed %. Identical [38:40] is for supply?
1390
1389
  assert payload[20:22] == payload[48:50] and payload[20:22] in (
1391
- "00",
1392
- "03",
1393
- ), f"expected (00|03), not {payload[10:12]}"
1394
- assert payload[78:80] in ("00", "02"), f"expected (00|02), not {payload[78:80]}"
1395
- assert payload[80:82] in ("01", "08"), f"expected (01|08), not {payload[80:82]}"
1396
- assert payload[82:] in ("00", "40"), f"expected (00|40), not {payload[82:]}"
1390
+ "00", # idle
1391
+ "02", # requested by CO2 level/sensor
1392
+ "03", # requested by humidity level/sensor
1393
+ ), f"expected req_reason (00|02|03), not {payload[20:22]}"
1394
+ assert payload[78:80] in ("00", "02"), (
1395
+ f"expected byte 39 (00|02), not {payload[78:80]}"
1396
+ )
1397
+ assert payload[80:82] in ("01", "08"), (
1398
+ f"expected byte 40 (01|08), not {payload[80:82]}"
1399
+ )
1400
+ assert payload[82:] in ("00", "40"), (
1401
+ f"expected byte 41- (00|40), not {payload[82:]}"
1402
+ )
1397
1403
 
1398
1404
  except AssertionError as err:
1399
1405
  _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1400
1406
 
1407
+ _req = "IDL"
1408
+ if payload[20:22] == "02":
1409
+ _req = "CO2"
1410
+ elif payload[20:22] == "03":
1411
+ _req = "HUM"
1412
+
1401
1413
  return {
1402
- "unknown_10": payload[10:12],
1403
- "unknown_20": payload[20:22],
1414
+ **parse_exhaust_fan_speed(
1415
+ payload[10:12]
1416
+ ), # for Orcon: 29 hex == 41 decimal divided by 2 gives 20.5 (%)
1417
+ SZ_REQ_REASON: _req,
1404
1418
  "unknown_78": payload[78:80],
1405
1419
  "unknown_80": payload[80:82],
1406
1420
  "unknown_82": payload[82:],
ramses_tx/ramses.py CHANGED
@@ -1059,7 +1059,7 @@ _DEV_KLASSES_HVAC: dict[str, dict[Code, dict[VerbT, Any]]] = {
1059
1059
  Code._12C8: {I_: {}},
1060
1060
  Code._1470: {RP: {}},
1061
1061
  Code._1F09: {I_: {}, RP: {}},
1062
- Code._1FC9: {W_: {}},
1062
+ Code._1FC9: {I_: {}, W_: {}},
1063
1063
  Code._2210: {I_: {}, RP: {}},
1064
1064
  Code._22E0: {RP: {}},
1065
1065
  Code._22E5: {RP: {}},
ramses_tx/transport.py CHANGED
@@ -778,7 +778,8 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
778
778
  while not self._reading:
779
779
  await asyncio.sleep(0.001)
780
780
  self._frame_read(dtm_str, pkt_line)
781
- # await asyncio.sleep(0) # NOTE: big performance penalty if delay >0
781
+ await asyncio.sleep(0)
782
+ # NOTE: instable without, big performance penalty if delay >0
782
783
 
783
784
  elif isinstance(self._pkt_source, str): # file_name, used in client parse
784
785
  # open file file_name before reading
@@ -794,7 +795,8 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
794
795
  ] != "#":
795
796
  self._frame_read(dtm_pkt_line[:26], dtm_pkt_line[27:])
796
797
  # this is where the parsing magic happens!
797
- # await asyncio.sleep(0) # NOTE: big performance penalty if delay >0
798
+ await asyncio.sleep(0)
799
+ # NOTE: instable without, big performance penalty if delay >0
798
800
  except FileNotFoundError as err:
799
801
  _LOGGER.warning(f"Correct the packet file name; {err}")
800
802
  elif isinstance(self._pkt_source, TextIOWrapper): # used by client monitor
@@ -804,7 +806,8 @@ class FileTransport(_ReadTransport, _FileTransportAbstractor):
804
806
  # can be blank lines in annotated log files
805
807
  if (dtm_pkt_line := dtm_pkt_line.strip()) and dtm_pkt_line[:1] != "#":
806
808
  self._frame_read(dtm_pkt_line[:26], dtm_pkt_line[27:])
807
- await asyncio.sleep(0) # NOTE: big performance penalty if delay >0
809
+ await asyncio.sleep(0)
810
+ # NOTE: instable without, big performance penalty if delay >0
808
811
  else:
809
812
  raise exc.TransportSourceInvalid(
810
813
  f"Packet source is not dict, TextIOWrapper or str: {self._pkt_source:!r}"
@@ -1035,6 +1038,7 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1035
1038
 
1036
1039
  self._connected = False
1037
1040
  self._connecting = False
1041
+ self._connection_established = False # Track if initial connection was made
1038
1042
  self._extra[SZ_IS_EVOFW3] = True
1039
1043
 
1040
1044
  # Reconnection settings
@@ -1069,7 +1073,7 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1069
1073
  self._connecting = True
1070
1074
  try:
1071
1075
  self.client.connect_async(
1072
- self._broker_url.hostname, # type: ignore[arg-type]
1076
+ str(self._broker_url.hostname or "localhost"),
1073
1077
  self._broker_url.port or 1883,
1074
1078
  60,
1075
1079
  )
@@ -1136,8 +1140,21 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1136
1140
  self._reconnect_task.cancel()
1137
1141
  self._reconnect_task = None
1138
1142
 
1143
+ # Subscribe to base topic to see 'online' messages
1139
1144
  self.client.subscribe(self._topic_base) # hope to see 'online' message
1140
1145
 
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("/+"):
1149
+ data_wildcard = self._topic_base.replace("/+", "/+/rx")
1150
+ self.client.subscribe(data_wildcard, qos=self._mqtt_qos)
1151
+ _LOGGER.debug(f"Subscribed to data wildcard: {data_wildcard}")
1152
+
1153
+ # If we already have specific topics, re-subscribe to them
1154
+ if hasattr(self, "_topic_sub") and self._topic_sub:
1155
+ self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
1156
+ _LOGGER.debug(f"Re-subscribed to specific topic: {self._topic_sub}")
1157
+
1141
1158
  def _on_connect_fail(
1142
1159
  self,
1143
1160
  client: mqtt.Client,
@@ -1155,19 +1172,36 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1155
1172
  self,
1156
1173
  client: mqtt.Client,
1157
1174
  userdata: Any,
1158
- reason_code: Any,
1159
- properties: Any | None,
1175
+ *args: Any,
1176
+ **kwargs: Any,
1160
1177
  ) -> None:
1161
- _LOGGER.warning(f"MQTT disconnected: {reason_code.getName()}")
1178
+ # Handle different paho-mqtt callback signatures
1179
+ reason_code = args[0] if len(args) >= 1 else None
1180
+
1181
+ reason_name = (
1182
+ reason_code.getName()
1183
+ if reason_code is not None and hasattr(reason_code, "getName")
1184
+ else str(reason_code)
1185
+ )
1186
+ _LOGGER.warning(f"MQTT disconnected: {reason_name}")
1162
1187
 
1188
+ was_connected = self._connected
1163
1189
  self._connected = False
1164
1190
 
1191
+ # If we were previously connected and had established communication,
1192
+ # notify that the device is now offline
1193
+ if was_connected and hasattr(self, "_topic_sub") and self._topic_sub:
1194
+ device_topic = self._topic_sub[:-3] # Remove "/rx" suffix
1195
+ _LOGGER.warning(f"{self}: the MQTT device is offline: {device_topic}")
1196
+
1197
+ # Pause writing since device is offline
1198
+ if hasattr(self, "_protocol"):
1199
+ self._protocol.pause_writing()
1200
+
1165
1201
  # Only attempt reconnection if we didn't deliberately disconnect
1166
- if not self._closing and not reason_code.is_failure:
1167
- # This was an unexpected disconnect, schedule reconnection
1168
- self._schedule_reconnect()
1169
- elif reason_code.is_failure and not self._closing:
1170
- # Connection failed, also schedule reconnection
1202
+
1203
+ if not self._closing:
1204
+ # Schedule reconnection for any disconnect (unexpected or failure)
1171
1205
  self._schedule_reconnect()
1172
1206
 
1173
1207
  def _create_connection(self, msg: mqtt.MQTTMessage) -> None:
@@ -1191,7 +1225,12 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1191
1225
 
1192
1226
  self.client.subscribe(self._topic_sub, qos=self._mqtt_qos)
1193
1227
 
1194
- self._make_connection(gwy_id=msg.topic[-9:]) # type: ignore[arg-type]
1228
+ # Only call connection_made on first connection, not reconnections
1229
+ if not self._connection_established:
1230
+ self._connection_established = True
1231
+ self._make_connection(gwy_id=msg.topic[-9:]) # type: ignore[arg-type]
1232
+ else:
1233
+ _LOGGER.info("MQTT reconnected - protocol connection already established")
1195
1234
 
1196
1235
  # NOTE: self._frame_read() invoked from here
1197
1236
  def _on_message(
@@ -1211,23 +1250,51 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1211
1250
  _LOGGER.info("Rx: %s", msg.payload)
1212
1251
 
1213
1252
  if msg.topic[-3:] != "/rx": # then, e.g. 'RAMSES/GATEWAY/18:017804'
1214
- if msg.payload == b"offline" and self._topic_sub.startswith(msg.topic):
1215
- _LOGGER.warning(
1216
- f"{self}: the MQTT device is offline: {self._topic_sub[:-3]}"
1217
- )
1218
- self._connected = False
1219
- self._protocol.pause_writing()
1253
+ if msg.payload == b"offline":
1254
+ # Check if this offline message is for our current device
1255
+ if (
1256
+ hasattr(self, "_topic_sub")
1257
+ and self._topic_sub
1258
+ and msg.topic == self._topic_sub[:-3]
1259
+ ) or not hasattr(self, "_topic_sub"):
1260
+ _LOGGER.warning(
1261
+ f"{self}: the ESP device is offline (via LWT): {msg.topic}"
1262
+ )
1263
+ # Don't set _connected = False here - that's for MQTT connection, not ESP device
1264
+ if hasattr(self, "_protocol"):
1265
+ self._protocol.pause_writing()
1220
1266
 
1221
1267
  # BUG: using create task (self._loop.ct() & asyncio.ct()) causes the
1222
1268
  # BUG: event look to close early
1223
1269
  elif msg.payload == b"online":
1224
1270
  _LOGGER.info(
1225
- f"{self}: the MQTT device is online: {self._topic_sub[:-3]}"
1271
+ f"{self}: the ESP device is online (via status): {msg.topic}"
1226
1272
  )
1227
1273
  self._create_connection(msg)
1228
1274
 
1229
1275
  return
1230
1276
 
1277
+ # Handle data messages - if we don't have connection established yet but get data,
1278
+ # we can infer the gateway from the topic
1279
+ if not self._connection_established and msg.topic.endswith("/rx"):
1280
+ # Extract gateway ID from topic like "RAMSES/GATEWAY/18:123456/rx"
1281
+ topic_parts = msg.topic.split("/")
1282
+ if len(topic_parts) >= 3 and topic_parts[-2] not in ("+", "*"):
1283
+ gateway_id = topic_parts[-2] # Should be something like "18:123456"
1284
+ _LOGGER.info(
1285
+ f"Inferring gateway connection from data topic: {gateway_id}"
1286
+ )
1287
+
1288
+ # Set up topics and connection
1289
+ self._topic_pub = f"{'/'.join(topic_parts[:-1])}/tx"
1290
+ self._topic_sub = msg.topic
1291
+ self._extra[SZ_ACTIVE_HGI] = gateway_id
1292
+
1293
+ # Mark as connected and establish protocol connection
1294
+ self._connected = True
1295
+ self._connection_established = True
1296
+ self._make_connection(gwy_id=gateway_id) # type: ignore[arg-type]
1297
+
1231
1298
  try:
1232
1299
  payload = json.loads(msg.payload)
1233
1300
  except json.JSONDecodeError:
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.3"
3
+ __version__ = "0.51.5"
4
4
  VERSION = __version__