Coverage for src/ramses_rf/device/hvac.py: 44%

1#!/usr/bin/env python3

2"""RAMSES RF - devices from the HVAC domain."""

3

4 from __future__ import annotations

5

6 import logging

7 from collections.abc import Callable

8 from typing import Any, TypeVar

9

10 from ramses_rf import exceptions as exc

11 from ramses_rf.const import (

12 SZ_AIR_QUALITY,

13 SZ_AIR_QUALITY_BASIS,

14 SZ_BOOST_TIMER,

15 SZ_BYPASS_MODE,

16 SZ_BYPASS_POSITION,

17 SZ_BYPASS_STATE,

18 SZ_CO2_LEVEL,

19 SZ_EXHAUST_FAN_SPEED,

20 SZ_EXHAUST_FLOW,

21 SZ_EXHAUST_TEMP,

22 SZ_FAN_INFO,

23 SZ_FAN_MODE,

24 SZ_FAN_RATE,

25 SZ_INDOOR_HUMIDITY,

26 SZ_INDOOR_TEMP,

27 SZ_OUTDOOR_HUMIDITY,

28 SZ_OUTDOOR_TEMP,

29 SZ_POST_HEAT,

30 SZ_PRE_HEAT,

31 SZ_PRESENCE_DETECTED,

32 SZ_REMAINING_DAYS,

33 SZ_REMAINING_MINS,

34 SZ_REMAINING_PERCENT,

35 SZ_REQ_REASON,

36 SZ_REQ_SPEED,

37 SZ_SPEED_CAPABILITIES,

38 SZ_SUPPLY_FAN_SPEED,

39 SZ_SUPPLY_FLOW,

40 SZ_SUPPLY_TEMP,

41 SZ_TEMPERATURE,

42 DevType,

43)

44 from ramses_rf.entity_base import class_by_attr

45 from ramses_tx import Address, Command, Message, Packet, Priority

46 from ramses_tx.ramses import CODES_OF_HVAC_DOMAIN_ONLY, HVAC_KLASS_BY_VC_PAIR

47

48 from .base import BatteryState, DeviceHvac, Fakeable

49

50 from ramses_rf.const import ( # noqa: F401, isort: skip, pylint: disable=unused-import

51 I_,

52 RP,

53 RQ,

54 W_,

55 Code,

56)

57

58# TODO: Switch this module to utilise the (run-time) decorator design pattern...

59# - https://refactoring.guru/design-patterns/decorator/python/example

60# - will probably need setattr()?

61# BaseComponents: FAN (HRU, PIV, EXT), SENsor (CO2, HUM, TEMp), SWItch (RF gateway?)

62# - a device could be a combination of above (e.g. Spider Gateway)

63# Track binding for SWI (HA service call) & SEN (HA trigger) to FAN/other

64

65# Challenges:

66# - may need two-tier system (HVAC -> FAN|SEN|SWI -> command class)

67# - thus, Composite design pattern may be more appropriate

68

69

70 _LOGGER = logging.getLogger(__name__)

71

72

73 _HvacRemoteBaseT = TypeVar("_HvacRemoteBaseT", bound="HvacRemoteBase")

74 _HvacSensorBaseT = TypeVar("_HvacSensorBaseT", bound="HvacSensorBase")

75

76

77 class HvacRemoteBase(DeviceHvac):

78 """Base class for HVAC remote control devices.

79

80 This class serves as a base for all remote control devices in the HVAC domain.

81 It provides common functionality and interfaces for remote control operations.

82 """

83

84 pass

85

86

87 class HvacSensorBase(DeviceHvac):

88 """Base class for HVAC sensor devices.

89

90 This class serves as a base for all sensor devices in the HVAC domain.

91 It provides common functionality for sensor data collection and processing.

92 """

93

94 pass

95

96

97 class CarbonDioxide(HvacSensorBase): # 1298

98 """The CO2 sensor (cardinal code is 1298)."""

99

100 @property

101 def co2_level(self) -> int | None:

102 """Get the CO2 level in ppm.

103

104 :return: The CO2 level in parts per million (ppm), or None if not available

105 :rtype: int | None

106 """

107 return self._msg_value(Code._1298, key=SZ_CO2_LEVEL)

108

109 @co2_level.setter

110 def co2_level(self, value: int | None) -> None:

111 """Set a fake CO2 level for the sensor.

112

113 :param value: The CO2 level in ppm to set, or None to clear the fake value

114 :type value: int | None

115 :raises TypeError: If the sensor is not in faked mode

116 """

117

118 if not self.is_faked:

119 raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")

120

121 cmd = Command.put_co2_level(self.id, value)

122 self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)

123

124 @property

125 def status(self) -> dict[str, Any]:

126 """Return the status of the CO2 sensor.

127

128 :return: A dictionary containing the sensor's status including CO2 level

129 :rtype: dict[str, Any]

130 """

131 return {

132 **super().status,

133 SZ_CO2_LEVEL: self.co2_level,

134 }

135

136

137 class IndoorHumidity(HvacSensorBase): # 12A0

138 """The relative humidity sensor (12A0)."""

139

140 @property

141 def indoor_humidity(self) -> float | None:

142 """Get the indoor relative humidity.

143

144 :return: The indoor relative humidity as a percentage (0-100), or None if not available

145 :rtype: float | None

146 """

147 return self._msg_value(Code._12A0, key=SZ_INDOOR_HUMIDITY)

148

149 @indoor_humidity.setter

150 def indoor_humidity(self, value: float | None) -> None:

151 """Set a fake indoor humidity value for the sensor.

152

153 :param value: The humidity percentage to set (0-100), or None to clear the fake value

154 :type value: float | None

155 :raises TypeError: If the sensor is not in faked mode

156 """

157

158 if not self.is_faked:

159 raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")

160

161 cmd = Command.put_indoor_humidity(self.id, value)

162 self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)

163

164 @property

165 def status(self) -> dict[str, Any]:

166 """Return the status of the indoor humidity sensor.

167

168 :return: A dictionary containing the sensor's status including humidity level

169 :rtype: dict[str, Any]

170 """

171 return {

172 **super().status,

173 SZ_INDOOR_HUMIDITY: self.indoor_humidity,

174 }

175

176

177 class PresenceDetect(HvacSensorBase): # 2E10

178 """The presence sensor (2E10/31E0)."""

179

180 # .I --- 37:154011 --:------ 37:154011 1FC9 030 00-31E0-96599B 00-1298-96599B 00-2E10-96599B 01-10E0-96599B 00-1FC9-96599B # CO2, idx|10E0 == 01

181 # .W --- 28:126620 37:154011 --:------ 1FC9 012 00-31D9-49EE9C 00-31DA-49EE9C # FAN, BRDG-02A55

182 # .I --- 37:154011 28:126620 --:------ 1FC9 001 00 # CO2, incl. integrated control, PIR

183

184 @property

185 def presence_detected(self) -> bool | None:

186 """Get the presence detection status.

187

188 :return: True if presence is detected, False if not, None if status is unknown

189 :rtype: bool | None

190 """

191 return self._msg_value(Code._2E10, key=SZ_PRESENCE_DETECTED)

192

193 @presence_detected.setter

194 def presence_detected(self, value: bool | None) -> None:

195 """Set a fake presence detection state for the sensor.

196

197 :param value: The presence state to set (True/False), or None to clear the fake value

198 :type value: bool | None

199 :raises TypeError: If the sensor is not in faked mode

200 """

201

202 if not self.is_faked:

203 raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")

204

205 cmd = Command.put_presence_detected(self.id, value)

206 self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)

207

208 @property

209 def status(self) -> dict[str, Any]:

210 """Return the status of the presence sensor.

211

212 :return: A dictionary containing the sensor's status including presence detection state

213 :rtype: dict[str, Any]

214 """

215 return {

216 **super().status,

217 SZ_PRESENCE_DETECTED: self.presence_detected,

218 }

219

220

221 class FilterChange(DeviceHvac): # FAN: 10D0

222 """The filter state sensor (10D0)."""

223

224 def _setup_discovery_cmds(self) -> None:

225 """Set up the discovery commands for the filter change sensor."""

226 super()._setup_discovery_cmds()

227

228 self._add_discovery_cmd(

229 Command.from_attrs(RQ, self.id, Code._10D0, "00"), 60 * 60 * 24, delay=30

230 )

231

232 @property

233 def filter_remaining(self) -> int | None:

234 """Return the remaining days until filter change is needed.

235

236 :return: Number of days remaining until filter change, or None if not available

237 :rtype: int | None

238 """

239 _val = self._msg_value(Code._10D0, key=SZ_REMAINING_DAYS)

240 assert isinstance(_val, (int | type(None)))

241 return _val

242

243 @property

244 def filter_remaining_percent(self) -> float | None:

245 """Return the remaining filter life as a percentage.

246

247 :return: Percentage of filter life remaining (0-100), or None if not available

248 :rtype: float | None

249 """

250 _val = self._msg_value(Code._10D0, key=SZ_REMAINING_PERCENT)

251 assert isinstance(_val, (float | type(None)))

252 return _val

253

254

255 class RfsGateway(DeviceHvac): # RFS: (spIDer gateway)

256 """The spIDer gateway base class."""

257

258 _SLUG: str = DevType.RFS

259

260 def __init__(self, *args: Any, **kwargs: Any) -> None:

261 """Initialize the RFS gateway.

262

263 :param args: Positional arguments passed to the parent class

264 :param kwargs: Keyword arguments passed to the parent class

265 """

266 super().__init__(*args, **kwargs)

267

268 self.ctl = None

269 self._child_id = "hv" # NOTE: domain_id

270 self.tcs = None

271

272

273 class HvacHumiditySensor(BatteryState, IndoorHumidity, Fakeable): # HUM: I/12A0

274 """The class for a humidity sensor.

275

276 The cardinal code is 12A0.

277 """

278

279 _SLUG: str = DevType.HUM

280

281 @property

282 def temperature(self) -> float | None:

283 """Return the current temperature in Celsius.

284

285 :return: The temperature in degrees Celsius, or None if not available

286 :rtype: float | None

287 """

288 return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)

289

290 @property

291 def dewpoint_temp(self) -> float | None:

292 """Return the dewpoint temperature in Celsius.

293

294 :return: The dewpoint temperature in degrees Celsius, or None if not available

295 :rtype: float | None

296 """

297 return self._msg_value(Code._12A0, key="dewpoint_temp")

298

299 @property

300 def status(self) -> dict[str, Any]:

301 """Return the status of the humidity sensor.

302

303 :return: A dictionary containing the sensor's status including temperature and humidity

304 :rtype: dict[str, Any]

305 """

306 return {

307 **super().status,

308 SZ_TEMPERATURE: self.temperature,

309 "dewpoint_temp": self.dewpoint_temp,

310 }

311

312

313 class HvacCarbonDioxideSensor(CarbonDioxide, Fakeable): # CO2: I/1298

314 """The class for a CO2 sensor.

315

316 The cardinal code is 1298.

317 """

318

319 _SLUG: str = DevType.CO2

320

321 # .I --- 29:181813 63:262142 --:------ 1FC9 030 00-31E0-76C635 01-31E0-76C635 00-1298-76C635 67-10E0-76C635 00-1FC9-76C635

322 # .W --- 32:155617 29:181813 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1 # The HRU

323 # .I --- 29:181813 32:155617 --:------ 1FC9 001 00

324

325 async def initiate_binding_process(self) -> Packet:

326 """Initiate the binding process for the CO2 sensor.

327

328 :return: The packet sent to initiate binding

329 :rtype: Packet

330 :raises exc.BindingError: If binding fails

331 """

332 return await super()._initiate_binding_process(

333 (Code._31E0, Code._1298, Code._2E10)

334 )

335

336

337 class HvacRemote(BatteryState, Fakeable, HvacRemoteBase): # REM: I/22F[138]

338 """The REM (remote/switch) class, such as a 4-way switch.

339

340 The cardinal codes are 22F1, 22F3 (also 22F8?).

341 """

342

343 _SLUG: str = DevType.REM

344

345 async def initiate_binding_process(self) -> Packet:

346 # .I --- 37:155617 --:------ 37:155617 1FC9 024 00-22F1-965FE1 00-22F3-965FE1 67-10E09-65FE1 00-1FC9-965FE1

347 # .W --- 32:155617 37:155617 --:------ 1FC9 012 00-31D9-825FE1 00-31DA-825FE1

348 # .I --- 37:155617 32:155617 --:------ 1FC9 001 00

349

350 return await super()._initiate_binding_process(

351 Code._22F1 if self._scheme == "nuaire" else (Code._22F1, Code._22F3)

352 )

353

354 @property

355 def fan_rate(self) -> str | None:

356 """Get the current fan rate setting.

357

358 :return: The fan rate as a string, or None if not available

359 :rtype: str | None

360 :note: This is a work in progress - rate can be either int or str

361 """

362 return self._msg_value(Code._22F1, key="rate")

363

364 @fan_rate.setter

365 def fan_rate(self, value: int) -> None:

366 """Set a fake fan rate for the remote control.

367

368 :param value: The fan rate to set (can be int or str, but not None)

369 :type value: int

370 :raises TypeError: If the remote is not in faked mode

371 :note: This is a work in progress

372 """

373

374 if not self.is_faked: # NOTE: some remotes are stateless (i.e. except seqn)

375 raise exc.DeviceNotFaked(f"{self}: Faking is not enabled")

376

377 # TODO: num_repeats=2, or wait_for_reply=True ?

378

379 # NOTE: this is not completely understood (i.e. diffs between vendor schemes)

380 cmd = Command.set_fan_mode(self.id, int(4 * value), src_id=self.id)

381 self._gwy.send_cmd(cmd, num_repeats=2, priority=Priority.HIGH)

382

383 @property

384 def fan_mode(self) -> str | None:

385 """Return the current fan mode.

386

387 :return: The fan mode as a string, or None if not available

388 :rtype: str | None

389 """

390 return self._msg_value(Code._22F1, key=SZ_FAN_MODE)

391

392 @property

393 def boost_timer(self) -> int | None:

394 """Return the remaining boost timer in minutes.

395

396 :return: The remaining boost time in minutes, or None if boost is not active

397 :rtype: int | None

398 """

399 return self._msg_value(Code._22F3, key=SZ_BOOST_TIMER)

400

401 @property

402 def status(self) -> dict[str, Any]:

403 return {

404 **super().status,

405 SZ_FAN_MODE: self.fan_mode,

406 SZ_BOOST_TIMER: self.boost_timer,

407 }

408

409

410 class HvacDisplayRemote(HvacRemote): # DIS

411 """The DIS (display switch)."""

412

413 _SLUG: str = DevType.DIS

414

415 # async def initiate_binding_process(self) -> Packet:

416 # return await super()._initiate_binding_process(

417 # (Code._31E0, Code._1298, Code._2E10)

418 # )

419

420

421 class HvacVentilator(FilterChange): # FAN: RP/31DA, I/31D[9A], 2411

422 """The FAN (ventilation) class.

423

424 The cardinal codes are 31D9, 31DA. Signature is RP/31DA.

425

426 Also handles 2411 parameter messages for configuration.

427 Since 2411 is not supported by all vendors, discovery is used to determine if it is supported.

428 Since more than 1 different parameters can be sent on 2411 messages,

429 we process these in the dedicated _handle_2411_message method.

430 """

431

432 # Itho Daalderop (NL)

433 # Heatrae Sadia (UK)

434 # Nuaire (UK), e.g. DRI-ECO-PIV

435 # Orcon/Ventiline

436 # ClimaRad (NL)

437 # Vasco (B)

438

439 _SLUG: str = DevType.FAN

440

441 def __init__(self, *args: Any, **kwargs: Any) -> None:

442 """Initialize the HvacVentilator.

443

444 :param args: Positional arguments passed to the parent class

445 :param kwargs: Keyword arguments passed to the parent class

446 """

447 super().__init__(*args, **kwargs)

448 self._supports_2411 = False # Flag for 2411 parameter support

449 self._params_2411: dict[str, float] = {} # Store 2411 parameters here

450 self._initialized_callback = None # Called when device is fully initialized

451 self._param_update_callback = None # Called when 2411 parameters are updated

452 self._hgi: Any | None = None # Will be set when HGI is available

453 self._bound_devices: dict[str, str] = {} # Track bound devices (e.g., REM/DIS)

454

455 def set_initialized_callback(self, callback: Callable[[], None] | None) -> None:

456 """Set a callback to be executed when the next message (any) is received.

457

458 The callback will be used exactly once to indicate that the device is fully functional.

459 In ramses_cc, 2411 entities are created - on the fly - only for devices that support them.

460

461 :param callback: A callable that takes no arguments and returns None.

462 If None, any existing callback will be cleared.

463 :type callback: Callable[[], None] | None

464 :raises ValueError: If the callback is not callable and not None

465 """

466 if callback is not None and not callable(callback):

467 raise ValueError("Callback must be callable or None")

468

469 self._initialized_callback = callback

470 if callback is not None:

471 _LOGGER.debug("Initialization callback set for %s", self.id)

472

473 def _handle_initialized_callback(self) -> None:

474 """Handle the initialization callback.

475

476 This method is called when the device has been fully initialized and

477 is ready to process commands. It triggers any registered initialization

478 callbacks and performs necessary setup for 2411 parameter support.

479 """

480 if self._initialized_callback is not None and self.supports_2411:

481 _LOGGER.debug("2411-Device initialized: %s", self.id)

482 if callable(self._initialized_callback):

483 try:

484 self._initialized_callback()

485 except Exception as ex:

486 _LOGGER.warning("Error in initialized_callback: %s", ex)

487 finally:

488 # Clear the callback so it's only called once

489 self._initialized_callback = None

490

491 def set_param_update_callback(

492 self, callback: Callable[[str, Any], None] | None

493 ) -> None:

494 """Set a callback to be called when 2411 parameters are updated.

495

496 This method registers a callback function that will be invoked whenever

497 a 2411 parameter is updated. The callback receives the parameter ID and

498 its new value as arguments.

499

500 Since 2411 parameters are configuration entities, we are not polling for them

501 and we update them immediately after receiving a 2411 message. We don't wait for them,

502 we only process when we see a 2411 response for our device. The request may have come

503 from another REM or DIS, but we will update to that as well.

504

505 :param callback: A callable that will be invoked with (param_id, value) when a

506 2411 parameter is updated, or None to clear the current callback

507 :type callback: Callable[[str, Any], None] | None

508 """

509 self._param_update_callback = callback

510

511 def _handle_param_update(self, param_id: str, value: Any) -> None:

512 """Handle a parameter update and notify listeners.

513

514 This method processes parameter updates and notifies any registered

515 callbacks of the change. It ensures thread safety and handles any

516 exceptions that may occur during callback execution.

517

518 :param param_id: The ID of the parameter that was updated

519 :type param_id: str

520 :param value: The new value of the parameter

521 :type value: float

522 """

523 if callable(self._param_update_callback):

524 try:

525 self._param_update_callback(param_id, value)

526 except Exception as ex:

527 _LOGGER.warning("Error in param_update_callback: %s", ex)

528

529 @property

530 def supports_2411(self) -> bool:

531 """Return whether this device supports 2411 parameters.

532

533 :return: True if the device supports 2411 parameters, False otherwise

534 :rtype: bool

535 """

536 return self._supports_2411

537

538 @property

539 def hgi(self) -> Any | None:

540 """Return the HGI (Home Gateway Interface) device if available.

541

542 The HGI device provides additional functionality for certain operations.

543

544 :return: The HGI device instance, or None if not available

545 :rtype: float | None

546 """

547 if self._hgi is None and self._gwy and hasattr(self._gwy, "hgi"):

548 self._hgi = self._gwy.hgi

549 return self._hgi

550

551 def get_2411_param(self, param_id: str) -> float | None:

552 """Get a 2411 parameter value.

553

554 :param param_id: The parameter ID to retrieve.

555 :type param_id: str

556 :return: The parameter value if found, None otherwise

557 :rtype: float | None

558 """

559 return self._params_2411.get(param_id)

560

561 def set_2411_param(self, param_id: str, value: float) -> bool:

562 """Set a 2411 parameter value.

563

564 :param param_id: The parameter ID to retrieve.

565 :type param_id: str

566 :param value: The parameter value to set.

567 :type value: float

568 :return: True if the parameter was set, False otherwise

569 :rtype: bool

570 """

571 if not self._supports_2411:

572 _LOGGER.warning("Device %s doesn't support 2411 parameters", self.id)

573 return False

574

575 self._params_2411[param_id] = value

576 return True

577

578 def get_fan_param(self, param_id: str) -> Any | None:

579 """Retrieve a fan parameter value from the device's message store.

580

581 This wrapper method gets a specific parameter value for a FAN device stored in

582 _params_2411 dict. It first makes sure we use the proper param_id format

583

584 :param param_id: The parameter ID to retrieve.

585 :type param_id: str

586 :return: The parameter value if found, None otherwise

587 :rtype: float | None

588 """

589 # Ensure param_id is uppercase and strip leading zeros for consistency

590 param_id = (

591 str(param_id).upper().lstrip("0") or "0"

592 ) # Handle case where param_id is "0"

593

594 param_value = self.get_2411_param(param_id)

595 if param_value is not None:

596 return param_value

597 else:

598 _LOGGER.debug("Parameter %s not found for %s", param_id, self.id)

599 return None

600

601 def _handle_2411_message(self, msg: Message) -> None:

602 """Handle incoming 2411 parameter messages.

603

604 This method processes 2411 parameter update messages, updates the device's

605 message store, and triggers any registered parameter update callbacks.

606 It handles parameter value normalization and validation.

607

608 :param msg: The incoming 2411 message

609 :type msg: Message to process

610 """

611 if not hasattr(msg, "payload") or not isinstance(msg.payload, dict):

612 _LOGGER.debug("Invalid 2411 message format: %s", msg)

613 return

614

615 param_id = msg.payload.get("parameter")

616 param_value = msg.payload.get("value")

617

618 if not param_id or param_value is None:

619 _LOGGER.debug("Missing parameter ID or value in 2411 message: %s", msg)

620 return

621

622 # Mark that we support 2411 parameters

623 if not self._supports_2411:

624 self._supports_2411 = True

625 _LOGGER.debug("Device %s supports 2411 parameters", self.id)

626

627 # Normalize the value if needed

628 if param_id == "75" and isinstance(param_value, (int, float)):

629 param_value = round(float(param_value), 1)

630 elif param_id in ("52", "95"): # Percentage parameters

631 param_value = round(float(param_value), 3) # Keep precision for percentages

632

633 # Store in params

634 old_value = self.get_2411_param(param_id)

635 self.set_2411_param(param_id, param_value)

636

637 # Log the update

638 _LOGGER.debug(

639 "Updated 2411 parameter %s: %s (was: %s) for %s",

640 param_id,

641 param_value,

642 old_value,

643 self.id,

644 )

645

646 # call the 2411 parameter update callback

647 self._handle_param_update(param_id, param_value)

648

649 def _handle_msg(self, msg: Message) -> None:

650 """Handle a message from this device.

651

652 This method processes incoming messages for the device, with special

653 handling for 2411 parameter messages. It updates the device state and

654 triggers any necessary callbacks.

655

656 After handling the messages, it calls the initialized callback - if set - to notify that

657 the device was fully initialized.

658

659 :param msg: The incoming message to process

660 :type msg: Message

661 """

662 super()._handle_msg(msg)

663

664 # Handle 2411 parameter messages

665 if msg.code == Code._2411:

666 _LOGGER.debug(

667 "Received 2411 message from %s: verb=%s, payload=%s, src=%s, dst=%s",

668 self.id,

669 msg.verb,

670 msg.payload,

671 msg.src,

672 msg.dst,

673 )

674 self._handle_2411_message(msg)

675

676 self._handle_initialized_callback()

677

678 def _setup_discovery_cmds(self) -> None:

679 """Set up discovery commands for the RFS gateway.

680

681 This method initializes the discovery commands needed to identify and

682 communicate with the RFS gateway device.

683 """

684 super()._setup_discovery_cmds()

685

686 # RP --- 32:155617 18:005904 --:------ 22F1 003 000207

687 self._add_discovery_cmd(

688 Command.from_attrs(RQ, self.id, Code._22F1, "00"), 60 * 60 * 24, delay=15

689 ) # to learn scheme: orcon/itho/other (04/07/0?)

690

691 # Add a single discovery command for all parameters (3F likely to be supported if any)

692 # The handler will process the response and update the appropriate parameter and

693 # also set the supports_2411 flag

694 _LOGGER.debug("Adding single discovery command for all 2411 parameters")

695 self._add_discovery_cmd(

696 Command.from_attrs(RQ, self.id, Code._2411, "00003F"),

697 interval=60 * 60 * 24, # Check daily

698 delay=40, # Initial delay before first discovery

699 )

700

701 # Standard discovery commands for other codes

702 for code in (

703 Code._2210, # Air quality

704 Code._22E0, # Bypass position

705 Code._22E5, # Remaining minutes

706 Code._22E9, # Speed cap

707 Code._22F2, # Post heat

708 Code._22F4, # Pre heat

709 Code._22F8, # Air quality base

710 ):

711 self._add_discovery_cmd(

712 Command.from_attrs(RQ, self.id, code, "00"), 60 * 30, delay=15

713 )

714

715 for code in (Code._313E, Code._3222):

716 self._add_discovery_cmd(

717 Command.from_attrs(RQ, self.id, code, "00"), 60 * 30, delay=30

718 )

719

720 def add_bound_device(self, device_id: str, device_type: str) -> None:

721 """Add a bound device to this FAN.

722

723 This method registers a REM or DIS device as bound to this FAN device.

724 Bound devices are required for certain operations like setting parameters.

725

726 A bound device is needed to be able to send 2411 parameter Set messages,

727 or the device will not accept and respond to them.

728 In HomeAssistant, ramses_cc, you can set a bound device in the device configuration.

729

730 System schema and known devices example:

731

732 .. code-block::

733

734 "32:153289":

735 bound: "37:168270"

736 class: FAN

737 "37:168270":

738 class: REM

739 faked: true

740

741 :param device_id: The unique identifier of the device to bind

742 :type device_id: str

743 :param device_type: The type of device (must be 'REM' or 'DIS')

744 :type device_type: str

745 :raises ValueError: If the device type is not 'REM' or 'DIS'

746 """

747 if device_type not in (DevType.REM, DevType.DIS):

748 _LOGGER.warning(

749 "Cannot bind device %s of type %s to FAN %s: must be REM or DIS",

750 device_id,

751 device_type,

752 self.id,

753 )

754 return

755

756 self._bound_devices[device_id] = device_type

757 _LOGGER.info("Bound %s device %s to FAN %s", device_type, device_id, self.id)

758

759 def remove_bound_device(self, device_id: str) -> None:

760 """Remove a bound device from this FAN.

761

762 This method unregisters a previously bound device from this FAN.

763

764 :param device_id: The unique identifier of the device to unbind

765 :type device_id: str

766 """

767 if device_id in self._bound_devices:

768 device_type = self._bound_devices.pop(device_id)

769 _LOGGER.info(

770 "Removed bound %s device %s from FAN %s",

771 device_type,

772 device_id,

773 self.id,

774 )

775

776 def get_bound_rem(self) -> str | None:

777 """Get the first bound REM/DIS device ID for this FAN.

778

779 This method retrieves the device ID of the first bound REM or DIS device.

780 Bound devices are required for certain operations like setting parameters.

781

782 :return: The device ID of the first bound REM or DIS device, or None if none found

783 :rtype: str | None

784 """

785 if not self._bound_devices:

786 _LOGGER.debug("No bound devices found for FAN %s", self.id)

787 return None

788

789 # Find first REM or DIS device

790 for device_id, device_type in self._bound_devices.items():

791 if device_type in (DevType.REM, DevType.DIS):

792 _LOGGER.debug(

793 "Found bound %s device %s for FAN %s",

794 device_type,

795 device_id,

796 self.id,

797 )

798 return device_id

799

800 _LOGGER.debug("No bound REM or DIS devices found for FAN %s", self.id)

801 return None

802

803 @property

804 def air_quality(self) -> float | None:

805 """Return the current air quality measurement.

806

807 :return: The air quality measurement as a float, or None if not available

808 :rtype: float | None

809 """

810 return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY)

811

812 @property

813 def air_quality_base(self) -> float | None:

814 """Return the base air quality measurement.

815

816 This represents the baseline or raw air quality measurement before any

817 processing or normalization.

818

819 :return: The base air quality measurement, or None if not available

820 :rtype: float | None

821 """

822 return self._msg_value(Code._31DA, key=SZ_AIR_QUALITY_BASIS)

823

824 @property

825 def bypass_mode(self) -> str | None:

826 """

827 :return: bypass mode as on|off|auto

828 """

829 return self._msg_value(Code._22F7, key=SZ_BYPASS_MODE)

830

831 @property

832 def bypass_position(self) -> float | str | None:

833 """

834 Position info is found in 22F7 and in 31DA. The most recent packet is returned.

835 :return: bypass position as percentage: 0.0 (closed) or 1.0 (open), on error: "x_faulted"

836 """

837 # if both packets exist and both have the key, returns the most recent

838 return self._msg_value((Code._22F7, Code._31DA), key=SZ_BYPASS_POSITION)

839

840 @property

841 def bypass_state(self) -> str | None:

842 """

843 Orcon, others?

844 :return: bypass position as on/off

845 """

846 return self._msg_value(Code._22F7, key=SZ_BYPASS_STATE)

847

848 @property

849 def co2_level(self) -> int | None:

850 """Return the CO2 level in parts per million (ppm).

851

852 :return: The CO2 level in ppm, or None if not available

853 :rtype: int | None

854 """

855 return self._msg_value(Code._31DA, key=SZ_CO2_LEVEL)

856

857 @property

858 def exhaust_fan_speed(

859 self,

860 ) -> float | None:

861 """

862 Some fans (Vasco, Itho) use Code._31D9 for speed + mode,

863 Orcon sends SZ_EXHAUST_FAN_SPEED in 31DA. See parser for details.

864 :return: speed as percentage

865 """

866 speed: float = -1

867 for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]:

868 if v := self._msgs[code].payload.get(SZ_EXHAUST_FAN_SPEED):

869 # if both packets exist and both have the key, use the highest value

870 if v is not None:

871 speed = max(v, speed)

872 if speed >= 0:

873 return speed

874 return None

875

876 @property

877 def exhaust_flow(self) -> float | None:

878 """Return the current exhaust air flow rate.

879

880 :return: The exhaust air flow rate in m³/h, or None if not available

881 :rtype: float | None

882 """

883 return self._msg_value(Code._31DA, key=SZ_EXHAUST_FLOW)

884

885 @property

886 def exhaust_temp(self) -> float | None:

887 """Return the current exhaust air temperature.

888

889 :return: The exhaust air temperature in degrees Celsius, or None if not available

890 :rtype: float | None

891 """

892 return self._msg_value(Code._31DA, key=SZ_EXHAUST_TEMP)

893

894 @property

895 def fan_rate(self) -> str | None:

896 """

897 Lookup fan mode description from _22F4 message payload, e.g. "low", "medium", "boost".

898 For manufacturers Orcon, Vasco, ClimaRad.

899

900 :return: int or str describing rate of fan

901 """

902 return self._msg_value(Code._22F4, key=SZ_FAN_RATE)

903

904 @property

905 def fan_mode(self) -> str | None:

906 """

907 Lookup fan mode description from _22F4 message payload, e.g. "auto", "manual", "off".

908 For manufacturers Orcon, Vasco, ClimaRad.

909

910 :return: a string describing mode

911 """

912 return self._msg_value(Code._22F4, key=SZ_FAN_MODE)

913

914 @property

915 def fan_info(self) -> str | None:

916 """

917 Extract fan info description from MessageIndex _31D9 or _31DA payload,

918 e.g. "speed 2, medium".

919 By its name, the result is picked up by a sensor in HA Climate UI.

920 Some manufacturers (Orcon, Vasco) include the fan mode (auto, manual), others don't (Itho).

921

922 :return: string describing fan mode, speed

923 """

924 # if self._gwy.msg_db:

925 # Use SQLite query on MessageIndex. res_rate/res_mode not exposed yet

926 # working fine in 0.52.4, no need to specify code, only payload key

927 # sql = f"""

928 # SELECT code from messages WHERE verb in (' I', 'RP')

929 # AND (src = ? OR dst = ?)

930 # AND (plk LIKE '%{SZ_FAN_MODE}%')

931 # """

932 # res_mode: list = self._msg_qry(sql)

933 # # SQLite query on MessageIndex

934 # _LOGGER.debug(

935 # f"# Fetched FAN_MODE for {self.id} from MessageIndex: {res_mode}"

936 # )

937

938 # sql = f"""

939 # SELECT code from messages WHERE verb in (' I', 'RP')

940 # AND (src = ? OR dst = ?)

941 # AND (plk LIKE '%{SZ_FAN_RATE}%')

942 # """

943 # res_rate: list = self._msg_qry(sql)

944 # # SQLite query on MessageIndex

945 # _LOGGER.debug(

946 # f"# Fetched FAN_RATE for {self.id} from MessageIndex: {res_rate}"

947 # )

948

949 if Code._31D9 in self._msgs:

950 # was a dict by Code

951 # Itho, Vasco D60 and ClimaRad MiniBox fan send mode/speed in _31D9

952 v: str

953 for k, v in self._msgs[Code._31D9].payload.items():

954 if k == SZ_FAN_MODE and len(v) > 2: # prevent non-lookups to pass

955 return v

956 # continue to 31DA

957 return str(self._msg_value(Code._31DA, key=SZ_FAN_INFO)) # Itho lookup

958

959 @property

960 def indoor_humidity(self) -> float | None:

961 """

962 Extract indoor_humidity from MessageIndex _12A0 or _31DA payload

963 Just a demo for SQLite query helper at the moment.

964

965 :return: float RH value from 0.0 to 1.0 = 100%

966 """

967 if Code._12A0 in self._msgs and isinstance(

968 self._msgs[Code._12A0].payload, list

969 ): # FAN Ventura sends RH/temps as a list; element [0] contains indoor_hum

970 if v := self._msgs[Code._12A0].payload[0].get(SZ_INDOOR_HUMIDITY):

971 assert isinstance(v, (float | type(None)))

972 return v

973 return self._msg_value((Code._12A0, Code._31DA), key=SZ_INDOOR_HUMIDITY)

974

975 @property

976 def indoor_temp(self) -> float | None:

977 """Return the current indoor temperature.

978

979 :return: The indoor temperature in degrees Celsius, or None if not available

980 :rtype: float | None

981 """

982 return self._msg_value(Code._31DA, key=SZ_INDOOR_TEMP)

983

984 @property

985 def outdoor_humidity(self) -> float | None:

986 """Return the outdoor relative humidity.

987

988 Handles special case for Ventura devices that send humidity data in 12A0 messages.

989

990 :return: The outdoor relative humidity as a percentage (0-100), or None if not available

991 :rtype: float | None

992 """

993 if Code._12A0 in self._msgs and isinstance(

994 self._msgs[Code._12A0].payload, list

995 ): # FAN Ventura sends RH/temps as a list; element [1] contains outdoor_hum

996 if v := self._msgs[Code._12A0].payload[1].get(SZ_OUTDOOR_HUMIDITY):

997 assert isinstance(v, (float | type(None)))

998 return v

999 return self._msg_value(Code._31DA, key=SZ_OUTDOOR_HUMIDITY)

1000

1001 @property

1002 def outdoor_temp(self) -> float | None:

1003 """Return the outdoor temperature in Celsius.

1004

1005 :return: The outdoor temperature in degrees Celsius, or None if not available

1006 :rtype: float | None

1007 """

1008 return self._msg_value(Code._31DA, key=SZ_OUTDOOR_TEMP)

1009

1010 @property

1011 def post_heat(self) -> int | None:

1012 """Return the post-heat status.

1013

1014 :return: The post-heat status as an integer, or None if not available

1015 :rtype: int | None

1016 """

1017 return self._msg_value(Code._31DA, key=SZ_POST_HEAT)

1018

1019 @property

1020 def pre_heat(self) -> int | None:

1021 """Return the pre-heat status.

1022

1023 :return: The pre-heat status as an integer, or None if not available

1024 :rtype: int | None

1025 """

1026 return self._msg_value(Code._31DA, key=SZ_PRE_HEAT)

1027

1028 @property

1029 def remaining_mins(self) -> int | None:

1030 """Return the remaining minutes for the current operation.

1031

1032 :return: The remaining minutes as an integer, or None if not available

1033 :rtype: int | None

1034 """

1035 return self._msg_value(Code._31DA, key=SZ_REMAINING_MINS)

1036

1037 @property

1038 def request_fan_speed(self) -> float | None:

1039 """Return the requested fan speed.

1040

1041 :return: The requested fan speed as a percentage, or None if not available

1042 :rtype: float | None

1043 """

1044 return self._msg_value(Code._2210, key=SZ_REQ_SPEED)

1045

1046 @property

1047 def request_src(self) -> str | None:

1048 """

1049 Orcon, others?

1050 :return: source sensor of auto speed request: IDL, CO2 or HUM

1051 """

1052 return self._msg_value(Code._2210, key=SZ_REQ_REASON)

1053

1054 @property

1055 def speed_cap(self) -> int | None:

1056 """Return the speed capabilities of the fan.

1057

1058 :return: The speed capabilities as an integer, or None if not available

1059 :rtype: int | None

1060 """

1061 return self._msg_value(Code._31DA, key=SZ_SPEED_CAPABILITIES)

1062

1063 @property

1064 def supply_fan_speed(self) -> float | None:

1065 """Return the supply fan speed.

1066

1067 :return: The supply fan speed as a percentage, or None if not available

1068 :rtype: float | None

1069 """

1070 return self._msg_value(Code._31DA, key=SZ_SUPPLY_FAN_SPEED)

1071

1072 @property

1073 def supply_flow(self) -> float | None:

1074 """Return the supply air flow rate.

1075

1076 :return: The supply air flow rate in m³/h, or None if not available

1077 :rtype: float | None

1078 """

1079 return self._msg_value(Code._31DA, key=SZ_SUPPLY_FLOW)

1080

1081 @property

1082 def supply_temp(self) -> float | None:

1083 """Return the supply air temperature.

1084

1085 Handles special case for Ventura devices that send temperature data in 12A0 messages.

1086

1087 :return: The supply air temperature in Celsius, or None if not available

1088 :rtype: float | None

1089 """

1090 if Code._12A0 in self._msgs and isinstance(

1091 self._msgs[Code._12A0].payload, list

1092 ): # FAN Ventura sends RH/temps as a list;

1093 # pass element [0] in place of supply_temp, which is always None in VenturaV1x 31DA

1094 if v := self._msgs[Code._12A0].payload[1].get(SZ_TEMPERATURE):

1095 assert isinstance(v, (float | type(None)))

1096 return v

1097 return self._msg_value(Code._31DA, key=SZ_SUPPLY_TEMP)

1098

1099 @property

1100 def status(self) -> dict[str, Any]:

1101 return {

1102 **super().status,

1103 SZ_EXHAUST_FAN_SPEED: self.exhaust_fan_speed,

1104 **{

1105 k: v

1106 for code in [c for c in (Code._31D9, Code._31DA) if c in self._msgs]

1107 for k, v in self._msgs[code].payload.items()

1108 if k != SZ_EXHAUST_FAN_SPEED

1109 },

1110 }

1111

1112 @property

1113 def temperature(self) -> float | None: # Celsius

1114 """Return the current temperature in Celsius.

1115

1116 Handles special cases.

1117

1118 :return: The temperature in degrees Celsius, or None if not available

1119 :rtype: float | None

1120 """

1121 if Code._12A0 in self._msgs and isinstance(

1122 self._msgs[Code._12A0].payload, list

1123 ): # FAN Ventura sends RH/temps as a list; use element [1]

1124 if v := self._msgs[Code._12A0].payload[0].get(SZ_TEMPERATURE):

1125 assert isinstance(v, (float | type(None)))

1126 return v

1127 # ClimaRad minibox FAN sends (indoor) temp in 12A0

1128 return self._msg_value(Code._12A0, key=SZ_TEMPERATURE)

1129

1130

1131# class HvacFanHru(HvacVentilator):

1132# """A Heat recovery unit (aka: HRU, WTW)."""

1133# _SLUG: str = DEV_TYPE.HRU

1134# class HvacFanCve(HvacVentilator):

1135# """An extraction unit (aka: CVE, CVD)."""

1136# _SLUG: str = DEV_TYPE.CVE

1137# class HvacFanPiv(HvacVentilator):

1138# """A positive input ventilation unit (aka: PIV)."""

1139# _SLUG: str = DEV_TYPE.PIV

1140

1141

1142# e.g. {"HUM": HvacHumiditySensor}

1143 HVAC_CLASS_BY_SLUG: dict[str, type[DeviceHvac]] = class_by_attr(__name__, "_SLUG")

1144

1145

1146 def class_dev_hvac(

1147 dev_addr: Address, *, msg: Message | None = None, eavesdrop: bool = False

1148) -> type[DeviceHvac]:

1149 """Return a device class, but only if the device must be from the HVAC group.

1150

1151 May return a base class, `DeviceHvac`, which will need promotion.

1152 """

1153

1154 if not eavesdrop:

1155 raise TypeError(f"No HVAC class for: {dev_addr} (no eavesdropping)")

1156

1157 if msg is None:

1158 raise TypeError(f"No HVAC class for: {dev_addr} (no msg)")

1159

1160 if klass := HVAC_KLASS_BY_VC_PAIR.get((msg.verb, msg.code)):

1161 return HVAC_CLASS_BY_SLUG[klass]

1162

1163 if msg.code in CODES_OF_HVAC_DOMAIN_ONLY:

1164 return DeviceHvac

1165

1166 raise TypeError(f"No HVAC class for: {dev_addr} (insufficient meta-data)")

1167

1168

1169 _REMOTES = {

1170 "21800000": {

1171 "name": "Orcon 15RF",

1172 "mode": "1,2,3,T,Auto,Away",

1173 },

1174 "21800060": {

1175 "name": "Orcon 15RF Display",

1176 "mode": "1,2,3,T,Auto,Away",

1177 },

1178 "xxx": {

1179 "name": "Orcon CO2 Control",

1180 "mode": "1T,2T,3T,Auto,Away",

1181 },

1182 "03-00062": {

1183 "name": "RFT-SPIDER",

1184 "mode": "1,2,3,T,A",

1185 },

1186 "04-00045": {"name": "RFT-CO2"}, # mains-powered

1187 "04-00046": {"name": "RFT-RV"},

1188 "545-7550": {

1189 "name": "RFT-PIR",

1190 },

1191 "536-0124": { # idx="00"

1192 "name": "RFT",

1193 "mode": "1,2,3,T",

1194 "CVE": False, # not clear

1195 "HRV": True,

1196 },

1197 "536-0146": { # idx="??"

1198 "name": "RFT-DF",

1199 "mode": "",

1200 "CVE": True,

1201 "HRV": False,

1202 },

1203 "536-0150": { # idx = "63"

1204 "name": "RFT-AUTO",

1205 "mode": "1,Auto,3,T",

1206 "CVE": True,

1207 "HRV": True,

1208 },

1209}

1210

1211

1212# see: https://github.com/arjenhiemstra/ithowifi/blob/master/software/NRG_itho_wifi/src/IthoPacket.h

1213

1214"""

1215 Itho Remote (model) enums.

1216

1217 CVE/HRU remote (536-0124) RFT W: 3 modes, timer

1218-------------------------------------------------

1219

1220.. table:: 536-0124

1221 :widths: auto

1222

1223 =========== ========================= ================================================

1224 "away": (Code._22F1, 00, 01|04"), how to invoke?

1225 "low": (Code._22F1, 00, 02|04"), aka eco

1226 "medium": (Code._22F1, 00, 03|04"), aka auto (with sensors) - is that only for 63?

1227 "high": (Code._22F1, 00, 04|04"), aka full

1228

1229 "timer_1": (Code._22F3, 00, 00|0A"), 10 minutes full speed

1230 "timer_2": (Code._22F3, 00, 00|14"), 20 minutes full speed

1231 "timer_3": (Code._22F3, 00, 00|1E"), 30 minutes full speed

1232 =========== ========================= ================================================

1233

1234 RFT-AUTO (536-0150) RFT CAR: 2 modes, auto, timer: idx = 63, essentially same as above, but also...

1235-----------------------------------------------------------------------------------------------------

1236

1237.. table:: 536-0150

1238 :widths: auto

1239

1240 ============= ========================= ================================================

1241 "auto_night": (Code._22F8, 63, 02|03"), additional - press auto x2

1242 ============= ========================= ================================================

1243

1244 RFT-RV (04-00046), RFT-CO2 (04-00045) - sensors with control

1245------------------------------------------------------------

1246

1247.. table:: 04-00046

1248 :widths: auto

1249

1250 ============== ======================================== =============

1251 "medium": (Code._22F1, 00, 03|07"), 1=away, 2=low?

1252 "auto": (Code._22F1, 00, 05|07"), 4=high

1253 "auto_night": (Code._22F1, 00, 0B|0B"),

1254

1255 "timer_1": (Code._22F3, 00, 00|0A, 00|00, 0000"), 10 minutes

1256 "timer_2": (Code._22F3, 00, 00|14, 00|00, 0000"), 20 minutes

1257 "timer_3": (Code._22F3, 00, 00|1E, 00|00, 0000"), 30 minutes

1258 ============== ======================================== =============

1259

1260 RFT-PIR (545-7550) - presence sensor

1261------------------------------------

1262

1263 RFT_DF: DemandFlow remote (536-0146)

1264------------------------------------

1265

1266.. table:: 536-0146

1267 :widths: auto

1268

1269 =========== ================================ =========================================

1270 "timer_1": (Code._22F3, 00, 42|03, 03|03"), 0b01-000-010 = 3 hrs, back to last mode

1271 "timer_2": (Code._22F3, 00, 42|06, 03|03"), 0b01-000-010 = 6 hrs, back to last mode

1272 "timer_3": (Code._22F3, 00, 42|09, 03|03"), 0b01-000-010 = 9 hrs, back to last mode

1273 "cook_30": (Code._22F3, 00, 02|1E, 02|03"), 30 mins (press 1x)

1274 "cook_60": (Code._22F3, 00, 02|3C, 02|03"), 60 mins (press 2x)

1275

1276 "low": (Code._22F8, 00, 01|02"), ?eco co2 <= 1200 ppm?

1277 "high": (Code._22F8, 00, 02|02"), ?comfort co2 <= 1000 ppm?

1278 =========== ================================ =========================================

1279

1280

1281 Join commands:

1282--------------

1283

1284.. table:: join per accessory type

1285 :widths: auto

1286

1287 ========== ================= ===================== ========================= ========================== ========================= ========================== ================= ==========

1288 type set 1 set 2 set 3 set 4 set 5 set 6 description art #

1289 ========== ================= ===================== ========================= ========================== ========================= ========================== ================= ==========

1290 "CVERFT": (Code._1FC9, 00, Code._22F1, 0x000000, 01, Code._10E0, 0x000000") CVE/HRU remote (536-0124)

1291 "AUTORFT": (Code._1FC9, 63, Code._22F8, 0x000000, 01, Code._10E0, 0x000000") AUTO RFT (536-0150)

1292 "DF": (Code._1FC9, 00, Code._22F8, 0x000000, 00, Code._10E0, 0x000000") DemandFlow remote (536-0146)

1293 "RV": (Code._1FC9, 00, Code._12A0, 0x000000, 01, Code._10E0, 0x000000, 00, Code._31E0, 0x000000, 00, Code._1FC9, 0x000000") RFT-RV (04-00046)

1294 "CO2": (Code._1FC9, 00, Code._1298, 0x000000, 00, Code._2E10, 0x000000, 01, Code._10E0, 0x000000, 00, Code._31E0, 0x000000, 00, Code._1FC9, 0x000000") RFT-CO2 (04-00045)

1295 ========== ================= ===================== ========================= ========================== ========================= ========================== ================= ==========

1296

1297 Leave commands:

1298---------------

1299

1300.. table:: leave per accessory type

1301 :widths: auto

1302

1303 ========== ================= ====================== ========================= ==========

1304 type set 1 set 2 description art #

1305 ========== ================= ====================== ========================= ==========

1306 "Others": (Code._1FC9, 00, Code._1FC9, 0x000000") standard leave command

1307 "AUTORFT": (Code._1FC9, 63, Code._1FC9, 0x000000") leave command of AUTO RFT (536-0150)

1308 ========== ================= ====================== ========================= ==========

1309

1310.. table:: verbs

1311 :widths: 2, 4

1312

1313 ====== ========

1314 verb byte

1315 ====== ========

1316 ``RQ`` ``0x00``

1317 ``I_`` ``0x01``

1318 ``W_`` ``0x02``

1319 ``RP`` ``0x03``

1320 ====== ========

1321

1322"""