ramses-rf 0.51.6__py3-none-any.whl → 0.51.8__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/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:
@@ -606,12 +606,9 @@ def _parse_hvac_humidity(
606
606
  if int(value, 16) & 0xF0 == 0xF0:
607
607
  return _faulted_sensor(param_name, value)
608
608
 
609
- percentage = int(value, 16) / 100 # TODO: confirm not 200
610
- assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
609
+ percentage = hex_to_percent(value, False) # TODO: confirm not /200
611
610
 
612
- result: dict[str, float | str | None] = {
613
- param_name: percentage
614
- } # was: percent_from_hex(value, high_res=False)
611
+ result: dict[str, float | str | None] = {param_name: percentage}
615
612
  if temp:
616
613
  result |= {SZ_TEMPERATURE: hex_to_temp(temp)}
617
614
  if dewpoint:
@@ -790,9 +787,9 @@ def fan_info_to_byte(info: str) -> int:
790
787
 
791
788
  def fan_info_flags(flags_list: list[int]) -> int:
792
789
  flag_res: int = 0
793
- for index, shft in enumerate(range(7, 4, -1)): # index = 7, 6 and 5
790
+ for index, shift in enumerate(range(7, 4, -1)): # index = 7, 6 and 5
794
791
  if flags_list[index] == 1:
795
- flag_res |= 1 << shft # set bits
792
+ flag_res |= 1 << shift # set bits
796
793
  return flag_res
797
794
 
798
795
 
@@ -881,7 +878,7 @@ def _parse_fan_heater(param_name: str, value: HexStr2) -> Mapping[str, float | N
881
878
  if int(value, 16) & 0xF0 == 0xF0:
882
879
  return _faulted_sensor(param_name, value) # type: ignore[return-value]
883
880
 
884
- 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?)
885
882
  assert percentage <= 1.0, value # TODO: raise exception if > 1.0?
886
883
 
887
884
  return {param_name: percentage} # was: percent_from_hex(value, high_res=False)
ramses_tx/logger.py CHANGED
@@ -173,33 +173,6 @@ class TimedRotatingFileHandler(_TimedRotatingFileHandler):
173
173
  # self.doRollover()
174
174
  # return super().emit(record)
175
175
 
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
202
-
203
176
 
204
177
  def getLogger( # permits a bespoke Logger class
205
178
  name: str | None = None, pkt_log: bool = False
ramses_tx/parsers.py CHANGED
@@ -1391,15 +1391,18 @@ def parser_2210(payload: str, msg: Message) -> dict[str, Any]:
1391
1391
  "02", # requested by CO2 level/sensor
1392
1392
  "03", # requested by humidity level/sensor
1393
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
- )
1394
+ assert payload[78:80] in (
1395
+ "00",
1396
+ "02",
1397
+ ), f"expected byte 39 (00|02), not {payload[78:80]}"
1398
+ assert payload[80:82] in (
1399
+ "01",
1400
+ "08",
1401
+ ), f"expected byte 40 (01|08), not {payload[80:82]}"
1402
+ assert payload[82:] in (
1403
+ "00",
1404
+ "40",
1405
+ ), f"expected byte 41- (00|40), not {payload[82:]}"
1403
1406
 
1404
1407
  except AssertionError as err:
1405
1408
  _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
@@ -1648,21 +1651,17 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
1648
1651
  _LOGGER.warning(f"{msg!r} < {_INFORM_DEV_MSG} ({err})")
1649
1652
 
1650
1653
  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?
1654
+ 0x00: "fan_boost", # set fan off, or 'boost' mode?
1655
+ 0x01: "per_request?", # set fan as per payload[6:10]?
1656
+ 0x02: "per_request", # set fan as per payload[6:10]
1654
1657
  }.get(int(payload[2:4], 0x10) & 0x07) # 0b0000-0111
1655
1658
 
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
1659
+ fallback_speed = { # after timer expiry
1660
+ 0x00: "per_vent_speed", # set fan as per current fan mode
1661
+ 0x08: "fan_off", # set fan off?
1662
+ 0x10: "per_request", # set fan as per payload[10:14]
1663
+ 0x18: "per_vent_speed?", # set fan as per current fan mode/speed?
1664
+ }.get(int(payload[2:4], 0x10) & 0x38) # 0b0011-1000
1666
1665
 
1667
1666
  units = {
1668
1667
  0x00: "minutes",
@@ -1677,15 +1676,21 @@ def parser_22f3(payload: str, msg: Message) -> dict[str, Any]:
1677
1676
  result = {
1678
1677
  "minutes" if units != "index" else "index": duration,
1679
1678
  "flags": hex_to_flag8(payload[2:4]),
1680
- "_new_speed_mode": new_speed,
1681
- "_fallback_speed_mode": fallback_speed,
1679
+ "new_speed_mode": new_speed,
1680
+ "fallback_speed_mode": fallback_speed,
1682
1681
  }
1683
1682
 
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")
1683
+ if msg._addrs[0] == NON_DEV_ADDR and msg.len <= 3:
1684
+ result["_scheme"] = "itho"
1685
+
1686
+ if msg.len >= 5 and payload[6:10] != "0000": # new speed
1687
+ mode_info = parser_22f1(f"00{payload[6:10]}", msg)
1688
+ result["_scheme"] = mode_info.get("_scheme")
1689
+ result["fan_mode"] = mode_info.get("fan_mode")
1686
1690
 
1687
- if msg.len >= 7: # fallback speed?
1688
- result.update({"_unknown_5": payload[10:]})
1691
+ if msg.len >= 7 and payload[10:14] != "0000": # fallback speed
1692
+ mode_info = parser_22f1(f"00{payload[10:14]}", msg)
1693
+ result["fallback_fan_mode"] = mode_info.get("fan_mode")
1689
1694
 
1690
1695
  return result
1691
1696
 
ramses_tx/protocol.py CHANGED
@@ -332,7 +332,7 @@ class _DeviceIdFilterMixin(_BaseProtocol):
332
332
  /,
333
333
  *,
334
334
  disable_warnings: bool = False,
335
- strick_checking: bool = False,
335
+ strict_checking: bool = False,
336
336
  ) -> DeviceIdT | None:
337
337
  """Return the device_id of the gateway specified in the include_list, if any.
338
338
 
@@ -384,7 +384,7 @@ class _DeviceIdFilterMixin(_BaseProtocol):
384
384
  f"The {SZ_KNOWN_LIST} includes exactly one gateway (HGI): {known_hgi}"
385
385
  )
386
386
 
387
- if strick_checking:
387
+ if strict_checking:
388
388
  return known_hgi if [known_hgi] == explicit_hgis else None
389
389
  return known_hgi
390
390
 
ramses_tx/protocol_fsm.py CHANGED
@@ -291,7 +291,7 @@ class ProtocolContext:
291
291
  # may want to set some instance variables, according to type of transport
292
292
  self._state.connection_made()
293
293
 
294
- # TODO: Should we clear the buffer if connection is lost (and apoligise to senders?
294
+ # TODO: Should we clear the buffer if connection is lost (and apologise to senders?
295
295
  def connection_lost(self, err: ExceptionT | None) -> None:
296
296
  self._state.connection_lost()
297
297
 
ramses_tx/ramses.py CHANGED
@@ -223,7 +223,7 @@ CODES_SCHEMA: dict[Code, dict[str, Any]] = { # rf_unknown
223
223
  SZ_NAME: "device_info",
224
224
  I_: r"^(00|FF)([0-9A-F]{30,})?$", # r"^[0-9A-F]{32,}$" might be OK
225
225
  RQ: r"^00$", # NOTE: 63 seen (no RP), some devices will accept [0-9A-F]{2}
226
- # RP: r"^[0-9A-F]{2}([0-9A-F]){30,}$", # NOTE: indx same as RQ
226
+ # RP: r"^[0-9A-F]{2}([0-9A-F]){30,}$", # NOTE: index same as RQ
227
227
  SZ_LIFESPAN: False,
228
228
  },
229
229
  Code._10E1: { # device_id
@@ -480,6 +480,7 @@ CODES_SCHEMA: dict[Code, dict[str, Any]] = { # rf_unknown
480
480
  SZ_NAME: "fan_params",
481
481
  I_: r"^(00|01|15|16|17|21)00[0-9A-F]{6}([0-9A-F]{8}){4}[0-9A-F]{4}$",
482
482
  RQ: r"^(00|01|15|16|17|21)00[0-9A-F]{2}((00){19})?$",
483
+ RP: r"^(00|01|15|16|17|21)00[0-9A-F]{6}[0-9A-F]{8}(([0-9A-F]{8}){3}[0-9A-F]{4})?$",
483
484
  W_: r"^(00|01|15|16|17|21)00[0-9A-F]{6}[0-9A-F]{8}(([0-9A-F]{8}){3}[0-9A-F]{4})?$",
484
485
  },
485
486
  Code._2420: { # unknown_2420, from OTB
@@ -1220,6 +1221,7 @@ SZ_MIN_VALUE: Final = "min_value"
1220
1221
  SZ_MAX_VALUE: Final = "max_value"
1221
1222
  SZ_PRECISION: Final = "precision"
1222
1223
  SZ_DATA_TYPE: Final = "data_type"
1224
+ SZ_DATA_UNIT: Final = "data_unit"
1223
1225
 
1224
1226
  _22F1_MODE_ITHO: dict[str, str] = {
1225
1227
  "00": "off", # not seen
@@ -1265,12 +1267,13 @@ _22F1_SCHEMES: dict[str, dict[str, str]] = {
1265
1267
 
1266
1268
  # unclear if true for only Orcon/*all* models
1267
1269
  _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1268
- "31": { # slot 09
1270
+ "31": { # slot 09 (FANs produced after 2021)
1269
1271
  SZ_DESCRIPTION: "Time to change filter (days)",
1270
1272
  SZ_MIN_VALUE: 0,
1271
1273
  SZ_MAX_VALUE: 1800,
1272
1274
  SZ_PRECISION: 30,
1273
1275
  SZ_DATA_TYPE: "10",
1276
+ SZ_DATA_UNIT: "days",
1274
1277
  },
1275
1278
  "3D": { # slot 00
1276
1279
  SZ_DESCRIPTION: "Away mode Supply fan rate (%)",
@@ -1278,6 +1281,7 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1278
1281
  SZ_MAX_VALUE: 0.4,
1279
1282
  SZ_PRECISION: 0.005,
1280
1283
  SZ_DATA_TYPE: "0F",
1284
+ SZ_DATA_UNIT: "%",
1281
1285
  },
1282
1286
  "3E": { # slot 01
1283
1287
  SZ_DESCRIPTION: "Away mode Exhaust fan rate (%)",
@@ -1285,6 +1289,7 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1285
1289
  SZ_MAX_VALUE: 0.4,
1286
1290
  SZ_PRECISION: 0.005,
1287
1291
  SZ_DATA_TYPE: "0F",
1292
+ SZ_DATA_UNIT: "%",
1288
1293
  },
1289
1294
  "3F": { # slot 02
1290
1295
  SZ_DESCRIPTION: "Low mode Supply fan rate (%)",
@@ -1292,6 +1297,7 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1292
1297
  SZ_MAX_VALUE: 0.8,
1293
1298
  SZ_PRECISION: 0.005,
1294
1299
  SZ_DATA_TYPE: "0F",
1300
+ SZ_DATA_UNIT: "%",
1295
1301
  },
1296
1302
  "40": { # slot 03
1297
1303
  SZ_DESCRIPTION: "Low mode Exhaust fan rate (%)",
@@ -1299,13 +1305,15 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1299
1305
  SZ_MAX_VALUE: 0.8,
1300
1306
  SZ_PRECISION: 0.005,
1301
1307
  SZ_DATA_TYPE: "0F",
1308
+ SZ_DATA_UNIT: "%",
1302
1309
  },
1303
1310
  "41": { # slot 04
1304
1311
  SZ_DESCRIPTION: "Medium mode Supply fan rate (%)",
1305
- SZ_MIN_VALUE: 0.0,
1312
+ SZ_MIN_VALUE: 0.1, # Orcon FAN responds with 0.0, but I guess this should be the same as for "42"
1306
1313
  SZ_MAX_VALUE: 1.0,
1307
1314
  SZ_PRECISION: 0.005,
1308
1315
  SZ_DATA_TYPE: "0F",
1316
+ SZ_DATA_UNIT: "%",
1309
1317
  },
1310
1318
  "42": { # slot 05
1311
1319
  SZ_DESCRIPTION: "Medium mode Exhaust fan rate (%)",
@@ -1313,13 +1321,15 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1313
1321
  SZ_MAX_VALUE: 1.0,
1314
1322
  SZ_PRECISION: 0.005,
1315
1323
  SZ_DATA_TYPE: "0F",
1324
+ SZ_DATA_UNIT: "%",
1316
1325
  },
1317
1326
  "43": { # slot 06
1318
1327
  SZ_DESCRIPTION: "High mode Supply fan rate (%)",
1319
- SZ_MIN_VALUE: 0.0,
1328
+ SZ_MIN_VALUE: 0.1,
1320
1329
  SZ_MAX_VALUE: 1.0,
1321
1330
  SZ_PRECISION: 0.005,
1322
1331
  SZ_DATA_TYPE: "0F",
1332
+ SZ_DATA_UNIT: "%",
1323
1333
  },
1324
1334
  "44": { # slot 07
1325
1335
  SZ_DESCRIPTION: "High mode Exhaust fan rate (%)",
@@ -1327,6 +1337,15 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1327
1337
  SZ_MAX_VALUE: 1.0,
1328
1338
  SZ_PRECISION: 0.005,
1329
1339
  SZ_DATA_TYPE: "0F",
1340
+ SZ_DATA_UNIT: "%",
1341
+ },
1342
+ "4B": { # slot 09 (FANs produced before 2021) Also check code 22F7
1343
+ SZ_DESCRIPTION: "(Test) Bypass Valve (0=auto, 1=open, 2=closed)",
1344
+ SZ_MIN_VALUE: 0,
1345
+ SZ_MAX_VALUE: 2,
1346
+ SZ_PRECISION: 1,
1347
+ SZ_DATA_TYPE: "00",
1348
+ SZ_DATA_UNIT: "",
1330
1349
  },
1331
1350
  "4E": { # slot 0A
1332
1351
  SZ_DESCRIPTION: "Moisture scenario position (0=medium, 1=high)",
@@ -1334,13 +1353,15 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1334
1353
  SZ_MAX_VALUE: 1,
1335
1354
  SZ_PRECISION: 1,
1336
1355
  SZ_DATA_TYPE: "00",
1356
+ SZ_DATA_UNIT: "",
1337
1357
  },
1338
1358
  "52": { # slot 0B
1339
1359
  SZ_DESCRIPTION: "Sensor sensitivity (%)",
1340
1360
  SZ_MIN_VALUE: 0,
1341
1361
  SZ_MAX_VALUE: 25.0,
1342
1362
  SZ_PRECISION: 0.1,
1343
- SZ_DATA_TYPE: "0F",
1363
+ SZ_DATA_TYPE: "01",
1364
+ SZ_DATA_UNIT: "%",
1344
1365
  },
1345
1366
  "54": { # slot 0C
1346
1367
  SZ_DESCRIPTION: "Moisture sensor overrun time (mins)",
@@ -1348,13 +1369,15 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1348
1369
  SZ_MAX_VALUE: 60,
1349
1370
  SZ_PRECISION: 1,
1350
1371
  SZ_DATA_TYPE: "00",
1372
+ SZ_DATA_UNIT: "min",
1351
1373
  },
1352
1374
  "75": { # slot 0D
1353
1375
  SZ_DESCRIPTION: "Comfort temperature (°C)",
1354
1376
  SZ_MIN_VALUE: 0.0,
1355
1377
  SZ_MAX_VALUE: 30.0,
1356
1378
  SZ_PRECISION: 0.01,
1357
- SZ_DATA_TYPE: 92,
1379
+ SZ_DATA_TYPE: "92",
1380
+ SZ_DATA_UNIT: "°C",
1358
1381
  },
1359
1382
  "95": { # slot 08
1360
1383
  SZ_DESCRIPTION: "Boost mode Supply/exhaust fan rate (%)",
@@ -1362,6 +1385,7 @@ _2411_PARAMS_SCHEMA: dict[str, dict[str, Any]] = {
1362
1385
  SZ_MAX_VALUE: 1.0,
1363
1386
  SZ_PRECISION: 0.005,
1364
1387
  SZ_DATA_TYPE: "0F",
1388
+ SZ_DATA_UNIT: "%",
1365
1389
  },
1366
1390
  }
1367
1391
 
@@ -1456,3 +1480,19 @@ _31DA_FAN_INFO: dict[int, str] = {
1456
1480
  # RAMSES_ZONES_ALL = RAMSES_ZONES.pop("ALL")
1457
1481
  # RAMSES_ZONES_DHW = RAMSES_ZONES[ZON_ROLE.DHW]
1458
1482
  # [RAMSES_ZONES[k].update(RAMSES_ZONES_ALL) for k in RAMSES_ZONES if k != ZON_ROLE.DHW]
1483
+
1484
+ __all__ = [
1485
+ "CODES_BY_DEV_SLUG",
1486
+ "CODES_SCHEMA",
1487
+ "CODE_NAME_LOOKUP",
1488
+ "CODES_BY_DEV_SLUG",
1489
+ "CODES_SCHEMA",
1490
+ "HVAC_KLASS_BY_VC_PAIR",
1491
+ "_2411_PARAMS_SCHEMA",
1492
+ "SZ_DESCRIPTION",
1493
+ "SZ_MIN_VALUE",
1494
+ "SZ_MAX_VALUE",
1495
+ "SZ_PRECISION",
1496
+ "SZ_DATA_TYPE",
1497
+ "SZ_DATA_UNIT",
1498
+ ]
ramses_tx/schemas.py CHANGED
@@ -215,6 +215,7 @@ def ConvertNullToDict() -> Callable[[_T | None], _T | dict[Never, Never]]:
215
215
 
216
216
 
217
217
  SZ_ALIAS: Final = "alias"
218
+ SZ_BOUND_TO: Final = "bound"
218
219
  SZ_CLASS: Final = "class"
219
220
  SZ_FAKED: Final = "faked"
220
221
  SZ_SCHEME: Final = "scheme"
@@ -296,6 +297,8 @@ def sch_global_traits_dict_factory(
296
297
  vol.Optional(SZ_CLASS, default="HVC"): vol.Any(
297
298
  None, *hvac_slugs, *(str(DEV_TYPE_MAP[s]) for s in hvac_slugs)
298
299
  ), # TODO: consider removing None
300
+ # Add 'bound' trait for FAN devices
301
+ vol.Optional(SZ_BOUND_TO): vol.Any(None, vol.Match(DEVICE_ID_REGEX.ANY)),
299
302
  }
300
303
  )
301
304
  SCH_TRAITS_HVAC = SCH_TRAITS_HVAC.extend(
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/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.6"
3
+ __version__ = "0.51.8"
4
4
  VERSION = __version__