ramses-rf 0.51.4__py3-none-any.whl → 0.51.6__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_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
@@ -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"
@@ -440,7 +442,7 @@ DEV_TYPE_MAP = attr_dict_factory(
440
442
  "HEAT_ZONE_ACTUATORS": ("00", "02", "04", "13"),
441
443
  "THM_DEVICES": ("03", "12", "22", "34"),
442
444
  "TRV_DEVICES": ("00", "04"),
443
- "CONTROLLERS": ("01", "12", "22", "23", "34"), # potentially controllers
445
+ "CONTROLLERS": ("01", "02", "12", "22", "23", "34"), # potentially controllers
444
446
  "PROMOTABLE_SLUGS": (DevType.DEV, DevType.HEA, DevType.HVC),
445
447
  "HVAC_SLUGS": {
446
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
@@ -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).
@@ -657,6 +670,26 @@ def _parse_hvac_temp(param_name: str, value: HexStr4) -> Mapping[str, float | No
657
670
  return {param_name: temp}
658
671
 
659
672
 
673
+ ABILITIES = {
674
+ 15: "off",
675
+ 14: "low_med_high", # 3,2,1 = high,med,low?
676
+ 13: "timer",
677
+ 12: "boost",
678
+ 11: "auto",
679
+ 10: "speed_4",
680
+ 9: "speed_5",
681
+ 8: "speed_6",
682
+ 7: "speed_7",
683
+ 6: "speed_8",
684
+ 5: "speed_9",
685
+ 4: "speed_10",
686
+ 3: "auto_night",
687
+ 2: "reserved",
688
+ 1: "post_heater",
689
+ 0: "pre_heater",
690
+ }
691
+
692
+
660
693
  # 31DA[30:34]
661
694
  def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
662
695
  """Return the speed capabilities (a bitmask).
@@ -672,25 +705,6 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
672
705
  if value == "7FFF": # TODO: Not implemented???
673
706
  return {SZ_SPEED_CAPABILITIES: None}
674
707
 
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
708
  # assert value in ("0002", "4000", "4808", "F000", "F001", "F800", "F808"), value
695
709
 
696
710
  return {
@@ -700,6 +714,16 @@ def parse_capabilities(value: HexStr4) -> PayDictT.CAPABILITIES:
700
714
  }
701
715
 
702
716
 
717
+ def capability_bits(cap_list: list[str]) -> int:
718
+ # 0xF800 = 0b1111100000000000
719
+ cap_res: int = 0
720
+ for cap in cap_list:
721
+ for k, v in ABILITIES.items():
722
+ if v == cap:
723
+ cap_res |= 2**k # set bit
724
+ return cap_res
725
+
726
+
703
727
  # 31DA[34:36]
704
728
  def parse_bypass_position(value: HexStr2) -> PayDictT.BYPASS_POSITION:
705
729
  """Return the bypass position (%), usually fully open or closed (0%, no bypass).
@@ -757,7 +781,22 @@ def parse_fan_info(value: HexStr2) -> PayDictT.FAN_INFO:
757
781
  }
758
782
 
759
783
 
760
- # 31DA[38:40]
784
+ def fan_info_to_byte(info: str) -> int:
785
+ for k, v in _31DA_FAN_INFO.items():
786
+ if v == info:
787
+ return int(k) & 0x1F
788
+ return 0x0000
789
+
790
+
791
+ def fan_info_flags(flags_list: list[int]) -> int:
792
+ flag_res: int = 0
793
+ for index, shft in enumerate(range(7, 4, -1)): # index = 7, 6 and 5
794
+ if flags_list[index] == 1:
795
+ flag_res |= 1 << shft # set bits
796
+ return flag_res
797
+
798
+
799
+ # 31DA[38:40], also 2210
761
800
  def parse_exhaust_fan_speed(value: HexStr2) -> PayDictT.EXHAUST_FAN_SPEED:
762
801
  """Return the exhaust fan speed (% of max speed)."""
763
802
  return _parse_fan_speed(SZ_EXHAUST_FAN_SPEED, value) # type: ignore[return-value]
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,28 +173,32 @@ 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
176
+ # To fix issue ramses_cc 293, test if this override is still required
177
+ # async def getFilesToDelete(self) -> list[str]: # zxdavb: my version
178
+ # """Determine the files to delete when rolling over.
179
+ #
180
+ # Overridden as old log files were 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
+ # loop = asyncio.get_running_loop()
185
+ # # Must run async in executor to prevent HA blocking call on rollover (ramses_cc issue 293)
186
+ # file_names = await loop.run_in_executor(None, os.listdir, dirName) < doesn't work
187
+ #
188
+ # result = []
189
+ # prefix = baseName + "."
190
+ # plen = len(prefix)
191
+ # for fileName in file_names:
192
+ # if fileName[:plen] == prefix:
193
+ # suffix = fileName[plen:]
194
+ # if self.extMatch.match(suffix):
195
+ # result.append(os.path.join(dirName, fileName))
196
+ # if len(result) < self.backupCount:
197
+ # result = []
198
+ # else:
199
+ # result.sort()
200
+ # result = result[: len(result) - self.backupCount]
201
+ # return result
199
202
 
200
203
 
201
204
  def getLogger( # permits a bespoke Logger class
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
@@ -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,22 +1379,18 @@ 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[
1386
- 10:12
1387
- ] in ( # auto requested fan speed step?
1388
- "58",
1389
- "64",
1390
- "96",
1391
- "FF",
1392
- ), f"expected req.speed? (58|64|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?
1393
1389
  assert payload[20:22] == payload[48:50] and payload[20:22] in (
1394
- "00",
1395
- "03",
1396
- ), f"expected byte 10 (00|03), not {payload[20:22]}"
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]}"
1397
1394
  assert payload[78:80] in ("00", "02"), (
1398
1395
  f"expected byte 39 (00|02), not {payload[78:80]}"
1399
1396
  )
@@ -1407,9 +1404,17 @@ def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1407
1404
  except AssertionError as err:
1408
1405
  _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1409
1406
 
1407
+ _req = "IDL"
1408
+ if payload[20:22] == "02":
1409
+ _req = "CO2"
1410
+ elif payload[20:22] == "03":
1411
+ _req = "HUM"
1412
+
1410
1413
  return {
1411
- "unknown_10": payload[10:12],
1412
- "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,
1413
1418
  "unknown_78": payload[78:80],
1414
1419
  "unknown_80": payload[80:82],
1415
1420
  "unknown_82": payload[82:],
@@ -2215,10 +2220,9 @@ def parser_31d9(payload: str, msg: Message) -> dict[str, Any]:
2215
2220
  # ventilation state (extended), HVAC
2216
2221
  def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
2217
2222
  # see: https://github.com/python/typing/issues/1445
2218
- return { # type: ignore[typeddict-unknown-key]
2223
+ result = {
2219
2224
  **parse_exhaust_fan_speed(payload[38:40]), # maybe 31D9[4:6] for some?
2220
2225
  **parse_fan_info(payload[36:38]), # 22F3-ish
2221
- #
2222
2226
  **parse_air_quality(payload[2:6]), # 12C8[2:6]
2223
2227
  **parse_co2_level(payload[6:10]), # 1298[2:6]
2224
2228
  **parse_indoor_humidity(payload[10:12]), # 12A0?
@@ -2236,6 +2240,11 @@ def parser_31da(payload: str, msg: Message) -> PayDictT._31DA:
2236
2240
  **parse_supply_flow(payload[50:54]), # NOTE: is supply, not exhaust
2237
2241
  **parse_exhaust_flow(payload[54:58]), # NOTE: order switched from others
2238
2242
  }
2243
+ if len(payload) == 58:
2244
+ return result # type: ignore[return-value]
2245
+
2246
+ result.update({"_extra": payload[58:]}) # sporadic [58:60] always 00
2247
+ return result # type: ignore[return-value]
2239
2248
 
2240
2249
  # From an Orcon 15RF Display
2241
2250
  # 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
@@ -1073,7 +1073,7 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1073
1073
  self._connecting = True
1074
1074
  try:
1075
1075
  self.client.connect_async(
1076
- self._broker_url.hostname, # type: ignore[arg-type]
1076
+ str(self._broker_url.hostname or "localhost"),
1077
1077
  self._broker_url.port or 1883,
1078
1078
  60,
1079
1079
  )
@@ -1172,10 +1172,18 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1172
1172
  self,
1173
1173
  client: mqtt.Client,
1174
1174
  userdata: Any,
1175
- reason_code: Any,
1176
- properties: Any | None,
1175
+ *args: Any,
1176
+ **kwargs: Any,
1177
1177
  ) -> None:
1178
- _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}")
1179
1187
 
1180
1188
  was_connected = self._connected
1181
1189
  self._connected = False
@@ -1191,13 +1199,9 @@ class MqttTransport(_FullTransport, _MqttTransportAbstractor):
1191
1199
  self._protocol.pause_writing()
1192
1200
 
1193
1201
  # Only attempt reconnection if we didn't deliberately disconnect
1194
- if not self._closing and not reason_code.is_failure:
1195
- # This was an unexpected disconnect, schedule reconnection
1196
- _LOGGER.debug("MQTT unexpected disconnect - scheduling reconnection")
1197
- self._schedule_reconnect()
1198
- elif reason_code.is_failure and not self._closing:
1199
- # Connection failed, also schedule reconnection
1200
- _LOGGER.debug("MQTT connection failed - scheduling reconnection")
1202
+
1203
+ if not self._closing:
1204
+ # Schedule reconnection for any disconnect (unexpected or failure)
1201
1205
  self._schedule_reconnect()
1202
1206
 
1203
1207
  def _create_connection(self, msg: mqtt.MQTTMessage) -> None:
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.4"
3
+ __version__ = "0.51.6"
4
4
  VERSION = __version__
@@ -1,55 +0,0 @@
1
- ramses_cli/__init__.py,sha256=uvGzWqOf4avvgzxJNSLFWEelIWqSZ-AeLAZzg5x58bc,397
2
- ramses_cli/client.py,sha256=QOmPKjCQHHOZwLBWEB438zabI9k38-ELRwisLvbvxSU,19782
3
- ramses_cli/debug.py,sha256=vgR0lOHoYjWarN948dI617WZZGNuqHbeq6Tc16Da7b4,608
4
- ramses_cli/discovery.py,sha256=81XbmpNiCpUHVZBwo2g1eRwyJG-wZhpSsc44G3hHlFA,12972
5
- ramses_cli/utils/cat_slow.py,sha256=AhUpM5gnegCitNKU-JGHn-DrRzSi-49ZR1Qw6lxe_t8,607
6
- ramses_cli/utils/convert.py,sha256=D_YiCyX5na9pgC-_NhBlW9N1dgRKUK-uLtLBfofjzZM,1804
7
- ramses_rf/__init__.py,sha256=zONFBiRdf07cPTSxzr2V3t-b3CGokZjT9SGit4JUKRA,1055
8
- ramses_rf/binding_fsm.py,sha256=uZAOl3i19KCXqqlaLJWkEqMMP7NJBhVPW3xTikQD1fY,25996
9
- ramses_rf/const.py,sha256=DSo4ROWDlOlcdXQdrpAF17vOsTLgmf2u0UppjYa5qJI,5390
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
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
16
- ramses_rf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- ramses_rf/schemas.py,sha256=mYOUZOH5OIDNBxRM2vd8POzDWEEmLhxh5UtqjTpFNek,13287
18
- ramses_rf/version.py,sha256=zEZhrTC1gMQzcGZ3ZSkZrhF_OBj8Dnlv5VhehgnT3_s,125
19
- ramses_rf/device/__init__.py,sha256=sUbH5dhbYFXSoM_TPFRutpRutBRpup7_cQ9smPtDTy8,4858
20
- ramses_rf/device/base.py,sha256=V2YzRhdxrTqfHYrCBq6pJsYdTgAx8gGzfdo8pkbeEo8,17450
21
- ramses_rf/device/heat.py,sha256=2sCsggySVcuTzyXDmgWy76QbhlU5MQWSejy3zgI5BDE,54242
22
- ramses_rf/device/hvac.py,sha256=QAlWx6jBArbHzZD5Mm1wZPmIbrQrw4ljbTDV0P4IH3I,23438
23
- ramses_rf/system/__init__.py,sha256=uZLKio3gLlBzePa2aDQ1nxkcp1YXOGrn6iHTG8LiNIw,711
24
- ramses_rf/system/faultlog.py,sha256=GdGmVGT3137KsTlV_nhccgIFEmYu6DFsLTn4S-8JSok,12799
25
- ramses_rf/system/heat.py,sha256=dARzcwL39JGwOBJkKJBi0_i7rr8IvY-qaNmWmgJLpdo,39223
26
- ramses_rf/system/schedule.py,sha256=Ts6tdZPTQLV5NkgwA73tPa5QUsnZNIIuYoKC-8VsXDk,18808
27
- ramses_rf/system/zones.py,sha256=QwRtSHY5c-Amcs6JD16uQcimOsEQTZcMm1dW-pqEFqM,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=B2db8Yxks-lMNsQAK1DoPkF1gvwNIacLmKwXuApUyLk,30221
32
- ramses_tx/exceptions.py,sha256=FJSU9YkvpKjs3yeTqUJX1o3TPFSe_B01gRGIh9b3PNc,2632
33
- ramses_tx/fingerprints.py,sha256=nfftA1E62HQnb-eLt2EqjEi_la0DAoT0wt-PtTMie0s,11974
34
- ramses_tx/frame.py,sha256=9lUVh8gAMXNRAolfFw2WuWANjn24AWkmscuM9Tm5imE,22036
35
- ramses_tx/gateway.py,sha256=FE5MWA1eIE9JATA2vRoBSQ8fAzqp7TqAm3Ds3k1KnKE,11267
36
- ramses_tx/helpers.py,sha256=WJ5JtAT9iyhkcW53AIPNPuvGEUWFwLumZc-mCG2kIOc,32236
37
- ramses_tx/logger.py,sha256=7vUpcfOFMW95juMWDx5dhUOqV8DTsindZ-Qz2aCmEoA,11073
38
- ramses_tx/message.py,sha256=J1wvVkLPJQr2ffKCUQYSWwLPzRTZBC0zUU5W9DkN3hU,13190
39
- ramses_tx/opentherm.py,sha256=58PXz9l5x8Ou6Fm3y-R_UnGHCYahoi2RKIDdYStUMzk,42378
40
- ramses_tx/packet.py,sha256=NGunaGCkEjhTp9t4mARK5e7kbqT-Z_JKCH7ibMYMJXU,7357
41
- ramses_tx/parsers.py,sha256=eU5dqbbw1vzWDFxDhyNPy2j6t_LQN56mRJa0A-PeKiE,109411
42
- ramses_tx/protocol.py,sha256=ifj3qwcQivjQDaQUwM94xp-U8Pmef6zwSH7mav8DLWA,28867
43
- ramses_tx/protocol_fsm.py,sha256=YhHkTqbl8w-myimsOjV50uIFgg9HiApwPU7xA_jg5nU,26827
44
- ramses_tx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
- ramses_tx/ramses.py,sha256=GnZwvx-HSVFdjXfUen6aWClDtrAmYaKwbrWl-LsyKO4,52045
46
- ramses_tx/schemas.py,sha256=h2AcArVROy1_C4n6F0Crj4e-2BxXxH74xogFlc6nKHI,12769
47
- ramses_tx/transport.py,sha256=-IO8UY85OOytciX3h7tFN58BBDtI3TEoOgmUmv-LiNc,56288
48
- ramses_tx/typed_dicts.py,sha256=4ZT50M-Cuwy2ljAIorwoxEJ9c737xUHrUxX9wTh79xE,10834
49
- ramses_tx/typing.py,sha256=eF2SlPWhNhEFQj6WX2AhTXiyRQVXYnFutiepllYl2rI,5042
50
- ramses_tx/version.py,sha256=QqJQBBdWjuoIvxrhzIH4ysjOp5bzB0KI1TdFhr9c6og,123
51
- ramses_rf-0.51.4.dist-info/METADATA,sha256=odaoeguG-xZAlWq8nYF4PHVK3SV5mzJRi1QdD4IBKcE,3906
52
- ramses_rf-0.51.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
53
- ramses_rf-0.51.4.dist-info/entry_points.txt,sha256=NnyK29baOCNg8DinPYiZ368h7MTH7bgTW26z2A1NeIE,50
54
- ramses_rf-0.51.4.dist-info/licenses/LICENSE,sha256=-Kc35W7l1UkdiQ4314_yVWv7vDDrg7IrJfMLUiq6Nfs,1074
55
- ramses_rf-0.51.4.dist-info/RECORD,,