Coverage for src/ramses_tx/command.py: 24%

1#!/usr/bin/env python3

2"""

3 RAMSES RF - a RAMSES-II protocol decoder & analyser.

4

5 This module provides the `Command` class for constructing and managing RAMSES-II protocol

6 commands (packets) that are to be sent to HVAC devices. It includes methods for creating

7 commands to control various aspects of the heating system including zones, DHW, and fan controls.

8

9"""

10

11 from __future__ import annotations

12

13 import logging

14 import math

15 from collections.abc import Iterable

16 from datetime import datetime as dt, timedelta as td

17 from typing import TYPE_CHECKING, Any, TypeVar

18

19 from . import exceptions as exc

20 from .address import (

21 ALL_DEV_ADDR,

22 HGI_DEV_ADDR,

23 NON_DEV_ADDR,

24 Address,

25 dev_id_to_hex_id,

26 pkt_addrs,

27)

28 from .const import (

29 DEV_TYPE_MAP,

30 DEVICE_ID_REGEX,

31 FAULT_DEVICE_CLASS,

32 FAULT_STATE,

33 FAULT_TYPE,

34 SYS_MODE_MAP,

35 SZ_DHW_IDX,

36 SZ_MAX_RETRIES,

37 SZ_PRIORITY,

38 SZ_TIMEOUT,

39 ZON_MODE_MAP,

40 FaultDeviceClass,

41 FaultState,

42 FaultType,

43 Priority,

44)

45 from .frame import Frame, pkt_header

46 from .helpers import (

47 air_quality_code,

48 capability_bits,

49 fan_info_flags,

50 fan_info_to_byte,

51 hex_from_bool,

52 hex_from_double,

53 hex_from_dtm,

54 hex_from_dts,

55 hex_from_percent,

56 hex_from_str,

57 hex_from_temp,

58 timestamp,

59)

60 from .opentherm import parity

61 from .parsers import LOOKUP_PUZZ

62 from .ramses import (

63 _2411_PARAMS_SCHEMA,

64 SZ_DATA_TYPE,

65 SZ_MAX_VALUE,

66 SZ_MIN_VALUE,

67 SZ_PRECISION,

68)

69 from .version import VERSION

70

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

72 I_,

73 RP,

74 RQ,

75 W_,

76 Code,

77)

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

79 F9,

80 FA,

81 FC,

82 FF,

83)

84

85

86 if TYPE_CHECKING:

87 from .const import VerbT

88 from .frame import HeaderT, PayloadT

89 from .schemas import DeviceIdT

90

91

92 COMMAND_FORMAT = "{:<2} {} {} {} {} {} {:03d} {}"

93

94

95 DEV_MODE = False

96

97 _LOGGER = logging.getLogger(__name__)

98 if DEV_MODE:

99 _LOGGER.setLevel(logging.DEBUG)

100

101

102 _ZoneIdxT = TypeVar("_ZoneIdxT", int, str)

103

104

105 class Qos:

106 """The QoS class - this is a mess - it is the first step in cleaning up QoS."""

107

108 # TODO: this needs work

109

110 POLL_INTERVAL = 0.002

111

112 TX_PRIORITY_DEFAULT = Priority.DEFAULT

113

114 # tx (from sent to gwy, to get back from gwy) seems to takes approx. 0.025s

115 TX_RETRIES_DEFAULT = 2

116 TX_RETRIES_MAX = 5

117 TX_TIMEOUT_DEFAULT = td(seconds=0.2) # 0.20 OK, but too high?

118

119 RX_TIMEOUT_DEFAULT = td(seconds=0.50) # 0.20 seems OK, 0.10 too low sometimes

120

121 TX_BACKOFFS_MAX = 2 # i.e. tx_timeout 2 ** MAX_BACKOFF

122

123 QOS_KEYS = (SZ_PRIORITY, SZ_MAX_RETRIES, SZ_TIMEOUT)

124 # priority, max_retries, rx_timeout, backoff

125 DEFAULT_QOS = (Priority.DEFAULT, TX_RETRIES_DEFAULT, TX_TIMEOUT_DEFAULT, True)

126 DEFAULT_QOS_TABLE = {

127 f"{RQ}|{Code._0016}": (Priority.HIGH, 5, None, True),

128 f"{RQ}|{Code._0006}": (Priority.HIGH, 5, None, True),

129 f"{I_}|{Code._0404}": (Priority.HIGH, 3, td(seconds=0.30), True),

130 f"{RQ}|{Code._0404}": (Priority.HIGH, 3, td(seconds=1.00), True),

131 f"{W_}|{Code._0404}": (Priority.HIGH, 3, td(seconds=1.00), True),

132 f"{RQ}|{Code._0418}": (Priority.LOW, 3, None, None),

133 f"{RQ}|{Code._1F09}": (Priority.HIGH, 5, None, True),

134 f"{I_}|{Code._1FC9}": (Priority.HIGH, 2, td(seconds=1), False),

135 f"{RQ}|{Code._3220}": (Priority.DEFAULT, 1, td(seconds=1.2), False),

136 f"{W_}|{Code._3220}": (Priority.HIGH, 3, td(seconds=1.2), False),

137 } # The long timeout for the OTB is for total RTT to slave (boiler)

138

139 def __init__(

140 self,

141 *,

142 priority: Priority | None = None, # TODO: deprecate

143 max_retries: int | None = None, # TODO: deprecate

144 timeout: td | None = None, # TODO: deprecate

145 backoff: bool | None = None, # TODO: deprecate

146 ) -> None:

147 self.priority = self.DEFAULT_QOS[0] if priority is None else priority

148 self.retry_limit = self.DEFAULT_QOS[1] if max_retries is None else max_retries

149 self.tx_timeout = self.TX_TIMEOUT_DEFAULT

150 self.rx_timeout = self.DEFAULT_QOS[2] if timeout is None else timeout

151 self.disable_backoff = not (self.DEFAULT_QOS[3] if backoff is None else backoff)

152

153 self.retry_limit = min(self.retry_limit, Qos.TX_RETRIES_MAX)

154

155 @classmethod # constructor from verb|code pair

156 def verb_code(cls, verb: VerbT, code: str | Code, **kwargs: Any) -> Qos:

157 """Constructor to create a QoS based upon the defaults for a verb|code pair."""

158

159 default_qos = cls.DEFAULT_QOS_TABLE.get(f"{verb}|{code}", cls.DEFAULT_QOS)

160 return cls(

161 **{k: kwargs.get(k, default_qos[i]) for i, k in enumerate(cls.QOS_KEYS)}

162 )

163

164

165 def _check_idx(zone_idx: int | str) -> str:

166 """Validate and normalize a zone index or DHW index.

167

168 This helper function validates that a zone index is within the valid range

169 and converts it to a consistent string format.

170

171 :param zone_idx: The zone index to validate. Can be:

172 - int: 0-15 for zones, 0xFA for DHW

173 - str: String representation of the index (hex or 'HW' for DHW)

174 :type zone_idx: int | str

175 :return: The normalized zone index as a 2-character hex string

176 :rtype: str

177 :raises CommandInvalid: If the zone index is invalid

178

179 .. note::

180 - For DHW (Domestic Hot Water), use 0xFA or 'HW'

181 - For zones, use 0-15 (or '00'-'0F' as hex strings)

182 """

183 # if zone_idx is None:

184 # return "00"

185 if not isinstance(zone_idx, int | str):

186 raise exc.CommandInvalid(f"Invalid value for zone_idx: {zone_idx}")

187 if isinstance(zone_idx, str):

188 zone_idx = FA if zone_idx == "HW" else zone_idx

189 result: int = zone_idx if isinstance(zone_idx, int) else int(zone_idx, 16)

190 if 0 > result > 15 and result != 0xFA:

191 raise exc.CommandInvalid(f"Invalid value for zone_idx: {result}")

192 return f"{result:02X}"

193

194

195 def _normalise_mode(

196 mode: int | str | None,

197 target: bool | float | None,

198 until: dt | str | None,

199 duration: int | None,

200) -> str:

201 """Validate and normalize a heating mode for zone or DHW control.

202

203 This helper function ensures the operating mode is valid and consistent

204 with the provided target and timing parameters.

205

206 :param mode: The operating mode. Can be:

207 - None: Auto-determined from other parameters

208 - int/str: Mode code (see ZON_MODE_MAP for valid values)

209 :type mode: int | str | None

210 :param target: The target value for the mode:

211 - For zone modes: The temperature setpoint

212 - For DHW modes: Active state (True/False)

213 :type target: bool | float | None

214 :param until: The end time for temporary modes

215 :type until: datetime | str | None

216 :param duration: The duration in minutes for countdown modes

217 :type duration: int | None

218 :return: Normalized 2-character hex mode string

219 :rtype: str

220 :raises CommandInvalid: If the parameters are inconsistent or invalid

221

222 .. note::

223 - If mode is None, it will be determined based on other parameters:

224 - If until is set: TEMPORARY mode

225 - If duration is set: COUNTDOWN mode

226 - Otherwise: PERMANENT mode

227 - The target parameter must be provided for all modes except FOLLOW

228 """

229

230 if mode is None and target is None:

231 raise exc.CommandInvalid(

232 "Invalid args: One of mode or setpoint/active can't be None"

233 )

234 if until and duration:

235 raise exc.CommandInvalid(

236 "Invalid args: At least one of until or duration must be None"

237 )

238

239 if mode is None:

240 if until:

241 mode = ZON_MODE_MAP.TEMPORARY

242 elif duration:

243 mode = ZON_MODE_MAP.COUNTDOWN

244 else:

245 mode = ZON_MODE_MAP.PERMANENT # TODO: advanced_override?

246 elif isinstance(mode, int):

247 mode = f"{mode:02X}"

248 if mode not in ZON_MODE_MAP:

249 mode = ZON_MODE_MAP._hex(mode) # type: ignore[arg-type] # may raise KeyError

250

251 assert isinstance(mode, str) # mypy check

252

253 if mode != ZON_MODE_MAP.FOLLOW and target is None:

254 raise exc.CommandInvalid(

255 f"Invalid args: For {ZON_MODE_MAP[mode]}, setpoint/active can't be None"

256 )

257

258 return mode

259

260

261 def _normalise_until(

262 mode: int | str | None,

263 _: Any,

264 until: dt | str | None,

265 duration: int | None,

266) -> tuple[Any, Any]:

267 """Validate and normalize timing parameters for zone/DHW mode changes.

268

269 This helper function ensures that the timing parameters (until/duration)

270 are consistent with the specified mode.

271

272 :param mode: The operating mode (from ZON_MODE_MAP)

273 :type mode: int | str | None

274 :param _: Unused parameter (kept for compatibility with call signatures)

275 :type _: Any

276 :param until: The end time for temporary modes

277 :type until: datetime | str | None

278 :param duration: The duration in minutes for countdown modes

279 :type duration: int | None

280 :return: A tuple of (until, duration) with validated values

281 :rtype: tuple[Any, Any]

282 :raises CommandInvalid: If the timing parameters are inconsistent with the mode

283

284 .. note::

285 - For TEMPORARY mode: 'until' must be provided, 'duration' must be None

286 - For COUNTDOWN mode: 'duration' must be provided, 'until' must be None

287 - For other modes: Both 'until' and 'duration' must be None

288 - If mode is TEMPORARY and until is None, it will be changed to ADVANCED mode

289 """

290 if mode == ZON_MODE_MAP.TEMPORARY:

291 if duration is not None:

292 raise exc.CommandInvalid(

293 f"Invalid args: For mode={mode}, duration must be None"

294 )

295 if until is None:

296 mode = ZON_MODE_MAP.ADVANCED # or: until = dt.now() + td(hour=1)

297

298 elif mode in ZON_MODE_MAP.COUNTDOWN:

299 if duration is None:

300 raise exc.CommandInvalid(

301 f"Invalid args: For mode={mode}, duration can't be None"

302 )

303 if until is not None:

304 raise exc.CommandInvalid(

305 f"Invalid args: For mode={mode}, until must be None"

306 )

307

308 elif until is not None or duration is not None:

309 raise exc.CommandInvalid(

310 f"Invalid args: For mode={mode}, until and duration must both be None"

311 )

312

313 return until, duration # TODO return updated mode for ZON_MODE_MAP.TEMPORARY ?

314

315

316 class Command(Frame):

317 """The Command class (packets to be transmitted).

318

319 They have QoS and/or callbacks (but no RSSI).

320 """

321

322 def __init__(self, frame: str) -> None:

323 """Create a command from a string (and its meta-attrs)."""

324

325 try:

326 super().__init__(frame)

327 except exc.PacketInvalid as err:

328 raise exc.CommandInvalid(err.message) from err

329

330 try:

331 self._validate(strict_checking=False)

332 except exc.PacketInvalid as err:

333 raise exc.CommandInvalid(err.message) from err

334

335 try:

336 self._validate(strict_checking=True)

337 except exc.PacketInvalid as err:

338 _LOGGER.warning(f"{self} < Command is potentially invalid: {err}")

339

340 self._rx_header: str | None = None

341 # self._source_entity: Entity | None = None # TODO: is needed?

342

343 @classmethod # convenience constructor

344 def from_attrs(

345 cls,

346 verb: VerbT,

347 dest_id: DeviceIdT | str,

348 code: Code,

349 payload: PayloadT,

350 *,

351 from_id: DeviceIdT | str | None = None,

352 seqn: int | str | None = None,

353 ) -> Command:

354 """Create a command from its attrs using a destination device_id."""

355

356 from_id = from_id or HGI_DEV_ADDR.id

357

358 addrs: tuple[DeviceIdT | str, DeviceIdT | str, DeviceIdT | str]

359

360 # if dest_id == NUL_DEV_ADDR.id:

361 # addrs = (from_id, dest_id, NON_DEV_ADDR.id)

362 if dest_id == from_id:

363 addrs = (from_id, NON_DEV_ADDR.id, dest_id)

364 else:

365 addrs = (from_id, dest_id, NON_DEV_ADDR.id)

366

367 return cls._from_attrs(

368 verb,

369 code,

370 payload,

371 addr0=addrs[0],

372 addr1=addrs[1],

373 addr2=addrs[2],

374 seqn=seqn,

375 )

376

377 @classmethod # generic constructor

378 def _from_attrs(

379 cls,

380 verb: str | VerbT,

381 code: str | Code,

382 payload: PayloadT,

383 *,

384 addr0: DeviceIdT | str | None = None,

385 addr1: DeviceIdT | str | None = None,

386 addr2: DeviceIdT | str | None = None,

387 seqn: int | str | None = None,

388 ) -> Command:

389 """Create a command from its attrs using an address set."""

390

391 verb = I_ if verb == "I" else W_ if verb == "W" else verb

392

393 addr0 = addr0 or NON_DEV_ADDR.id

394 addr1 = addr1 or NON_DEV_ADDR.id

395 addr2 = addr2 or NON_DEV_ADDR.id

396

397 _, _, *addrs = pkt_addrs(" ".join((addr0, addr1, addr2)))

398 # print(pkt_addrs(" ".join((addr0, addr1, addr2))))

399

400 if seqn is None or seqn in ("", "-", "--", "---"):

401 seqn = "---"

402 elif isinstance(seqn, int):

403 seqn = f"{int(seqn):03d}"

404

405 frame = " ".join(

406 (

407 verb,

408 seqn,

409 *(a.id for a in addrs),

410 code,

411 f"{int(len(payload) / 2):03d}",

412 payload,

413 )

414 )

415

416 return cls(frame)

417

418 @classmethod # used by CLI for -x switch (NB: no len field)

419 def from_cli(cls, cmd_str: str) -> Command:

420 """Create a command from a CLI string (the -x switch).

421

422 Examples include (whitespace for readability):

423 'RQ 01:123456 1F09 00'

424 'RQ 01:123456 13:123456 3EF0 00'

425 'RQ 07:045960 01:054173 10A0 00137400031C'

426 ' W 123 30:045960 -:- 32:054173 22F1 001374'

427 """

428

429 parts = cmd_str.upper().split()

430 if len(parts) < 4:

431 raise exc.CommandInvalid(

432 f"Command string is not parseable: '{cmd_str}'"

433 ", format is: verb [seqn] addr0 [addr1 [addr2]] code payload"

434 )

435

436 verb = parts.pop(0)

437 seqn = "---" if DEVICE_ID_REGEX.ANY.match(parts[0]) else parts.pop(0)

438 payload = parts.pop()[:48]

439 code = parts.pop()

440

441 addrs: tuple[DeviceIdT | str, DeviceIdT | str, DeviceIdT | str]

442

443 if not 0 < len(parts) < 4:

444 raise exc.CommandInvalid(f"Command is invalid: '{cmd_str}'")

445 elif len(parts) == 1 and verb == I_:

446 # drs = (cmd[0], NON_DEV_ADDR.id, cmd[0])

447 addrs = (NON_DEV_ADDR.id, NON_DEV_ADDR.id, parts[0])

448 elif len(parts) == 1:

449 addrs = (HGI_DEV_ADDR.id, parts[0], NON_DEV_ADDR.id)

450 elif len(parts) == 2 and parts[0] == parts[1]:

451 addrs = (parts[0], NON_DEV_ADDR.id, parts[1])

452 elif len(parts) == 2:

453 addrs = (parts[0], parts[1], NON_DEV_ADDR.id)

454 else:

455 addrs = (parts[0], parts[1], parts[2])

456

457 return cls._from_attrs(

458 verb,

459 code,

460 payload,

461 **{f"addr{k}": v for k, v in enumerate(addrs)},

462 seqn=seqn,

463 )

464

465 def __repr__(self) -> str:

466 """Return an unambiguous string representation of this object."""

467 # e.g.: RQ --- 18:000730 01:145038 --:------ 000A 002 0800 # 000A|RQ|01:145038|08

468 comment = f" # {self._hdr}{f' ({self._ctx})' if self._ctx else ''}"

469 return f"... {self}{comment}"

470

471 def __str__(self) -> str:

472 """Return a brief readable string representation of this object."""

473 # e.g.: 000A|RQ|01:145038|08

474 return super().__repr__() # TODO: self._hdr

475

476 @property

477 def tx_header(self) -> HeaderT:

478 """Return the QoS header of this (request) packet."""

479

480 return self._hdr

481

482 @property

483 def rx_header(self) -> HeaderT | None:

484 """Return the QoS header of a corresponding response packet (if any)."""

485

486 if self.tx_header and self._rx_header is None:

487 self._rx_header = pkt_header(self, rx_header=True)

488 return self._rx_header

489

490 @classmethod # constructor for I|0002 # TODO: trap corrupt temps?

491 def put_weather_temp(cls, dev_id: DeviceIdT | str, temperature: float) -> Command:

492 """Constructor to announce the current temperature of a weather sensor (0002).

493

494 This is for use by a faked HB85 or similar.

495 """

496

497 if dev_id[:2] != DEV_TYPE_MAP.OUT:

498 raise exc.CommandInvalid(

499 f"Faked device {dev_id} has an unsupported device type: "

500 f"device_id should be like {DEV_TYPE_MAP.OUT}:xxxxxx"

501 )

502

503 payload = f"00{hex_from_temp(temperature)}01"

504 return cls._from_attrs(I_, Code._0002, payload, addr0=dev_id, addr2=dev_id)

505

506 @classmethod # constructor for RQ|0004

507 def get_zone_name(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:

508 """Get the name of a zone. (c.f. parser_0004)

509

510 This method constructs a command to request the name of a specific zone

511 from the controller.

512

513 :param ctl_id: The device ID of the controller

514 :type ctl_id: DeviceIdT | str

515 :param zone_idx: The index of the zone (00-31)

516 :type zone_idx: _ZoneIdxT

517 :return: A Command object for the RQ|0004 message

518 :rtype: Command

519

520 .. note::

521 The zone name is typically a user-assigned identifier for the zone,

522 such as "Living Room" or "Bedroom 1".

523 """

524 return cls.from_attrs(RQ, ctl_id, Code._0004, f"{_check_idx(zone_idx)}00")

525

526 @classmethod # constructor for W|0004

527 def set_zone_name(

528 cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT, name: str

529 ) -> Command:

530 """Set the name of a zone. (c.f. parser_0004)

531

532 This method constructs a command to set the name of a specific zone

533 on the controller. The name will be truncated to 20 characters (40 hex digits).

534

535 :param ctl_id: The device ID of the controller

536 :type ctl_id: DeviceIdT | str

537 :param zone_idx: The index of the zone (00-31)

538 :type zone_idx: _ZoneIdxT

539 :param name: The new name for the zone (max 20 characters)

540 :type name: str

541 :return: A Command object for the W|0004 message

542 :rtype: Command

543

544 .. note::

545 The name will be converted to uppercase and non-ASCII characters

546 will be replaced with '?'. The name is limited to 20 characters.

547 """

548 payload = f"{_check_idx(zone_idx)}00{hex_from_str(name)[:40]:0<40}"

549 return cls.from_attrs(W_, ctl_id, Code._0004, payload)

550

551 @classmethod # constructor for RQ|0006

552 def get_schedule_version(cls, ctl_id: DeviceIdT | str) -> Command:

553 """Get the current version (change counter) of the schedules.

554

555 This method retrieves a version number that is incremented whenever any zone's

556 schedule (including the DHW zone) is modified. This allows clients to efficiently

557 check if schedules have changed before downloading them.

558

559 :param ctl_id: The device ID of the controller

560 :type ctl_id: DeviceIdT | str

561 :return: A Command object for the RQ|0006 message

562 :rtype: Command

563

564 .. note::

565 The version number is a simple counter that increments with each schedule

566 change. It has no inherent meaning beyond indicating that a change has

567 occurred. The actual value should be compared with a previously stored

568 version to detect changes.

569 """

570 return cls.from_attrs(RQ, ctl_id, Code._0006, "00")

571

572 @classmethod # constructor for RQ|0008

573 def get_relay_demand(

574 cls, dev_id: DeviceIdT | str, zone_idx: _ZoneIdxT | None = None

575 ) -> Command:

576 """Get the current demand value for a relay or zone. (c.f. parser_0008)

577

578 This method constructs a command to request the current demand value for a

579 specific relay or zone. The demand value typically represents the requested

580 output level (0-100%) for the relay or zone.

581

582 :param dev_id: The device ID of the relay or controller

583 :type dev_id: DeviceIdT | str

584 :param zone_idx: The index of the zone (00-31), or None for the relay itself

585 :type zone_idx: _ZoneIdxT | None

586 :return: A Command object for the RQ|0008 message

587 :rtype: Command

588

589 .. note::

590 - If zone_idx is None, the command requests the relay's overall demand.

591 - If zone_idx is specified, the command requests the demand for that specific zone.

592 - The response will contain the current demand value as a percentage (0-100%).

593 """

594 payload = "00" if zone_idx is None else _check_idx(zone_idx)

595 return cls.from_attrs(RQ, dev_id, Code._0008, payload)

596

597 @classmethod # constructor for RQ|000A

598 def get_zone_config(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:

599 """Get the configuration of a specific zone. (c.f. parser_000a)

600

601 This method constructs a command to request the configuration parameters

602 for a specific zone from the controller. The configuration includes

603 settings related to the zone's operation, such as temperature setpoints,

604 mode, and other zone-specific parameters.

605

606 :param ctl_id: The device ID of the controller

607 :type ctl_id: DeviceIdT | str

608 :param zone_idx: The index of the zone (00-31)

609 :type zone_idx: _ZoneIdxT

610 :return: A Command object for the RQ|000A message

611 :rtype: Command

612

613 .. note::

614 The response to this command will include various configuration parameters

615 for the specified zone, such as:

616 - Zone type (radiator, underfloor heating, etc.)

617 - Temperature setpoints

618 - Mode (heating/cooling)

619 - Other zone-specific settings

620 """

621 zon_idx = _check_idx(zone_idx)

622 return cls.from_attrs(RQ, ctl_id, Code._000A, zon_idx)

623

624 @classmethod # constructor for W|000A

625 def set_zone_config(

626 cls,

627 ctl_id: DeviceIdT | str,

628 zone_idx: _ZoneIdxT,

629 *,

630 min_temp: float = 5,

631 max_temp: float = 35,

632 local_override: bool = False,

633 openwindow_function: bool = False,

634 multiroom_mode: bool = False,

635 ) -> Command:

636 """Set the configuration parameters for a specific zone. (c.f. parser_000a)

637

638 This method constructs a command to configure various parameters for a zone,

639 including temperature limits and operational modes.

640

641 :param ctl_id: The device ID of the controller

642 :type ctl_id: DeviceIdT | str

643 :param zone_idx: The index of the zone (00-31)

644 :type zone_idx: _ZoneIdxT

645 :param min_temp: Minimum allowed temperature for the zone (5-21°C)

646 :type min_temp: float

647 :param max_temp: Maximum allowed temperature for the zone (21-35°C)

648 :type max_temp: float

649 :param local_override: If True, allows local temperature override at the device

650 :type local_override: bool

651 :param openwindow_function: If True, enables open window detection function

652 :type openwindow_function: bool

653 :param multiroom_mode: If True, enables multi-room mode for this zone

654 :type multiroom_mode: bool

655 :return: A Command object for the W|000A message

656 :rtype: Command

657 :raises CommandInvalid: If any parameter is out of range or of incorrect type

658

659 .. note::

660 - The minimum temperature must be between 5°C and 21°C

661 - The maximum temperature must be between 21°C and 35°C

662 - The minimum temperature cannot be higher than the maximum temperature

663 - These settings affect how the zone behaves in different operating modes

664 """

665 zon_idx = _check_idx(zone_idx)

666

667 if not (5 <= min_temp <= 21):

668 raise exc.CommandInvalid(f"Out of range, min_temp: {min_temp}")

669 if not (21 <= max_temp <= 35):

670 raise exc.CommandInvalid(f"Out of range, max_temp: {max_temp}")

671 if not isinstance(local_override, bool):

672 raise exc.CommandInvalid(f"Invalid arg, local_override: {local_override}")

673 if not isinstance(openwindow_function, bool):

674 raise exc.CommandInvalid(

675 f"Invalid arg, openwindow_function: {openwindow_function}"

676 )

677 if not isinstance(multiroom_mode, bool):

678 raise exc.CommandInvalid(f"Invalid arg, multiroom_mode: {multiroom_mode}")

679

680 bitmap = 0 if local_override else 1

681 bitmap |= 0 if openwindow_function else 2

682 bitmap |= 0 if multiroom_mode else 16

683

684 payload = "".join(

685 (zon_idx, f"{bitmap:02X}", hex_from_temp(min_temp), hex_from_temp(max_temp))

686 )

687

688 return cls.from_attrs(W_, ctl_id, Code._000A, payload)

689

690 @classmethod # constructor for RQ|0100

691 def get_system_language(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:

692 """Get the configured language of the system. (c.f. parser_0100)

693

694 This method constructs a command to request the current language setting

695 from the system controller.

696

697 :param ctl_id: The device ID of the controller

698 :type ctl_id: DeviceIdT | str

699 :param kwargs: Additional keyword arguments (not used, for compatibility only)

700 :return: A Command object for the RQ|0100 message

701 :rtype: Command

702

703 .. note::

704 The response will contain a language code that corresponds to the

705 system's configured language setting.

706 """

707 assert not kwargs, kwargs

708 return cls.from_attrs(RQ, ctl_id, Code._0100, "00", **kwargs)

709

710 @classmethod # constructor for RQ|0404

711 def get_schedule_fragment(

712 cls,

713 ctl_id: DeviceIdT | str,

714 zone_idx: _ZoneIdxT,

715 frag_number: int,

716 total_frags: int | None,

717 **kwargs: Any,

718 ) -> Command:

719 """Get a specific fragment of a schedule. (c.f. parser_0404)

720

721 This method constructs a command to request a specific fragment of a schedule

722 from the controller. Schedules are typically broken into multiple fragments

723 for efficient transmission.

724

725 :param ctl_id: The device ID of the controller

726 :type ctl_id: DeviceIdT | str

727 :param zone_idx: The index of the zone (00-31), or 0xFA/'FA'/'HW' for DHW schedule

728 :type zone_idx: _ZoneIdxT

729 :param frag_number: The fragment number to retrieve (0-based)

730 :type frag_number: int

731 :param total_frags: Total number of fragments (optional)

732 :type total_frags: int | None

733 :param kwargs: Additional keyword arguments

734 :return: A Command object for the RQ|0404 message

735 :rtype: Command

736

737 .. note::

738 - For zone schedules, use a zone index between 00-31

739 - For DHW (Domestic Hot Water) schedule, use 0xFA, 'FA', or 'HW' as zone_idx

740 - The schedule is typically retrieved in multiple fragments to handle

741 the potentially large amount of data

742 """

743

744 assert not kwargs, kwargs

745 zon_idx = _check_idx(zone_idx)

746

747 if total_frags is None:

748 total_frags = 0

749

750 kwargs.pop("frag_length", None) # for pytests?

751 frag_length = "00"

752

753 # TODO: check the following rules

754 if frag_number == 0:

755 raise exc.CommandInvalid(f"frag_number={frag_number}, but it is 1-indexed")

756 elif frag_number == 1 and total_frags != 0:

757 raise exc.CommandInvalid(

758 f"total_frags={total_frags}, but must be 0 when frag_number=1"

759 )

760 elif frag_number > total_frags and total_frags != 0:

761 raise exc.CommandInvalid(

762 f"frag_number={frag_number}, but must be <= total_frags={total_frags}"

763 )

764

765 header = "00230008" if zon_idx == FA else f"{zon_idx}200008"

766

767 payload = f"{header}{frag_length}{frag_number:02X}{total_frags:02X}"

768 return cls.from_attrs(RQ, ctl_id, Code._0404, payload, **kwargs)

769

770 @classmethod # constructor for W|0404

771 def set_schedule_fragment(

772 cls,

773 ctl_id: DeviceIdT | str,

774 zone_idx: _ZoneIdxT,

775 frag_num: int,

776 frag_cnt: int,

777 fragment: str,

778 ) -> Command:

779 """Set a specific fragment of a schedule. (c.f. parser_0404)

780

781 This method constructs a command to set a specific fragment of a schedule

782 on the controller. Schedules are typically set in multiple fragments

783 due to their potentially large size.

784

785 :param ctl_id: The device ID of the controller

786 :type ctl_id: DeviceIdT | str

787 :param zone_idx: The index of the zone (00-31), or 0xFA/'FA'/'HW' for DHW schedule

788 :type zone_idx: _ZoneIdxT

789 :param frag_num: The fragment number being set (1-based index)

790 :type frag_num: int

791 :param frag_cnt: Total number of fragments in the schedule

792 :type frag_cnt: int

793 :param fragment: The schedule fragment data as a hex string

794 :type fragment: str

795 :return: A Command object for the W|0404 message

796 :rtype: Command

797 :raises CommandInvalid: If fragment number is invalid or out of range

798

799 .. note::

800 - For zone schedules, use a zone index between 00-31

801 - For DHW (Domestic Hot Water) schedule, use 0xFA, 'FA', or 'HW' as zone_idx

802 - The first fragment (frag_num=1) typically contains schedule metadata

803 - Fragment numbers are 1-based (1 to frag_cnt)

804 """

805

806 zon_idx = _check_idx(zone_idx)

807

808 # TODO: check the following rules

809 if frag_num == 0:

810 raise exc.CommandInvalid(f"frag_num={frag_num}, but it is 1-indexed")

811 elif frag_num > frag_cnt:

812 raise exc.CommandInvalid(

813 f"frag_num={frag_num}, but must be <= frag_cnt={frag_cnt}"

814 )

815

816 header = "00230008" if zon_idx == FA else f"{zon_idx}200008"

817 frag_length = int(len(fragment) / 2)

818

819 payload = f"{header}{frag_length:02X}{frag_num:02X}{frag_cnt:02X}{fragment}"

820 return cls.from_attrs(W_, ctl_id, Code._0404, payload)

821

822 @classmethod # constructor for RQ|0418

823 def get_system_log_entry(

824 cls, ctl_id: DeviceIdT | str, log_idx: int | str

825 ) -> Command:

826 """Retrieve a specific log entry from the system log. (c.f. parser_0418)

827

828 This method constructs a command to request a specific log entry from the

829 system's event log. The log contains historical events and fault records.

830

831 :param ctl_id: The device ID of the controller

832 :type ctl_id: DeviceIdT | str

833 :param log_idx: The index of the log entry to retrieve (0-based)

834 :type log_idx: int | str (hex string)

835 :return: A Command object for the RQ|0418 message

836 :rtype: Command

837

838 .. note::

839 - The log index is 0-based, where 0 is the most recent entry

840 - The log typically contains system events, faults, and warnings

841 - The response will include details about the log entry

842 """

843 log_idx = log_idx if isinstance(log_idx, int) else int(log_idx, 16)

844 return cls.from_attrs(RQ, ctl_id, Code._0418, f"{log_idx:06X}")

845

846 @classmethod # constructor for I|0418 (used for testing only)

847 def _put_system_log_entry(

848 cls,

849 ctl_id: DeviceIdT | str,

850 fault_state: FaultState | str,

851 fault_type: FaultType | str,

852 device_class: FaultDeviceClass | str,

853 device_id: DeviceIdT | str | None = None,

854 domain_idx: int | str = "00",

855 _log_idx: int | str | None = None,

856 timestamp: dt | str | None = None,

857 **kwargs: Any,

858 ) -> Command:

859 """Create a log entry in the system log. (c.f. parser_0418)

860

861 This internal method constructs a command to create a log entry in the system's

862 event log. It's primarily used for testing purposes to simulate log entries.

863

864 :param ctl_id: The device ID of the controller

865 :type ctl_id: DeviceIdT | str

866 :param fault_state: The state of the fault (e.g., 'on', 'off', 'unknown')

867 :type fault_state: FaultState | str

868 :param fault_type: The type of fault being logged

869 :type fault_type: FaultType | str

870 :param device_class: The class of device associated with the fault

871 :type device_class: FaultDeviceClass | str

872 :param device_id: The ID of the device associated with the fault (optional)

873 :type device_id: DeviceIdT | str | None

874 :param domain_idx: The domain index (default: '00')

875 :type domain_idx: int | str

876 :param _log_idx: The log index (for internal use, optional)

877 :type _log_idx: int | str | None

878 :param timestamp: The timestamp of the log entry (default: current time)

879 :type timestamp: dt | str | None

880 :param kwargs: Additional keyword arguments

881 :return: A Command object for the I|0418 message

882 :rtype: Command

883 :raises AssertionError: If device_class is invalid

884

885 .. note::

886 - This is an internal method primarily used for testing

887 - The log entry will appear in the system's event log

888 - The fault_state and fault_type should match the expected enums

889 - If timestamp is not provided, the current time will be used

890 """

891 if isinstance(device_class, FaultDeviceClass):

892 device_class = {v: k for k, v in FAULT_DEVICE_CLASS.items()}[device_class]

893 assert device_class in FAULT_DEVICE_CLASS

894

895 if isinstance(fault_state, FaultState):

896 fault_state = {v: k for k, v in FAULT_STATE.items()}[fault_state]

897 assert fault_state in FAULT_STATE

898

899 if isinstance(fault_type, FaultType):

900 fault_type = {v: k for k, v in FAULT_TYPE.items()}[fault_type]

901 assert fault_type in FAULT_TYPE

902

903 assert isinstance(domain_idx, str) and len(domain_idx) == 2

904

905 if _log_idx is None:

906 _log_idx = 0

907 if not isinstance(_log_idx, str):

908 _log_idx = f"{_log_idx:02X}"

909 assert 0 <= int(_log_idx, 16) <= 0x3F # TODO: is it 0x3E or 0x3F?

910

911 if timestamp is None:

912 timestamp = dt.now() #

913 timestamp = hex_from_dts(timestamp)

914

915 dev_id = dev_id_to_hex_id(device_id) if device_id else "000000" # type: ignore[arg-type]

916

917 payload = "".join(

918 (

919 "00",

920 fault_state,

921 _log_idx,

922 "B0",

923 fault_type,

924 domain_idx,

925 device_class,

926 "0000",

927 timestamp,

928 "FFFF7000",

929 dev_id,

930 )

931 )

932

933 return cls.from_attrs(I_, ctl_id, Code._0418, payload)

934

935 @classmethod # constructor for RQ|1030

936 def get_mix_valve_params(

937 cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT

938 ) -> Command:

939 """Retrieve the mixing valve parameters for a specific zone. (c.f. parser_1030)

940

941 This method constructs a command to request the current mixing valve parameters

942 for a specific zone from the controller. These parameters control how the

943 mixing valve operates for the specified zone.

944

945 :param ctl_id: The device ID of the controller

946 :type ctl_id: DeviceIdT | str

947 :param zone_idx: The index of the zone (00-31)

948 :type zone_idx: _ZoneIdxT

949 :return: A Command object for the RQ|1030 message

950 :rtype: Command

951

952 .. note::

953 - The mixing valve controls the temperature of the water in the heating circuit

954 by mixing hot water from the boiler with cooler return water

955 - The parameters include settings like the minimum and maximum flow temperatures

956 and the proportional band for the valve control

957 """

958 zon_idx = _check_idx(zone_idx)

959

960 return cls.from_attrs(RQ, ctl_id, Code._1030, zon_idx)

961

962 @classmethod # constructor for W|1030 - TODO: sort out kwargs for HVAC

963 def set_mix_valve_params(

964 cls,

965 ctl_id: DeviceIdT | str,

966 zone_idx: _ZoneIdxT,

967 *,

968 max_flow_setpoint: int = 55,

969 min_flow_setpoint: int = 15,

970 valve_run_time: int = 150,

971 pump_run_time: int = 15,

972 **kwargs: Any,

973 ) -> Command:

974 """Set the mixing valve parameters for a specific zone. (c.f. parser_1030)

975

976 This method constructs a command to configure the mixing valve parameters

977 for a specific zone. These parameters control how the mixing valve operates

978 to regulate the temperature of the water in the heating circuit.

979

980 :param ctl_id: The device ID of the controller

981 :type ctl_id: DeviceIdT | str

982 :param zone_idx: The index of the zone (00-31)

983 :type zone_idx: _ZoneIdxT

984 :param max_flow_setpoint: Maximum flow temperature setpoint in °C (0-99)

985 :type max_flow_setpoint: int

986 :param min_flow_setpoint: Minimum flow temperature setpoint in °C (0-50)

987 :type min_flow_setpoint: int

988 :param valve_run_time: Valve run time in seconds (0-240)

989 :type valve_run_time: int

990 :param pump_run_time: Pump overrun time in seconds after valve closes (0-99)

991 :type pump_run_time: int

992 :param kwargs: Additional keyword arguments (e.g., boolean_cc)

993 :return: A Command object for the W|1030 message

994 :rtype: Command

995 :raises CommandInvalid: If any parameter is out of valid range

996

997 .. note::

998 - The mixing valve controls the temperature by mixing hot water from the boiler

999 with cooler return water

1000 - The pump overrun time allows the pump to continue running after the valve

1001 closes to dissipate residual heat

1002 - The valve run time determines how long the valve takes to move between

1003 fully open and fully closed positions

1004 """

1005 boolean_cc = kwargs.pop("boolean_cc", 1)

1006 assert not kwargs, kwargs

1007

1008 zon_idx = _check_idx(zone_idx)

1009

1010 if not (0 <= max_flow_setpoint <= 99):

1011 raise exc.CommandInvalid(

1012 f"Out of range, max_flow_setpoint: {max_flow_setpoint}"

1013 )

1014 if not (0 <= min_flow_setpoint <= 50):

1015 raise exc.CommandInvalid(

1016 f"Out of range, min_flow_setpoint: {min_flow_setpoint}"

1017 )

1018 if not (0 <= valve_run_time <= 240):

1019 raise exc.CommandInvalid(f"Out of range, valve_run_time: {valve_run_time}")

1020 if not (0 <= pump_run_time <= 99):

1021 raise exc.CommandInvalid(f"Out of range, pump_run_time: {pump_run_time}")

1022

1023 payload = "".join(

1024 (

1025 zon_idx,

1026 f"C801{max_flow_setpoint:02X}",

1027 f"C901{min_flow_setpoint:02X}",

1028 f"CA01{valve_run_time:02X}",

1029 f"CB01{pump_run_time:02X}",

1030 f"CC01{boolean_cc:02X}",

1031 )

1032 )

1033

1034 return cls.from_attrs(W_, ctl_id, Code._1030, payload, **kwargs)

1035

1036 @classmethod # constructor for RQ|10A0

1037 def get_dhw_params(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:

1038 """Get the parameters of the Domestic Hot Water (DHW) system. (c.f. parser_10a0)

1039

1040 This method constructs a command to retrieve the current parameters

1041 of the DHW system, including setpoint, overrun, and differential settings.

1042

1043 :param ctl_id: The device ID of the controller

1044 :type ctl_id: DeviceIdT | str

1045 :param kwargs: Additional keyword arguments

1046 - dhw_idx: Index of the DHW circuit (0 or 1), defaults to 0

1047 - Other arguments will raise an exception

1048 :return: A Command object for the RQ|10A0 message

1049 :rtype: Command

1050 :raises AssertionError: If unexpected keyword arguments are provided

1051

1052 .. note::

1053 - Most systems only have one DHW circuit (index 0)

1054 - The response includes current setpoint, overrun, and differential values

1055 - The actual values are parsed by parser_10a0

1056 """

1057 dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)

1058 assert not kwargs, f"Unexpected arguments: {kwargs}"

1059

1060 return cls.from_attrs(RQ, ctl_id, Code._10A0, dhw_idx)

1061

1062 @classmethod # constructor for W|10A0

1063 def set_dhw_params(

1064 cls,

1065 ctl_id: DeviceIdT | str,

1066 *,

1067 setpoint: float | None = 50.0,

1068 overrun: int | None = 5,

1069 differential: float | None = 1,

1070 **kwargs: Any, # only expect "dhw_idx"

1071 ) -> Command:

1072 """Set the parameters of the Domestic Hot Water (DHW) system. (c.f. parser_10a0)

1073

1074 This method constructs a command to configure the parameters of the DHW system,

1075 including temperature setpoint, pump overrun time, and temperature differential.

1076

1077 :param ctl_id: The device ID of the controller

1078 :type ctl_id: DeviceIdT | str

1079 :param setpoint: Target temperature for DHW in °C (30.0-85.0), defaults to 50.0

1080 :type setpoint: float | None

1081 :param overrun: Pump overrun time in minutes (0-10), defaults to 5

1082 :type overrun: int | None

1083 :param differential: Temperature differential in °C (1.0-10.0), defaults to 1.0

1084 :type differential: float | None

1085 :param kwargs: Additional keyword arguments

1086 - dhw_idx: Index of the DHW circuit (0 or 1), defaults to 0

1087 :return: A Command object for the W|10A0 message

1088 :rtype: Command

1089 :raises CommandInvalid: If any parameter is out of valid range

1090 :raises AssertionError: If unexpected keyword arguments are provided

1091

1092 .. note::

1093 - The setpoint is the target temperature for the hot water

1094 - Overrun keeps the pump running after heating stops to dissipate residual heat

1095 - Differential prevents rapid cycling by requiring this much temperature drop

1096 before reheating

1097 - Most systems only have one DHW circuit (index 0)

1098 """

1099 # Defaults for newer evohome colour:

1100 # Defaults for older evohome colour: ?? (30-85) C, ? (0-10) min, ? (1-10) C

1101 # Defaults for evohome monochrome:

1102

1103 # 14:34:26.734 022 W --- 18:013393 01:145038 --:------ 10A0 006 000F6E050064

1104 # 14:34:26.751 073 I --- 01:145038 --:------ 01:145038 10A0 006 000F6E0003E8

1105 # 14:34:26.764 074 I --- 01:145038 18:013393 --:------ 10A0 006 000F6E0003E8

1106

1107 dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)

1108 assert not kwargs, f"Unexpected arguments: {kwargs}"

1109

1110 setpoint = 50.0 if setpoint is None else setpoint

1111 overrun = 5 if overrun is None else overrun

1112 differential = 1.0 if differential is None else differential

1113

1114 if not (30.0 <= setpoint <= 85.0):

1115 raise exc.CommandInvalid(f"Out of range, setpoint: {setpoint}")

1116 if not (0 <= overrun <= 10):

1117 raise exc.CommandInvalid(f"Out of range, overrun: {overrun}")

1118 if not (1 <= differential <= 10):

1119 raise exc.CommandInvalid(f"Out of range, differential: {differential}")

1120

1121 payload = f"{dhw_idx}{hex_from_temp(setpoint)}{overrun:02X}{hex_from_temp(differential)}"

1122

1123 return cls.from_attrs(W_, ctl_id, Code._10A0, payload)

1124

1125 @classmethod # constructor for RQ|1100

1126 def get_tpi_params(

1127 cls, dev_id: DeviceIdT | str, *, domain_id: int | str | None = None

1128 ) -> Command:

1129 """Get the Time Proportional and Integral (TPI) parameters of a system. (c.f. parser_1100)

1130

1131 This method constructs a command to retrieve the TPI parameters for a specific domain.

1132 TPI is a control algorithm used to maintain temperature by cycling the boiler on/off.

1133

1134 :param dev_id: The device ID of the controller or BDR91 relay

1135 :type dev_id: DeviceIdT | str

1136 :param domain_id: The domain ID to get parameters for, or None for default

1137 (00 for BDR devices, FC for controllers)

1138 :type domain_id: int | str | None

1139 :return: A Command object for the RQ|1100 message

1140 :rtype: Command

1141

1142 .. note::

1143 - TPI parameters control how the system maintains temperature by cycling the boiler

1144 - Different domains can have different TPI settings

1145 - The response will include cycle rate, minimum on/off times, and other parameters

1146 """

1147 if domain_id is None:

1148 domain_id = "00" if dev_id[:2] == DEV_TYPE_MAP.BDR else FC

1149

1150 return cls.from_attrs(RQ, dev_id, Code._1100, _check_idx(domain_id))

1151

1152 @classmethod # constructor for W|1100

1153 def set_tpi_params(

1154 cls,

1155 ctl_id: DeviceIdT | str,

1156 domain_id: int | str | None,

1157 *,

1158 cycle_rate: int = 3, # TODO: check

1159 min_on_time: int = 5, # TODO: check

1160 min_off_time: int = 5, # TODO: check

1161 proportional_band_width: float | None = None, # TODO: check

1162 ) -> Command:

1163 """Set the Time Proportional and Integral (TPI) parameters of a system. (c.f. parser_1100)

1164

1165 This method constructs a command to configure the TPI parameters for a specific domain.

1166 TPI is a control algorithm that maintains temperature by cycling the boiler on/off.

1167

1168 :param ctl_id: The device ID of the controller

1169 :type ctl_id: DeviceIdT | str

1170 :param domain_id: The domain ID to configure, or None for default domain (00)

1171 :type domain_id: int | str | None

1172 :param cycle_rate: Number of on/off cycles per hour (TODO: validate range, typically 3,6,9,12)

1173 :type cycle_rate: int

1174 :param min_on_time: Minimum time in minutes the boiler stays on (TODO: validate range, typically 1-5)

1175 :type min_on_time: int

1176 :param min_off_time: Minimum time in minutes the boiler stays off (TODO: validate range, typically 1-5)

1177 :type min_off_time: int

1178 :param proportional_band_width: Width of the proportional band in °C (TODO: validate range, typically 1.5-3.0)

1179 :type proportional_band_width: float | None

1180 :return: A Command object for the W|1100 message

1181 :rtype: Command

1182 :raises AssertionError: If any parameter is out of valid range

1183

1184 .. note::

1185 - TPI parameters control how the system maintains temperature by cycling the boiler

1186 - Different domains can have different TPI settings

1187 - The proportional band determines how much the temperature can vary before the

1188 boiler cycles on/off

1189 - The cycle rate affects how frequently the boiler cycles when maintaining temperature

1190 - Parameters are converted to appropriate hex values in the payload (e.g., minutes * 4)

1191 """

1192 if domain_id is None:

1193 domain_id = "00"

1194

1195 # TODO: Uncomment and fix these validations once ranges are confirmed

1196 # assert cycle_rate is None or cycle_rate in (3, 6, 9, 12), cycle_rate

1197 # assert min_on_time is None or 1 <= min_on_time <= 5, min_on_time

1198 # assert min_off_time is None or 1 <= min_off_time <= 5, min_off_time

1199 # assert (

1200 # proportional_band_width is None or 1.5 <= proportional_band_width <= 3.0

1201 # ), proportional_band_width

1202

1203 payload = "".join(

1204 (

1205 _check_idx(domain_id),

1206 f"{cycle_rate * 4:02X}", # Convert cycles/hour to internal format

1207 f"{int(min_on_time * 4):02X}", # Convert minutes to internal format

1208 f"{int(min_off_time * 4):02X}00", # Convert minutes to internal format (or: ...FF)

1209 f"{hex_from_temp(proportional_band_width)}01", # Convert temperature to hex

1210 )

1211 )

1212

1213 return cls.from_attrs(W_, ctl_id, Code._1100, payload)

1214

1215 @classmethod # constructor for RQ|1260

1216 def get_dhw_temp(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:

1217 """Get the current temperature from a Domestic Hot Water (DHW) sensor. (c.f. parser_10a0)

1218

1219 This method constructs a command to request the current temperature reading from

1220 a DHW temperature sensor. The sensor is typically located in the hot water tank.

1221

1222 :param ctl_id: The device ID of the controller

1223 :type ctl_id: DeviceIdT | str

1224 :param kwargs: Additional keyword arguments

1225 - dhw_idx: Index of the DHW sensor (0 or 1), defaults to 0

1226 - Other arguments will raise an exception

1227 :return: A Command object for the RQ|1260 message

1228 :rtype: Command

1229 :raises AssertionError: If unexpected keyword arguments are provided

1230

1231 .. note::

1232 - Most systems only have one DHW sensor (index 0)

1233 - The response will include the current temperature in degrees Celsius

1234 - The actual temperature is parsed by parser_10a0

1235 """

1236 dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)

1237 assert not kwargs, f"Unexpected arguments: {kwargs}"

1238

1239 return cls.from_attrs(RQ, ctl_id, Code._1260, dhw_idx)

1240

1241 @classmethod # constructor for I|1260 # TODO: trap corrupt temps?

1242 def put_dhw_temp(

1243 cls, dev_id: DeviceIdT | str, temperature: float | None, **kwargs: Any

1244 ) -> Command:

1245 """Announce the current temperature of a Domestic Hot Water (DHW) sensor. (1260)

1246

1247 This method constructs a command to announce/simulate a temperature reading from

1248 a DHW temperature sensor. This is primarily intended for use with simulated or

1249 emulated devices like a faked CS92A sensor.

1250

1251 :param dev_id: The device ID of the DHW sensor (must start with DHW type code)

1252 :type dev_id: DeviceIdT | str

1253 :param temperature: The temperature to report in °C, or None for no reading

1254 :type temperature: float | None

1255 :param kwargs: Additional keyword arguments

1256 - dhw_idx: Index of the DHW sensor (0 or 1), defaults to 0

1257 - Other arguments will raise an exception

1258 :return: A Command object for the I|1260 message

1259 :rtype: Command

1260 :raises CommandInvalid: If the device type is not a DHW sensor

1261 :raises AssertionError: If unexpected keyword arguments are provided

1262

1263 .. note::

1264 - This is typically used for testing or simulation purposes

1265 - The temperature is converted to the appropriate hex format

1266 - The device ID must be a valid DHW sensor type (starts with DHW code)

1267 - Most systems only have one DHW sensor (index 0)

1268 - The message is sent as an I-type (unsolicited) message

1269 """

1270 dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)

1271 assert not kwargs, f"Unexpected arguments: {kwargs}"

1272

1273 if dev_id[:2] != DEV_TYPE_MAP.DHW:

1274 raise exc.CommandInvalid(

1275 f"Faked device {dev_id} has an unsupported device type: "

1276 f"device_id should be like {DEV_TYPE_MAP.DHW}:xxxxxx"

1277 )

1278

1279 payload = f"{dhw_idx}{hex_from_temp(temperature)}"

1280 return cls._from_attrs(I_, Code._1260, payload, addr0=dev_id, addr2=dev_id)

1281

1282 @classmethod # constructor for I|1290 # TODO: trap corrupt temps?

1283 def put_outdoor_temp(

1284 cls, dev_id: DeviceIdT | str, temperature: float | None

1285 ) -> Command:

1286 """Announce the current outdoor temperature from a sensor. (1290)

1287

1288 This method constructs a command to announce/simulate an outdoor temperature reading.

1289 This is for use by a faked HVAC sensor, or similar.

1290

1291 :param dev_id: The device ID of the outdoor temperature sensor

1292 :type dev_id: DeviceIdT | str

1293 :param temperature: The temperature to report in °C, or None for no reading

1294 :type temperature: float | None

1295 :return: A Command object for the I|1290 message

1296 :rtype: Command

1297

1298 .. note::

1299 - This is typically used for testing or simulation purposes

1300 - The temperature is converted to the appropriate hex format

1301 - The message is sent as an I-type (unsolicited) message

1302 - The sensor index is hardcoded to 00 (most systems have only one outdoor sensor)

1303 - The device ID should match the expected format for an outdoor temperature sensor

1304 """

1305 payload = f"00{hex_from_temp(temperature)}"

1306 return cls._from_attrs(I_, Code._1290, payload, addr0=dev_id, addr2=dev_id)

1307

1308 @classmethod # constructor for I|1298

1309 def put_co2_level(cls, dev_id: DeviceIdT | str, co2_level: float | None) -> Command:

1310 """Announce the current CO₂ level from a sensor. (1298)

1311 .I --- 37:039266 --:------ 37:039266 1298 003 000316

1312

1313 This method constructs a command to announce/simulate a CO₂ level reading from

1314 an indoor air quality sensor. The message is typically sent by devices that

1315 monitor indoor air quality.

1316

1317 :param dev_id: The device ID of the CO₂ sensor

1318 :type dev_id: DeviceIdT | str

1319 :param co2_level: The CO₂ level to report in ppm (parts per million), or None for no reading

1320 :type co2_level: float | None

1321 :return: A Command object for the I|1298 message

1322 :rtype: Command

1323

1324 .. note::

1325 - This is typically used for testing or simulation purposes

1326 - The CO₂ level is converted to the appropriate hex format using double precision

1327 - The message is sent as an I-type (unsolicited) message

1328 - The sensor index is hardcoded to 00 (most systems have only one CO₂ sensor)

1329 - The device ID should match the expected format for a CO₂ sensor

1330 - Example message format: ``.I --- 37:039266 --:------ 37:039266 1298 003 000316``

1331 """

1332 payload = f"00{hex_from_double(co2_level)}"

1333 return cls._from_attrs(I_, Code._1298, payload, addr0=dev_id, addr2=dev_id)

1334

1335 @classmethod # constructor for I|12A0

1336 def put_indoor_humidity(

1337 cls, dev_id: DeviceIdT | str, indoor_humidity: float | None

1338 ) -> Command:

1339 """Announce the current indoor humidity from a sensor or fan. (12A0)

1340 .I --- 37:039266 --:------ 37:039266 1298 003 000316

1341

1342 This method constructs a command to announce/simulate an indoor humidity reading.

1343 The message is typically sent by devices that monitor indoor air quality,

1344 such as humidity sensors or ventilation systems with humidity sensing capabilities.

1345

1346 :param dev_id: The device ID of the humidity sensor or fan

1347 :type dev_id: DeviceIdT | str

1348 :param indoor_humidity: The relative humidity to report (0-100%), or None for no reading

1349 :type indoor_humidity: float | None

1350 :return: A Command object for the I|12A0 message

1351 :rtype: Command

1352

1353 .. note::

1354 - This is typically used for testing or simulation purposes

1355 - The humidity is converted to the appropriate hex format using standard precision

1356 - The message is sent as an I-type (unsolicited) message

1357 - The sensor index is hardcoded to 00 (most systems have only one humidity sensor)

1358 - The device ID should match the expected format for a humidity sensor or fan

1359 - The humidity value is expected to be in the range 0-100%

1360 - Example message format: ``.I --- 37:039266 --:------ 37:039266 12A0 003 0032`` (for 50%)

1361 """

1362 payload = "00" + hex_from_percent(indoor_humidity, high_res=False)

1363 return cls._from_attrs(I_, Code._12A0, payload, addr0=dev_id, addr2=dev_id)

1364

1365 @classmethod # constructor for RQ|12B0

1366 def get_zone_window_state(

1367 cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT

1368 ) -> Command:

1369 """Request the open window state of a zone (c.f. parser 12B0).

1370

1371 This method constructs a command to query whether a particular zone has an open window.

1372 The response will indicate if the window in the specified zone is open or closed.

1373

1374 :param ctl_id: The device ID of the controller managing the zone

1375 :type ctl_id: DeviceIdT | str

1376 :param zone_idx: The zone index (0-based) to query

1377 :type zone_idx: _ZoneIdxT

1378 :return: A Command object for the RQ|12B0 message

1379 :rtype: Command

1380

1381 .. note::

1382 - The zone index is 0-based (0 = Zone 1, 1 = Zone 2, etc.)

1383 - The controller will respond with a message indicating the window state

1384 - This is typically used by thermostats to enable/disable heating when windows are open

1385 - The actual window state detection is usually done by a separate sensor

1386 """

1387 return cls.from_attrs(RQ, ctl_id, Code._12B0, _check_idx(zone_idx))

1388

1389 @classmethod # constructor for RQ|1F41

1390 def get_dhw_mode(cls, ctl_id: DeviceIdT | str, **kwargs: Any) -> Command:

1391 """Request the current mode of the Domestic Hot Water (DHW) system. (c.f. parser 1F41)

1392

1393 This method constructs a command to query the operating mode of the DHW system.

1394 The response will indicate whether the DHW is in automatic, manual, or other modes.

1395

1396 :param ctl_id: The device ID of the DHW controller

1397 :type ctl_id: DeviceIdT | str

1398 :param kwargs: Additional parameters (currently only 'dhw_idx' is supported)

1399 :key dhw_idx: The DHW circuit index (0 or 1, defaults to 0 for single-DHW systems)

1400 :type dhw_idx: int, optional

1401 :return: A Command object for the RQ|1F41 message

1402 :rtype: Command

1403 :raises AssertionError: If unexpected keyword arguments are provided

1404

1405 .. note::

1406 - Most systems have a single DHW circuit (index 0)

1407 - The response will indicate the current DHW mode (e.g., auto, manual, off)

1408 - This is typically used by heating controllers to monitor DHW state

1409 - The actual mode values are defined in the response parser (parser_1f41)

1410 """

1411 dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)

1412 assert not kwargs, f"Unexpected arguments: {kwargs}"

1413

1414 return cls.from_attrs(RQ, ctl_id, Code._1F41, dhw_idx)

1415

1416 @classmethod # constructor for W|1F41

1417 def set_dhw_mode(

1418 cls,

1419 ctl_id: DeviceIdT | str,

1420 *,

1421 mode: int | str | None = None,

1422 active: bool | None = None,

1423 until: dt | str | None = None,

1424 duration: int | None = None, # never supplied by DhwZone.set_mode()

1425 **kwargs: Any,

1426 ) -> Command:

1427 """Set or reset the mode of the Domestic Hot Water (DHW) system. (c.f. parser 1F41)

1428

1429 This method constructs a command to change the operating mode of the DHW system.

1430 It can set the DHW to automatic, manual on/off, or scheduled modes with specific durations.

1431

1432 :param ctl_id: The device ID of the DHW controller

1433 :type ctl_id: DeviceIdT | str

1434 :param mode: The desired DHW mode (None, "auto", "heat", "off", or numeric values)

1435 :type mode: int | str | None

1436 :param active: If specified, sets the DHW on/off state (alternative to mode)

1437 :type active: bool | None

1438 :param until: End time for temporary mode (datetime or "YYYY-MM-DD HH:MM" string)

1439 :type until: datetime | str | None

1440 :param duration: Duration in seconds for temporary mode (alternative to 'until')

1441 :type duration: int | None

1442 :param kwargs: Additional parameters (currently only 'dhw_idx' is supported)

1443 :key dhw_idx: The DHW circuit index (0 or 1, defaults to 0 for single-DHW systems)

1444 :type dhw_idx: int, optional

1445 :return: A Command object for the W|1F41 message

1446 :rtype: Command

1447 :raises AssertionError: If unexpected keyword arguments are provided

1448 :raises CommandInvalid: If invalid parameters are provided

1449

1450 .. note::

1451 - Mode takes precedence over 'active' if both are specified

1452 - When using 'active' with 'until' or 'duration', the mode will be temporary

1453 - Supported mode values are defined in ZON_MODE_MAP

1454 - Most systems have a single DHW circuit (index 0)

1455 - The actual mode values are defined in the response parser (parser_1f41)

1456 """

1457 dhw_idx = _check_idx(kwargs.pop(SZ_DHW_IDX, 0)) # 00 or 01 (rare)

1458 assert not kwargs, f"Unexpected arguments: {kwargs}"

1459

1460 mode = _normalise_mode(mode, active, until, duration)

1461

1462 if mode == ZON_MODE_MAP.FOLLOW:

1463 active = None

1464 if active is not None and not isinstance(active, bool | int):

1465 raise exc.CommandInvalid(

1466 f"Invalid args: active={active}, but must be a bool"

1467 )

1468

1469 until, duration = _normalise_until(mode, active, until, duration)

1470

1471 payload = "".join(

1472 (

1473 dhw_idx,

1474 "FF" if active is None else "01" if bool(active) else "00",

1475 mode,

1476 "FFFFFF" if duration is None else f"{duration:06X}",

1477 "" if until is None else hex_from_dtm(until),

1478 )

1479 )

1480

1481 return cls.from_attrs(W_, ctl_id, Code._1F41, payload)

1482

1483 @classmethod # constructor for 1FC9 (rf_bind) 3-way handshake

1484 def put_bind(

1485 cls,

1486 verb: VerbT,

1487 src_id: DeviceIdT | str,

1488 codes: Code | Iterable[Code] | None,

1489 dst_id: DeviceIdT | str | None = None,

1490 **kwargs: Any,

1491 ) -> Command:

1492 """Create an RF bind command (1FC9) for device binding operations.

1493

1494 This method constructs commands used in the 3-way handshake process for binding

1495 devices in the Ramses RF protocol. It's primarily used by faked/test devices.

1496

1497 :param verb: The verb for the command (I, RQ, RP, W, etc.)

1498 :type verb: VerbT

1499 :param src_id: Source device ID initiating the bind

1500 :type src_id: DeviceIdT | str

1501 :param codes: Single code or list of codes to bind

1502 :type codes: Code | Iterable[Code] | None

1503 :param dst_id: Optional destination device ID (defaults to broadcast)

1504 :type dst_id: DeviceIdT | str | None

1505 :param kwargs: Additional parameters

1506 :key oem_code: OEM code for bind offers (only used with I-type messages)

1507 :type oem_code: str, optional

1508 :return: A Command object for the bind operation

1509 :rtype: Command

1510 :raises CommandInvalid: If invalid codes are provided for binding

1511

1512 .. note::

1513 - Common use cases include:

1514 - FAN binding to CO2 (1298), HUM (12A0), PER (2E10), or SWI (22F1, 22F3)

1515 - CTL binding to DHW (1260), RND/THM (30C9)

1516 - More complex bindings (e.g., TRV to CTL) may require custom constructors

1517 - The binding process typically involves a 3-way handshake

1518 - For I-type messages with no specific destination, this creates a bind offer

1519 """

1520 kodes: list[Code]

1521

1522 if not codes: # None, "", or []

1523 kodes = [] # used by confirm

1524 elif len(codes[0]) == len(Code._1FC9): # type: ignore[index] # if iterable: list, tuple, or dict.keys()

1525 kodes = list(codes) # type: ignore[arg-type]

1526 elif len(codes[0]) == len(Code._1FC9[0]): # type: ignore[index]

1527 kodes = [codes] # type: ignore[list-item]

1528 else:

1529 raise exc.CommandInvalid(f"Invalid codes for a bind command: {codes}")

1530

1531 if verb == I_ and dst_id in (None, src_id, ALL_DEV_ADDR.id):

1532 oem_code = kwargs.pop("oem_code", None)

1533 assert not kwargs, f"Unexpected arguments: {kwargs}"

1534 return cls._put_bind_offer(src_id, dst_id, kodes, oem_code=oem_code)

1535

1536 elif verb == W_ and dst_id not in (None, src_id):

1537 idx = kwargs.pop("idx", None)

1538 assert not kwargs, kwargs

1539 return cls._put_bind_accept(src_id, dst_id, kodes, idx=idx) # type: ignore[arg-type]

1540

1541 elif verb == I_:

1542 idx = kwargs.pop("idx", None)

1543 assert not kwargs, kwargs

1544 return cls._put_bind_confirm(src_id, dst_id, kodes, idx=idx) # type: ignore[arg-type]

1545

1546 raise exc.CommandInvalid(

1547 f"Invalid verb|dst_id for a bind command: {verb}|{dst_id}"

1548 )

1549

1550 @classmethod # constructor for 1FC9 (rf_bind) offer

1551 def _put_bind_offer(

1552 cls,

1553 src_id: DeviceIdT | str,

1554 dst_id: DeviceIdT | str | None,

1555 codes: list[Code],

1556 *,

1557 oem_code: str | None = None,

1558 ) -> Command:

1559 """Create a bind offer message (I-type) for device binding.

1560

1561 # TODO: should preserve order of codes, else tests may fail

1562

1563 This internal method constructs the initial bind offer message in the 3-way

1564 binding handshake. It's typically called by `put_bind()` and not used directly.

1565

1566 :param src_id: Source device ID making the offer

1567 :type src_id: DeviceIdT | str

1568 :param dst_id: Optional destination device ID (broadcast if None)

1569 :type dst_id: DeviceIdT | str | None

1570 :param codes: List of codes to include in the bind offer

1571 :type codes: list[Code]

1572 :param oem_code: Optional OEM-specific code for the binding

1573 :type oem_code: str | None

1574 :return: A Command object for the bind offer message

1575 :rtype: Command

1576 :raises CommandInvalid: If no valid codes are provided for the offer

1577

1578 .. note::

1579 - This creates an I-type (unsolicited) bind offer message

1580 - The message includes the source device's ID and the requested bind codes

1581 - OEM-specific bindings can include an additional OEM code

1582 - The actual binding codes are filtered to exclude 1FC9 and 10E0

1583 - The order of codes is preserved in the output message

1584 """

1585 # Filter out 1FC9 and 10E0 from the codes list

1586 kodes = [c for c in codes if c not in (Code._1FC9, Code._10E0)]

1587 if not kodes: # might be []

1588 raise exc.CommandInvalid(f"Invalid codes for a bind offer: {codes}")

1589

1590 hex_id = Address.convert_to_hex(src_id) # type: ignore[arg-type]

1591 payload = "".join(f"00{c}{hex_id}" for c in kodes)

1592

1593 if oem_code: # 01, 67, 6C

1594 payload += f"{oem_code}{Code._10E0}{hex_id}"

1595 payload += f"00{Code._1FC9}{hex_id}"

1596

1597 return cls.from_attrs( # NOTE: .from_attrs, not ._from_attrs

1598 I_, dst_id or src_id, Code._1FC9, payload, from_id=src_id

1599 ) # as dst_id could be NUL_DEV_ID

1600

1601 @classmethod # constructor for 1FC9 (rf_bind) accept - mainly used for test suite

1602 def _put_bind_accept(

1603 cls,

1604 src_id: DeviceIdT | str,

1605 dst_id: DeviceIdT | str,

1606 codes: list[Code],

1607 *,

1608 idx: str | None = "00",

1609 ) -> Command:

1610 """Create a bind accept message (W-type) for device binding.

1611

1612 This internal method constructs the bind accept message in the 3-way binding

1613 handshake. It's typically called by `put_bind()` and is mainly used for testing.

1614

1615 :param src_id: Source device ID accepting the bind

1616 :type src_id: DeviceIdT | str

1617 :param dst_id: Destination device ID that sent the bind offer

1618 :type dst_id: DeviceIdT | str

1619 :param codes: List of codes to include in the bind accept

1620 :type codes: list[Code]

1621 :param idx: Optional index for the binding (defaults to "00")

1622 :type idx: str | None

1623 :return: A Command object for the bind accept message

1624 :rtype: Command

1625 :raises CommandInvalid: If no valid codes are provided for the accept

1626

1627 .. note::

1628 - This creates a W-type (write) bind accept message

1629 - The message includes the source device's ID and the accepted bind codes

1630 - The index parameter allows for multiple bindings between the same devices

1631 - Primarily used in test suites to simulate device binding

1632 - The actual binding codes should match those in the original offer

1633 """

1634 if not codes: # might be empty list

1635 raise exc.CommandInvalid(f"Invalid codes for a bind accept: {codes}")

1636

1637 hex_id = Address.convert_to_hex(src_id) # type: ignore[arg-type]

1638 payload = "".join(f"{idx or '00'}{c}{hex_id}" for c in codes)

1639

1640 return cls.from_attrs(W_, dst_id, Code._1FC9, payload, from_id=src_id)

1641

1642 @classmethod # constructor for 1FC9 (rf_bind) confirm

1643 def _put_bind_confirm(

1644 cls,

1645 src_id: DeviceIdT | str,

1646 dst_id: DeviceIdT | str,

1647 codes: list[Code],

1648 *,

1649 idx: str | None = "00",

1650 ) -> Command:

1651 """Create a bind confirmation message (I-type) to complete device binding.

1652

1653 This internal method constructs the final confirmation message in the 3-way

1654 binding handshake. It's typically called by `put_bind()` to confirm that

1655 the binding process has been completed successfully.

1656

1657 :param src_id: Source device ID confirming the bind

1658 :type src_id: DeviceIdT | str

1659 :param dst_id: Destination device ID that needs confirmation

1660 :type dst_id: DeviceIdT | str

1661 :param codes: List of codes that were bound (only first code is used)

1662 :type codes: list[Code]

1663 :param idx: Optional index for the binding (defaults to "00")

1664 :type idx: str | None

1665 :return: A Command object for the bind confirmation message

1666 :rtype: Command

1667

1668 .. note::

1669 - This creates an I-type (unsolicited) bind confirmation message

1670 - The message includes the source device's ID and the first bound code

1671 - If no codes are provided, only the index is used as payload

1672 - The index is important (e.g., Nuaire 4-way switch uses "21")

1673 - This is the final step in the 3-way binding handshake

1674 - The binding is considered complete after this message is received

1675 """

1676 if not codes: # if not payload

1677 payload = idx or "00" # e.g. Nuaire 4-way switch uses 21!

1678 else:

1679 hex_id = Address.convert_to_hex(src_id) # type: ignore[arg-type]

1680 payload = f"{idx or '00'}{codes[0]}{hex_id}"

1681

1682 return cls.from_attrs(I_, dst_id, Code._1FC9, payload, from_id=src_id)

1683

1684 @classmethod # constructor for I|22F1

1685 def set_fan_mode(

1686 cls,

1687 fan_id: DeviceIdT | str,

1688 fan_mode: int | str | None,

1689 *,

1690 seqn: int | str | None = None,

1691 src_id: DeviceIdT | str | None = None,

1692 idx: str = "00", # could be e.g. "63"

1693 ) -> Command:

1694 """Set the operating mode of a ventilation fan.

1695

1696 This method constructs a command to control the speed and operating mode of a

1697 ventilation fan. The command can be sent with either a sequence number or a

1698 source device ID, depending on the system configuration.

1699

1700 There are two types of this packet observed:

1701 - With sequence number: ``I 018 --:------ --:------ 39:159057 22F1 003 000x04``

1702 - With source ID: ``I --- 21:039407 28:126495 --:------ 22F1 003 000x07``

1703

1704 :param fan_id: The device ID of the target fan (e.g., '39:159057')

1705 :type fan_id: DeviceIdT | str

1706 :param fan_mode: The desired fan mode, which can be specified as:

1707 - Integer: 0-9 for different speed levels

1708 - String: Descriptive mode like 'auto', 'low', 'medium', 'high'

1709 - None: Default mode (typically auto)

1710 :type fan_mode: int | str | None

1711 :param seqn: Optional sequence number (0-255), mutually exclusive with src_id

1712 :type seqn: int | str | None

1713 :param src_id: Optional source device ID, mutually exclusive with seqn

1714 :type src_id: DeviceIdT | str | None

1715 :param idx: Index identifier, typically '00' but can be other values like '63'

1716 :type idx: str

1717 :return: A configured Command object ready to be sent to the device.

1718 :rtype: Command

1719 :raises CommandInvalid: If both seqn and src_id are provided, or if fan_mode is invalid.

1720

1721 .. note::

1722 This command is typically sent as part of a triplet with 0.1s intervals

1723 when using sequence numbers. The sequence number should increase

1724 monotonically modulo 256 after each triplet.

1725

1726 **Scheme 1 (with sequence number):**

1727 - Sent as a triplet, 0.1s apart

1728 - Uses a sequence number (000-255)

1729 - Example: ``I 218 --:------ --:------ 39:159057 22F1 003 000204`` (low speed)

1730

1731 **Scheme 2 (with source ID):**

1732 - Sent as a triplet, 0.085s apart

1733 - Uses source device ID instead of sequence number

1734 - Example: ``I --- 21:039407 28:126495 --:------ 22F1 003 000507``

1735 """

1736 # NOTE: WIP: rate can be int or str

1737

1738 # Scheme 1: I 218 --:------ --:------ 39:159057

1739 # - are cast as a triplet, 0.1s apart?, with a seqn (000-255) and no src_id

1740 # - triplet has same seqn, increased monotonically mod 256 after every triplet

1741 # - only payloads seen: '(00|63)0[234]04', may accept '000.'

1742 # .I 218 --:------ --:------ 39:159057 22F1 003 000204 # low

1743

1744 # Scheme 1a: I --- --:------ --:------ 21:038634 (less common)

1745 # - some systems that accept scheme 2 will accept this scheme

1746

1747 # Scheme 2: I --- 21:038634 18:126620 --:------ (less common)

1748 # - are cast as a triplet, 0.085s apart, without a seqn (i.e. is ---)

1749 # - only payloads seen: '000[0-9A]0[5-7A]', may accept '000.'

1750 # .I --- 21:038634 18:126620 --:------ 22F1 003 000507

1751

1752 from .ramses import _22F1_MODE_ORCON

1753

1754 _22F1_MODE_ORCON_MAP = {v: k for k, v in _22F1_MODE_ORCON.items()}

1755

1756 if fan_mode is None:

1757 mode = "00"

1758 elif isinstance(fan_mode, int):

1759 mode = f"{fan_mode:02X}"

1760 else:

1761 mode = fan_mode

1762

1763 if mode in _22F1_MODE_ORCON:

1764 payload = f"{idx}{mode}"

1765 elif mode in _22F1_MODE_ORCON_MAP:

1766 payload = f"{idx}{_22F1_MODE_ORCON_MAP[mode]}"

1767 else:

1768 raise exc.CommandInvalid(f"fan_mode is not valid: {fan_mode}")

1769

1770 if src_id and seqn:

1771 raise exc.CommandInvalid(

1772 "seqn and src_id are mutually exclusive (you can have neither)"

1773 )

1774

1775 if seqn:

1776 return cls._from_attrs(I_, Code._22F1, payload, addr2=fan_id, seqn=seqn)

1777 return cls._from_attrs(I_, Code._22F1, payload, addr0=src_id, addr1=fan_id)

1778

1779 @classmethod # constructor for I|22F7

1780 def set_bypass_position(

1781 cls,

1782 fan_id: DeviceIdT | str,

1783 *,

1784 bypass_position: float | None = None,

1785 src_id: DeviceIdT | str | None = None,

1786 **kwargs: Any,

1787 ) -> Command:

1788 """Set the position or mode of a bypass valve in a ventilation system.

1789

1790 This method constructs a command to control the bypass valve position or mode

1791 for a ventilation system. The bypass valve regulates the flow of air between

1792 the supply and exhaust air streams, typically for heat recovery.

1793

1794 The method supports two ways to control the bypass:

1795 - Direct position control using `bypass_position` (0.0 to 1.0)

1796 - Predefined modes using `bypass_mode` ('auto', 'on', 'off')

1797

1798 :param fan_id: The device ID of the target fan/ventilation unit (e.g., '01:123456')

1799 :type fan_id: DeviceIdT | str

1800 :param bypass_position: The desired position as a float between 0.0 (fully closed)

1801 and 1.0 (fully open). If None, the system will use auto mode.

1802 :type bypass_position: float | None

1803 :param src_id: The source device ID sending the command. If None, defaults to fan_id.

1804 :type src_id: DeviceIdT | str | None

1805 :keyword bypass_mode: Alternative to bypass_position, accepts:

1806 - 'auto': Let the system control the bypass automatically

1807 - 'on': Force bypass fully open

1808 - 'off': Force bypass fully closed

1809 :type bypass_mode: str | None

1810 :return: A configured Command object ready to be sent to the device.

1811 :rtype: Command

1812 :raises CommandInvalid: If both bypass_position and bypass_mode are provided,

1813 or if an invalid bypass_mode is specified.

1814

1815 .. note::

1816 The bypass valve position affects heat recovery efficiency and indoor air quality.

1817 Use with caution as incorrect settings may impact system performance.

1818 """

1819

1820 # RQ --- 37:155617 32:155617 --:------ 22F7 002 0064 # officially: 00C8EF

1821 # RP --- 32:155617 37:155617 --:------ 22F7 003 00C8C8

1822

1823 bypass_mode = kwargs.pop("bypass_mode", None)

1824 assert not kwargs, kwargs

1825

1826 src_id = src_id or fan_id # TODO: src_id should be an arg?

1827

1828 if bypass_mode and bypass_position is not None:

1829 raise exc.CommandInvalid(

1830 "bypass_mode and bypass_position are mutually exclusive, "

1831 "both cannot be provided, and neither is OK"

1832 )

1833 elif bypass_position is not None:

1834 pos = f"{int(bypass_position * 200):02X}"

1835 elif bypass_mode:

1836 pos = {"auto": "FF", "off": "00", "on": "C8"}[bypass_mode]

1837 else:

1838 pos = "FF" # auto

1839

1840 return cls._from_attrs(

1841 W_, Code._22F7, f"00{pos}", addr0=src_id, addr1=fan_id

1842 ) # trailing EF not required

1843

1844 @classmethod # constructor for RQ|2309

1845 def get_zone_setpoint(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:

1846 """Get the current temperature setpoint for a specific zone.

1847

1848 This method constructs a command to request the current temperature setpoint

1849 for a specified zone from the controller. The response will contain the current

1850 target temperature for the zone.

1851

1852 :param ctl_id: The device ID of the controller (e.g., '01:123456')

1853 :type ctl_id: DeviceIdT | str

1854 :param zone_idx: The index of the zone (0-31 or '00'-'1F')

1855 :type zone_idx: _ZoneIdxT

1856 :return: A configured Command object that can be sent to the device.

1857 :rtype: Command

1858 :raises ValueError: If the zone index is out of valid range (0-31)

1859

1860 .. note::

1861 The zone index is 0-based, where:

1862 - 0 = Zone 1 (typically main living area)

1863 - 1 = Zone 2 (e.g., bedrooms)

1864 - And so on up to zone 32

1865

1866 The actual number of available zones depends on the controller configuration.

1867 Requesting a non-existent zone will typically result in no response.

1868 """

1869 return cls.from_attrs(RQ, ctl_id, Code._2309, _check_idx(zone_idx))

1870

1871 @classmethod # constructor for W|2309

1872 def set_zone_setpoint(

1873 cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT, setpoint: float

1874 ) -> Command:

1875 """Set the temperature setpoint for a specific zone.

1876

1877 This method constructs a command to set the target temperature for a specified

1878 zone. The setpoint is specified in degrees Celsius with a resolution of 0.1°C.

1879

1880 :param ctl_id: The device ID of the controller (e.g., '01:123456')

1881 :type ctl_id: DeviceIdT | str

1882 :param zone_idx: The index of the zone (0-31 or '00'-'1F')

1883 :type zone_idx: _ZoneIdxT

1884 :param setpoint: The desired temperature in °C (typically 5.0-35.0)

1885 :type setpoint: float

1886 :return: A configured Command object ready to be sent to the device.

1887 :rtype: Command

1888 :raises ValueError: If the setpoint is outside the valid range or if the

1889 zone index is invalid.

1890

1891 .. note::

1892 The controller will typically round the setpoint to the nearest 0.5°C.

1893 The actual temperature range may be further limited by:

1894 - System-wide minimum/maximum limits

1895 - Zone-specific overrides

1896 - Current operating mode (heating/cooling)

1897

1898 When setting a new setpoint, the system may take some time to acknowledge

1899 the change. Use `get_zone_setpoint` to verify the new setting.

1900

1901 Some systems may have additional restrictions on when setpoints can be

1902 modified, such as during specific operating modes or schedules.

1903 """

1904 # Example: .W --- 34:092243 01:145038 --:------ 2309 003 0107D0

1905 payload = f"{_check_idx(zone_idx)}{hex_from_temp(setpoint)}"

1906 return cls.from_attrs(W_, ctl_id, Code._2309, payload)

1907

1908 @classmethod # constructor for RQ|2349

1909 def get_zone_mode(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:

1910 """Get the current operating mode of a zone.

1911

1912 This method constructs a command to request the current operating mode

1913 and setpoint information for a specific zone from the controller.

1914

1915 :param ctl_id: The device ID of the controller (e.g., '01:123456')

1916 :type ctl_id: DeviceIdT | str

1917 :param zone_idx: The index of the zone (0-31 or '00'-'1F')

1918 :type zone_idx: _ZoneIdxT

1919 :return: A configured Command object that can be sent to the device.

1920 :rtype: Command

1921

1922 :Example:

1923 >>> # Get mode for zone 0

1924 >>> cmd = Command.get_zone_mode('01:123456', '00')

1925 """

1926

1927 return cls.from_attrs(RQ, ctl_id, Code._2349, _check_idx(zone_idx))

1928

1929 @classmethod # constructor for W|2349

1930 def set_zone_mode(

1931 cls,

1932 ctl_id: DeviceIdT | str,

1933 zone_idx: _ZoneIdxT,

1934 *,

1935 mode: int | str | None = None,

1936 setpoint: float | None = None,

1937 until: dt | str | None = None,

1938 duration: int | None = None, # never supplied by Zone.set_mode()

1939 ) -> Command:

1940 """Set or reset the operating mode of a zone.

1941

1942 This method constructs a command to configure the operating mode and setpoint

1943 for a specific zone. The command can set the zone to various modes including

1944 follow schedule, temporary override, or permanent override.

1945

1946 :param ctl_id: The device ID of the controller (e.g., '01:123456')

1947 :type ctl_id: DeviceIdT | str

1948 :param zone_idx: The index of the zone (0-31 or '00'-'1F')

1949 :type zone_idx: _ZoneIdxT

1950 :keyword mode: The desired operating mode. Can be an integer, string, or None.

1951 Common values include 'follow_schedule', 'temporary', 'permanent_override'.

1952 :type mode: int | str | None

1953 :keyword setpoint: The target temperature in °C (resolution 0.1°C). Required for

1954 some modes. If None, the system will use the maximum possible value.

1955 :type setpoint: float | None

1956 :keyword until: The end time for a temporary override. Required for 'temporary' mode.

1957 Can be a datetime object or ISO 8601 formatted string.

1958 :type until: datetime | str | None

1959 :keyword duration: Duration in minutes for the override. Mutually exclusive with 'until'.

1960 :type duration: int | None

1961 :return: A configured Command object ready to be sent to the device.

1962 :rtype: Command

1963 :raises CommandInvalid: If invalid arguments are provided.

1964

1965 .. note::

1966 Incompatible combinations:

1967 - mode == 'follow_schedule' & setpoint is not None (setpoint will be ignored)

1968 - mode == 'temporary' & until is None (until is required)

1969 - until and duration are mutually exclusive (use only one)

1970 """

1971

1972 # .W --- 18:013393 01:145038 --:------ 2349 013 0004E201FFFFFF330B1A0607E4

1973 # .W --- 22:017139 01:140959 --:------ 2349 007 0801F400FFFFFF

1974

1975 mode = _normalise_mode(mode, setpoint, until, duration)

1976

1977 if setpoint is not None and not isinstance(setpoint, float | int):

1978 raise exc.CommandInvalid(

1979 f"Invalid args: setpoint={setpoint}, but must be a float"

1980 )

1981

1982 until, duration = _normalise_until(mode, setpoint, until, duration)

1983

1984 payload = "".join(

1985 (

1986 _check_idx(zone_idx),

1987 hex_from_temp(setpoint), # None means max, if a temp is required

1988 mode,

1989 "FFFFFF" if duration is None else f"{duration:06X}",

1990 "" if until is None else hex_from_dtm(until),

1991 )

1992 )

1993

1994 return cls.from_attrs(W_, ctl_id, Code._2349, payload)

1995

1996 @classmethod # constructor for W|2411

1997 def set_fan_param(

1998 cls,

1999 fan_id: DeviceIdT | str,

2000 param_id: str,

2001 value: str | int | float | bool,

2002 *,

2003 src_id: DeviceIdT | str | None = None,

2004 ) -> Command:

2005 """Set a configuration parameter for a fan/ventilation device.

2006

2007 This method constructs a command to configure various parameters of a

2008 fan or ventilation device using the RAMSES-II protocol.

2009

2010 :param fan_id: The device ID of the fan/ventilation unit

2011 :type fan_id: DeviceIdT | str

2012 :param param_id: The parameter ID to set (e.g., 'bypass_position' or hex code '00')

2013 :type param_id: str

2014 :param value: The value to set for the parameter. Type depends on the parameter.

2015 :type value: str | int | float | bool

2016 :param src_id: Optional source device ID. If not provided, fan_id will be used.

2017 :type src_id: DeviceIdT | str | None

2018 :return: A configured Command object ready to be sent to the device.

2019 :rtype: Command

2020 :raises CommandInvalid: If the parameter ID is unknown or value is invalid.

2021

2022 .. note::

2023 The parameter ID must be a valid 2-character hexadecimal string (00-FF) that

2024 exists in the _2411_PARAMS_SCHEMA. The payload format follows the pattern:

2025 ^(00|01|15|16|17|21)00[0-9A-F]{6}[0-9A-F]{8}(([0-9A-F]{8}){3}[0-9A-F]{4})?$

2026 --- Ramses-II 2411 payload: 23 bytes, 46 hex digits ---

2027

2028 Raises:

2029 CommandInvalid: For invalid parameters or values

2030 """

2031 # Validate and normalize parameter ID

2032 try:

2033 param_id = param_id.strip().upper()

2034 if len(param_id) != 2:

2035 raise ValueError(

2036 "Parameter ID must be exactly 2 hexadecimal characters"

2037 )

2038 int(param_id, 16) # Validate hex

2039 except ValueError as err:

2040 raise exc.CommandInvalid(

2041 f"Invalid parameter ID: '{param_id}'. Must be a 2-digit hexadecimal value (00-FF)"

2042 ) from err

2043

2044 # Get parameter schema

2045 if (param_schema := _2411_PARAMS_SCHEMA.get(param_id)) is None:

2046 raise exc.CommandInvalid(

2047 f"Unknown parameter ID: '{param_id}'. This parameter is not defined in the device schema"

2048 )

2049

2050 # Get value constraints with defaults

2051 min_val = param_schema[SZ_MIN_VALUE]

2052 max_val = param_schema[SZ_MAX_VALUE]

2053 precision = param_schema.get(SZ_PRECISION, 1.0)

2054 data_type = param_schema.get(SZ_DATA_TYPE, "00")

2055

2056 try:

2057 # Check for special float values first

2058 if isinstance(value, float) and not math.isfinite(value):

2059 raise exc.CommandInvalid(

2060 f"Parameter {param_id}: Invalid value '{value}'. Must be a finite number"

2061 )

2062

2063 # Scaling

2064 if str(data_type) == "01": # %

2065 # Special handling for parameter 52 (Sensor sensitivity)

2066 value_scaled = int(round(float(value) / precision))

2067 min_val_scaled = int(round(float(min_val) / precision))

2068 max_val_scaled = int(round(float(max_val) / precision))

2069 precision_scaled = int(round(float(precision) * 10))

2070 trailer = "0032" # Trailer for percentage parameters

2071

2072 # For percentage values, validate input is in range

2073 if not min_val_scaled <= value_scaled <= max_val_scaled:

2074 raise exc.CommandInvalid(

2075 f"Parameter {param_id}: Value {value_scaled / 10}% is out of allowed range ({min_val_scaled / 10}% to {max_val_scaled / 10}%)"

2076 )

2077 elif str(data_type) == "0F": # %

2078 # For other percentage parameters, use the standard scaling

2079 value_scaled = int(round((float(value) / 100.0) / float(precision)))

2080 min_val_scaled = int(round(float(min_val) / float(precision)))

2081 max_val_scaled = int(round(float(max_val) / float(precision)))

2082 precision_scaled = int(round(float(precision) * 200))

2083 trailer = "0032" # Trailer for percentage parameters

2084

2085 # For percentage values, validate input is in range

2086 if not min_val_scaled <= value_scaled <= max_val_scaled:

2087 raise exc.CommandInvalid(

2088 f"Parameter {param_id}: Value {value_scaled / 2}% is out of allowed range ({min_val_scaled / 2}% to {max_val_scaled / 2}%)"

2089 )

2090 elif str(data_type) == "92": # °C

2091 # Scale temperature values by 100 (21.5°C -> 2150 = 0x0866)

2092 # Round to 0.1°C precision first, then scale

2093 value_rounded = (

2094 round(float(value) * 10) / 10

2095 ) # Round to 1 decimal place

2096 value_scaled = int(

2097 value_rounded * 100

2098 ) # Convert to integer (e.g., 21.5 -> 2150)

2099 min_val_scaled = int(float(min_val) * 100)

2100 max_val_scaled = int(float(max_val) * 100)

2101 precision_scaled = int(float(precision) * 100)

2102 trailer = (

2103 "0001" # always 4 hex not sure about the value, but seems to work.

2104 )

2105 # For temperature values, validate input is within allowed range

2106 if not min_val_scaled <= value_scaled <= max_val_scaled:

2107 raise exc.CommandInvalid(

2108 f"Parameter {param_id}: Temperature {value_scaled / 100:.1f}°C is out of allowed range ({min_val_scaled / 100:.1f}°C to {max_val_scaled / 100:.1f}°C)"

2109 )

2110 elif (str(data_type) == "00") or (

2111 str(data_type) == "10"

2112 ): # numeric (minutes, medium(0)/high(1) or days)

2113 value_scaled = int(float(value))

2114 min_val_scaled = int(float(min_val))

2115 max_val_scaled = int(float(max_val))

2116 precision = 1

2117 precision_scaled = int(precision)

2118 trailer = (

2119 "0001" # always 4 hex not sure about the value, but seems to work.

2120 )

2121 # For numeric values, validate input is between min and max

2122 if not min_val_scaled <= value_scaled <= max_val_scaled:

2123 unit = "minutes" if data_type == "00" else ""

2124 raise exc.CommandInvalid(

2125 f"Parameter {param_id}: Value {value_scaled}{' ' + unit if unit else ''} is out of allowed range ({min_val_scaled} to {max_val_scaled}{' ' + unit if unit else ''})"

2126 )

2127 else:

2128 # Validate value against min/max

2129 raise exc.CommandInvalid(

2130 f"Parameter {param_id}: Invalid data type '{data_type}'. Must be one of '00', '01', '0F', '10', or '92'"

2131 f"Invalid Data_type {data_type} for parameter {param_id}"

2132 )

2133

2134 # Assemble payload fields

2135 leading = "00" # always 2 hex

2136 param_id_hex = f"{int(param_id, 16):04X}" # 4 hex, upper, zero-padded

2137

2138 # data_type (6 hex): always from schema, zero-padded to 6 hex

2139 data_type_hex = f"00{data_type}"

2140 value_hex = f"{value_scaled:08X}"

2141 min_hex = f"{min_val_scaled:08X}"

2142 max_hex = f"{max_val_scaled:08X}"

2143 precision_hex = f"{precision_scaled:08X}"

2144

2145 _LOGGER.debug(

2146 f"set_fan_param: value={value}, min={min_val}, max={max_val}, precision={precision}"

2147 f"\n Scaled: value={value_scaled} (0x{value_hex}), min={min_val_scaled} (0x{min_hex}), "

2148 f"max={max_val_scaled} (0x{max_hex}), precision={precision_scaled} (0x{precision_hex})"

2149 )

2150

2151 # Final field order: 2+4+4+8+8+8+8+4 = 46 hex -> 23 bytes

2152 payload = (

2153 f"{leading}"

2154 f"{param_id_hex}"

2155 f"{data_type_hex}"

2156 f"{value_hex}"

2157 f"{min_hex}"

2158 f"{max_hex}"

2159 f"{precision_hex}"

2160 f"{trailer}"

2161 )

2162 payload = "".join(payload)

2163 _LOGGER.debug(

2164 f"set_fan_param: Final frame: {W_} --- {src_id} {fan_id} --:------ 2411 {len(payload):03d} {payload}"

2165 )

2166

2167 # Create the command with exactly 2 addresses: from_id and fan_id

2168 return cls._from_attrs(

2169 W_,

2170 Code._2411,

2171 payload,

2172 addr0=src_id,

2173 addr1=fan_id,

2174 addr2=NON_DEV_ADDR.id,

2175 )

2176

2177 except (ValueError, TypeError) as err:

2178 raise exc.CommandInvalid(f"Invalid value: {value}") from err

2179

2180 @classmethod # constructor for RQ|2411

2181 def get_fan_param(

2182 cls,

2183 fan_id: DeviceIdT | str,

2184 param_id: str,

2185 *,

2186 src_id: DeviceIdT | str,

2187 ) -> Command:

2188 """Create a command to get a fan parameter value.

2189

2190 This method constructs a command to read a specific parameter from a fan device

2191 using the RAMSES-II 2411 command. The parameter ID must be a valid 2-character

2192 hexadecimal string (00-FF).

2193

2194 :param fan_id: The device ID of the target fan (e.g., '01:123456')

2195 :type fan_id: DeviceIdT | str

2196 :param param_id: The parameter ID to read (2-character hex string, e.g., '4E')

2197 :type param_id: str

2198 :param src_id: The source device ID that will send the command

2199 :type src_id: DeviceIdT | str

2200 :return: A Command object for the RQ|2411 message

2201 :rtype: Command

2202 :raises CommandInvalid: If the parameter ID is invalid (None, wrong type, wrong format)

2203

2204 .. note::

2205 For a complete working example, see the `test_get_fan_param.py` test file

2206 which demonstrates:

2207 - Setting up the gateway

2208 - Sending the command

2209 - Handling the response

2210 - Proper error handling

2211

2212 .. warning::

2213 The parameter ID must be a valid 2-character hexadecimal string (00-FF).

2214 The following will raise CommandInvalid:

2215 - None value

2216 - Non-string types

2217 - Leading/trailing whitespace

2218 - Incorrect length (not 2 characters)

2219 - Non-hexadecimal characters

2220 """

2221 if param_id is None:

2222 raise exc.CommandInvalid("Parameter ID cannot be None")

2223

2224 if not isinstance(param_id, str):

2225 raise exc.CommandInvalid(

2226 f"Parameter ID must be a string, got {type(param_id).__name__}"

2227 )

2228

2229 param_id_stripped = param_id.strip()

2230 if param_id != param_id_stripped:

2231 raise exc.CommandInvalid(

2232 f"Parameter ID cannot have leading or trailing whitespace: '{param_id}'"

2233 )

2234

2235 # validate the string format

2236 try:

2237 if len(param_id) != 2:

2238 raise ValueError("Invalid length")

2239 int(param_id, 16) # Will raise ValueError if not valid hex

2240 except ValueError as err:

2241 raise exc.CommandInvalid(

2242 f"Invalid parameter ID: '{param_id}'. Must be a 2-character hex string (00-FF)."

2243 ) from err

2244

2245 payload = f"0000{param_id.upper()}" # Convert to uppercase for consistency

2246 _LOGGER.debug(

2247 "Created get_fan_param command for %s from %s to %s",

2248 param_id,

2249 src_id,

2250 fan_id,

2251 )

2252

2253 return cls._from_attrs(RQ, Code._2411, payload, addr0=src_id, addr1=fan_id)

2254

2255 @classmethod # constructor for RQ|2E04

2256 def get_system_mode(cls, ctl_id: DeviceIdT | str) -> Command:

2257 """Get the mode of a system (c.f. parser_2e04).

2258

2259 :param ctl_id: The device ID of the controller

2260 :type ctl_id: DeviceIdT | str

2261 :return: A Command object for the RQ|2E04 message

2262 :rtype: Command

2263 """

2264

2265 return cls.from_attrs(RQ, ctl_id, Code._2E04, FF)

2266

2267 @classmethod # constructor for W|2E04

2268 def set_system_mode(

2269 cls,

2270 ctl_id: DeviceIdT | str,

2271 system_mode: int | str | None,

2272 *,

2273 until: dt | str | None = None,

2274 ) -> Command:

2275 """Set or reset the operating mode of the HVAC system. (c.f. parser_2e04)

2276

2277 This method constructs a command to change the system-wide operating mode,

2278 such as switching between heating modes or setting a temporary override.

2279

2280 :param ctl_id: The device ID of the controller (e.g., '01:123456')

2281 :type ctl_id: DeviceIdT | str

2282 :param system_mode: The desired system mode. Can be specified as:

2283 - Integer: Numeric mode code (0-5)

2284 - String: Mode name (e.g., 'auto', 'heat_eco')

2285 - Hex string: Two-character hex code (e.g., '00' for auto)

2286 If None, defaults to 'auto' mode.

2287 :type system_mode: int | str | None

2288 :param until: Optional timestamp when the mode should revert.

2289 Required for temporary modes like 'eco' or 'advanced'.

2290 Not allowed for 'auto' or 'heat_off' modes.

2291 :type until: datetime | str | None

2292 :return: A configured Command object ready to be sent to the device.

2293 :rtype: Command

2294 :raises CommandInvalid: If the combination of mode and until is invalid.

2295 :raises KeyError: If an invalid mode is specified.

2296

2297 .. note::

2298 Available modes are defined in SYS_MODE_MAP and typically include:

2299 - 'auto': System follows the schedule (code '00')

2300 - 'heat_off': Heating disabled (code '04')

2301 - 'eco': Reduced temperature mode (code '01')

2302 - 'advanced': Custom temperature mode (code '02')

2303 - 'holiday': Away mode (code '03')

2304 - 'custom': Custom mode (code '05')

2305

2306 When using temporary modes (eco/advanced), the 'until' parameter

2307 must be provided. The system will automatically revert to the

2308 schedule when the time elapses.

2309 """

2310

2311 if system_mode is None:

2312 system_mode = SYS_MODE_MAP.AUTO

2313 if isinstance(system_mode, int):

2314 system_mode = f"{system_mode:02X}"

2315 if system_mode not in SYS_MODE_MAP:

2316 system_mode = SYS_MODE_MAP._hex(system_mode) # may raise KeyError

2317

2318 if until is not None and system_mode in (

2319 SYS_MODE_MAP.AUTO,

2320 SYS_MODE_MAP.AUTO_WITH_RESET,

2321 SYS_MODE_MAP.HEAT_OFF,

2322 ):

2323 raise exc.CommandInvalid(

2324 f"Invalid args: For system_mode={SYS_MODE_MAP[system_mode]},"

2325 " until must be None"

2326 )

2327

2328 assert isinstance(system_mode, str) # mypy hint

2329

2330 payload = "".join(

2331 (

2332 system_mode,

2333 hex_from_dtm(until),

2334 "00" if until is None else "01",

2335 )

2336 )

2337

2338 return cls.from_attrs(W_, ctl_id, Code._2E04, payload)

2339

2340 @classmethod # constructor for I|2E10

2341 def put_presence_detected(

2342 cls, dev_id: DeviceIdT | str, presence_detected: bool | None

2343 ) -> Command:

2344 """Announce the current presence detection state from a sensor. (c.f. parser_2e10)

2345 # .I --- ...

2346

2347 This method constructs an I-type (unsolicited) command to report the

2348 presence detection state from a presence sensor to the system.

2349

2350 :param dev_id: The device ID of the presence sensor (e.g., '01:123456')

2351 :type dev_id: DeviceIdT | str

2352 :param presence_detected: The current presence state:

2353 - True: Presence detected

2354 - False: No presence detected

2355 - None: Sensor state unknown/error

2356 :type presence_detected: bool | None

2357 :return: A configured Command object ready to be sent to the system.

2358 :rtype: Command

2359

2360 .. note::

2361 This is typically used by presence sensors to report their state

2362 to the HVAC system. The system may use this information for

2363 occupancy-based control strategies.

2364

2365 The command uses the 2E10 code, which is specifically designed

2366 for presence/occupancy reporting in the RAMSES-II protocol.

2367 """

2368 payload = f"00{hex_from_bool(presence_detected)}"

2369 return cls._from_attrs(I_, Code._2E10, payload, addr0=dev_id, addr2=dev_id)

2370

2371 @classmethod # constructor for RQ|30C9

2372 def get_zone_temp(cls, ctl_id: DeviceIdT | str, zone_idx: _ZoneIdxT) -> Command:

2373 """Request the current temperature reading for a specific zone. (c.f. parser_30c9)

2374

2375 This method constructs a command to request the current temperature

2376 from a zone's temperature sensor. The response will include the current

2377 temperature in degrees Celsius with 0.1°C resolution.

2378

2379 :param ctl_id: The device ID of the controller managing the zone (e.g., '01:123456')

2380 :type ctl_id: DeviceIdT | str

2381 :param zone_idx: The index of the zone to query. Can be specified as:

2382 - Integer (0-31)

2383 - Hex string ('00'-'1F')

2384 - String representation of integer ('0'-'31')

2385 :type zone_idx: _ZoneIdxT

2386 :return: A configured Command object that can be sent to the device.

2387 :rtype: Command

2388 :raises ValueError: If the zone index is out of valid range (0-31)

2389

2390 .. note::

2391 The zone index is 0-based. For example:

2392 - 0 = Zone 1 (typically main living area)

2393 - 1 = Zone 2 (e.g., bedrooms)

2394 - And so on up to zone 32

2395

2396 The actual number of available zones depends on the controller configuration.

2397 Requesting a non-existent zone will typically result in no response.

2398 """

2399 return cls.from_attrs(RQ, ctl_id, Code._30C9, _check_idx(zone_idx))

2400

2401 @classmethod # constructor for I|30C9 # TODO: trap corrupt temps?

2402 def put_sensor_temp(

2403 cls, dev_id: DeviceIdT | str, temperature: float | None

2404 ) -> Command:

2405 """Announce the current temperature reading from a thermostat. (c.f. parser_30c9)

2406 This is for use by a faked DTS92(E) or similar.

2407

2408 This method constructs an I-type (unsolicited) command to report the current

2409 temperature from a thermostat or temperature sensor to the system. This is

2410 typically used to simulate a physical thermostat's temperature reporting.

2411

2412 :param dev_id: The device ID of the thermostat or sensor (e.g., '01:123456')

2413 :type dev_id: DeviceIdT | str

2414 :param temperature: The current temperature in degrees Celsius.

2415 Use None to indicate a sensor error or invalid reading.

2416 The valid range is typically 0-40°C, but this may vary by device.

2417 :type temperature: float | None

2418 :return: A configured Command object ready to be sent to the system.

2419 :rtype: Command

2420

2421 .. note::

2422 This is primarily used for testing or simulating thermostats like the DTS92(E).

2423 The temperature is transmitted with 0.1°C resolution.

2424

2425 The command uses the 30C9 code, which is used by thermostats to report

2426 their current temperature reading to the controller.

2427

2428 When temperature is None, it typically indicates a sensor fault or

2429 invalid reading, which the system may interpret as a maintenance alert.

2430 """

2431 # .I --- 34:021943 --:------ 34:021943 30C9 003 000C0D

2432

2433 if dev_id[:2] not in (

2434 DEV_TYPE_MAP.TR0, # 00

2435 DEV_TYPE_MAP.HCW, # 03

2436 DEV_TYPE_MAP.TRV, # 04

2437 DEV_TYPE_MAP.DTS, # 12

2438 DEV_TYPE_MAP.DT2, # 22

2439 DEV_TYPE_MAP.RND, # 34

2440 ):

2441 raise exc.CommandInvalid(

2442 f"Faked device {dev_id} has an unsupported device type: "

2443 f"device_id should be like {DEV_TYPE_MAP.HCW}:xxxxxx"

2444 )

2445

2446 payload = f"00{hex_from_temp(temperature)}"

2447 return cls._from_attrs(I_, Code._30C9, payload, addr0=dev_id, addr2=dev_id)

2448

2449 @classmethod # constructor for RQ|313F

2450 def get_system_time(cls, ctl_id: DeviceIdT | str) -> Command:

2451 """Constructor to get the datetime of a system (c.f. parser_313f)."""

2452

2453 return cls.from_attrs(RQ, ctl_id, Code._313F, "00")

2454

2455 @classmethod # constructor for W|313F

2456 def set_system_time(

2457 cls,

2458 ctl_id: DeviceIdT | str,

2459 datetime: dt | str,

2460 is_dst: bool = False,

2461 ) -> Command:

2462 """Set the datetime of a system (c.f. parser_313f).

2463

2464 :param ctl_id: The device ID of the controller

2465 :type ctl_id: DeviceIdT | str

2466 :param datetime: The target date and time

2467 :type datetime: dt | str

2468 :param is_dst: Whether Daylight Saving Time is active, defaults to False

2469 :type is_dst: bool

2470 :return: A Command object for the W|313F message

2471 :rtype: Command

2472 """

2473 # .W --- 30:185469 01:037519 --:------ 313F 009 0060003A0C1B0107E5

2474

2475 dt_str = hex_from_dtm(datetime, is_dst=is_dst, incl_seconds=True)

2476 return cls.from_attrs(W_, ctl_id, Code._313F, f"0060{dt_str}")

2477

2478 @classmethod # constructor for I|31DA

2479 def get_hvac_fan_31da(

2480 cls,

2481 dev_id: DeviceIdT | str,

2482 hvac_id: str,

2483 bypass_position: float | None,

2484 air_quality: int | None,

2485 co2_level: int | None,

2486 indoor_humidity: float | None,

2487 outdoor_humidity: float | None,

2488 exhaust_temp: float | None,

2489 supply_temp: float | None,

2490 indoor_temp: float | None,

2491 outdoor_temp: float | None,

2492 speed_capabilities: list[str],

2493 fan_info: str,

2494 _unknown_fan_info_flags: list[int], # skip? as starts with _

2495 exhaust_fan_speed: float | None,

2496 supply_fan_speed: float | None,

2497 remaining_mins: int | None,

2498 post_heat: int | None,

2499 pre_heat: int | None,

2500 supply_flow: float | None,

2501 exhaust_flow: float | None,

2502 **kwargs: Any, # option: air_quality_basis: str | None,

2503 ) -> Command:

2504 """Construct an I|31DA command for HVAC fan status updates.

2505

2506 This method creates an unsolicited status update command for HVAC fan systems,

2507 reporting various sensor readings and system states.

2508

2509 :param dev_id: The device ID of the HVAC controller

2510 :type dev_id: DeviceIdT | str

2511 :param hvac_id: The ID of the HVAC unit

2512 :type hvac_id: str

2513 :param bypass_position: Current bypass damper position (0.0-1.0)

2514 :type bypass_position: float | None

2515 :param air_quality: Current air quality reading

2516 :type air_quality: int | None

2517 :param co2_level: Current CO₂ level in ppm

2518 :type co2_level: int | None

2519 :param indoor_humidity: Current indoor relative humidity (0.0-1.0)

2520 :type indoor_humidity: float | None

2521 :param outdoor_humidity: Current outdoor relative humidity (0.0-1.0)

2522 :type outdoor_humidity: float | None

2523 :param exhaust_temp: Current exhaust air temperature in °C

2524 :type exhaust_temp: float | None

2525 :param supply_temp: Current supply air temperature in °C

2526 :type supply_temp: float | None

2527 :param indoor_temp: Current indoor temperature in °C

2528 :type indoor_temp: float | None

2529 :param outdoor_temp: Current outdoor temperature in °C

2530 :type outdoor_temp: float | None

2531 :param speed_capabilities: List of supported fan speed settings

2532 :type speed_capabilities: list[str]

2533 :param fan_info: Current fan mode/status information

2534 :type fan_info: str

2535 :param _unknown_fan_info_flags: Internal flags (reserved for future use)

2536 :type _unknown_fan_info_flags: list[int]

2537 :param exhaust_fan_speed: Current exhaust fan speed (0.0-1.0)

2538 :type exhaust_fan_speed: float | None

2539 :param supply_fan_speed: Current supply fan speed (0.0-1.0)

2540 :type supply_fan_speed: float | None

2541 :param remaining_mins: Remaining time in current mode (minutes)

2542 :type remaining_mins: int | None

2543 :param post_heat: Post-heat status/level

2544 :type post_heat: int | None

2545 :param pre_heat: Pre-heat status/level

2546 :type pre_heat: int | None

2547 :param supply_flow: Current supply air flow rate (if available)

2548 :type supply_flow: float | None

2549 :param exhaust_flow: Current exhaust air flow rate (if available)

2550 :type exhaust_flow: float | None

2551 :param kwargs: Additional parameters (reserved for future use)

2552 :return: A configured Command object for the HVAC fan status update

2553 :rtype: Command

2554

2555 .. note::

2556 This command is typically sent periodically by the HVAC controller to report

2557 current system status. All parameters are optional, but providing complete

2558 information will result in more accurate system monitoring and control.

2559 """

2560 # 00 EF00 7FFF 34 33 0898 0898 088A 0882 F800 00 15 14 14 0000 EF EF 05F5 0613:

2561 # {"hvac_id": '00', 'bypass_position': 0.000, 'air_quality': None,

2562 # 'co2_level': None, 'indoor_humidity': 0.52, 'outdoor_humidity': 0.51,

2563 # 'exhaust_temp': 22.0, 'supply_temp': 22.0, 'indoor_temp': 21.86,

2564 # 'outdoor_temp': 21.78, 'speed_capabilities': ['off', 'low_med_high',

2565 # 'timer', 'boost', 'auto'], 'fan_info': 'away',

2566 # '_unknown_fan_info_flags': [0, 0, 0], 'exhaust_fan_speed': 0.1,

2567 # 'supply_fan_speed': 0.1, 'remaining_mins': 0, 'post_heat': None,

2568 # 'pre_heat': None, 'supply_flow': 15.25, 'exhaust_flow': 15.55},

2569

2570 air_quality_basis: str = kwargs.pop("air_quality_basis", "00")

2571 extra: str = kwargs.pop("_extra", "")

2572 assert not kwargs, kwargs

2573

2574 payload = hvac_id

2575 payload += (

2576 f"{(int(air_quality * 200)):02X}" if air_quality is not None else "EF"

2577 )

2578 payload += (

2579 f"{air_quality_code(air_quality_basis)}"

2580 if air_quality_basis is not None

2581 else "00"

2582 )

2583 payload += f"{co2_level:04X}" if co2_level is not None else "7FFF"

2584 payload += (

2585 hex_from_percent(indoor_humidity, high_res=False)

2586 if indoor_humidity is not None

2587 else "EF"

2588 )

2589 payload += (

2590 hex_from_percent(outdoor_humidity, high_res=False)

2591 if outdoor_humidity is not None

2592 else "EF"

2593 )

2594 payload += hex_from_temp(exhaust_temp) if exhaust_temp is not None else "7FFF"

2595 payload += hex_from_temp(supply_temp) if supply_temp is not None else "7FFF"

2596 payload += hex_from_temp(indoor_temp) if indoor_temp is not None else "7FFF"

2597 payload += hex_from_temp(outdoor_temp) if outdoor_temp is not None else "7FFF"

2598 payload += (

2599 f"{capability_bits(speed_capabilities):04X}"

2600 if speed_capabilities is not None

2601 else "7FFF"

2602 )

2603 payload += (

2604 hex_from_percent(bypass_position, high_res=True)

2605 if bypass_position is not None

2606 else "EF"

2607 )

2608 payload += (

2609 f"{(fan_info_to_byte(fan_info) | fan_info_flags(_unknown_fan_info_flags)):02X}"

2610 if fan_info is not None

2611 else "EF"

2612 )

2613 payload += (

2614 hex_from_percent(exhaust_fan_speed, high_res=True)

2615 if exhaust_fan_speed is not None

2616 else "FF"

2617 )

2618 payload += (

2619 hex_from_percent(supply_fan_speed, high_res=True)

2620 if supply_fan_speed is not None

2621 else "FF"

2622 )

2623 payload += f"{remaining_mins:04X}" if remaining_mins is not None else "7FFF"

2624 payload += f"{int(post_heat * 200):02X}" if post_heat is not None else "EF"

2625 payload += f"{int(pre_heat * 200):02X}" if pre_heat is not None else "EF"

2626 payload += (

2627 f"{(int(supply_flow * 100)):04X}" if supply_flow is not None else "7FFF"

2628 )

2629 payload += (

2630 f"{(int(exhaust_flow * 100)):04X}" if exhaust_flow is not None else "7FFF"

2631 )

2632 payload += extra

2633

2634 return cls._from_attrs(I_, Code._31DA, payload, addr0=dev_id, addr2=dev_id)

2635

2636 @classmethod # constructor for RQ|3220

2637 def get_opentherm_data(cls, otb_id: DeviceIdT | str, msg_id: int | str) -> Command:

2638 """Request OpenTherm protocol data from a device. (c.f. parser_3220)

2639

2640 This method constructs a command to request data from an OpenTherm compatible

2641 device using the OpenTherm protocol. It sends a Read-Data request for a

2642 specific data ID to the target device.

2643

2644 :param otb_id: The device ID of the OpenTherm bridge/controller

2645 :type otb_id: DeviceIdT | str

2646 :param msg_id: The OpenTherm message ID to request. Can be specified as:

2647 - Integer (e.g., 0 for Status)

2648 - Hex string (e.g., '00' for Status)

2649 See OpenTherm specification for valid message IDs.

2650 :type msg_id: int | str

2651 :return: A configured Command object ready to be sent to the device.

2652 :rtype: Command

2653

2654 .. note::

2655 The OpenTherm protocol is used for communication between heating systems

2656 and thermostats. Common message IDs include:

2657 - 0x00: Status (0x00)

2658 - 0x01: Control setpoint (0x01)

2659 - 0x11: Relative modulation level (0x11)

2660 - 0x12: CH water pressure (0x12)

2661 - 0x19: Boiler water temperature (0x19)

2662 - 0x1A: DHW temperature (0x1A)

2663 - 0x71: DHW setpoint (0x71)

2664

2665 The response will contain the requested data in the OpenTherm format,

2666 which includes status flags and the data value.

2667

2668 The command automatically handles the parity bit required by the

2669 OpenTherm protocol.

2670 """

2671 msg_id = msg_id if isinstance(msg_id, int) else int(msg_id, 16)

2672 payload = f"0080{msg_id:02X}0000" if parity(msg_id) else f"0000{msg_id:02X}0000"

2673 return cls.from_attrs(RQ, otb_id, Code._3220, payload)

2674

2675 @classmethod # constructor for I|3EF0 # TODO: trap corrupt states?

2676 def put_actuator_state(

2677 cls, dev_id: DeviceIdT | str, modulation_level: float

2678 ) -> Command:

2679 """Announce the current modulation level of a heating actuator. (c.f. parser_3ef0)

2680 This is for use by a faked BDR91A or similar.

2681

2682 This method constructs an I-type (unsolicited) command to report the current

2683 modulation level of a heating actuator, such as a BDR91A relay. The modulation

2684 level represents the current output state of the actuator as a percentage.

2685

2686 :param dev_id: The device ID of the actuator (e.g., '13:123456').

2687 Must be a device type compatible with BDR91A.

2688 :type dev_id: DeviceIdT | str

2689 :param modulation_level: The current modulation level as a float between 0.0 and 1.0.

2690 - 0.0: Actuator is fully off

2691 - 1.0: Actuator is fully on

2692 - Values in between represent partial modulation (if supported)

2693 - None: Indicates an error or unknown state

2694 :type modulation_level: float | None

2695 :return: A configured Command object ready to be sent to the system.

2696 :rtype: Command

2697 :raises CommandInvalid: If the device ID is not a valid BDR-type device.

2698

2699 .. note::

2700 This is primarily used for testing or simulating BDR91A relay modules.

2701 The modulation level is converted to a percentage (0-100%) with 0.5% resolution.

2702

2703 The command uses the 3EF0 code, which is specifically designed for

2704 reporting actuator states in the RAMSES-II protocol.

2705 """

2706 # .I --- 13:049798 --:------ 13:049798 3EF0 003 00C8FF

2707 # .I --- 13:106039 --:------ 13:106039 3EF0 003 0000FF

2708

2709 if dev_id[:2] != DEV_TYPE_MAP.BDR:

2710 raise exc.CommandInvalid(

2711 f"Faked device {dev_id} has an unsupported device type: "

2712 f"device_id should be like {DEV_TYPE_MAP.BDR}:xxxxxx"

2713 )

2714

2715 payload = (

2716 "007FFF"

2717 if modulation_level is None

2718 else f"00{int(modulation_level * 200):02X}FF"

2719 )

2720 return cls._from_attrs(I_, Code._3EF0, payload, addr0=dev_id, addr2=dev_id)

2721

2722 @classmethod # constructor for RP|3EF1 (I|3EF1?) # TODO: trap corrupt values?

2723 def put_actuator_cycle(

2724 cls,

2725 src_id: DeviceIdT | str,

2726 dst_id: DeviceIdT | str,

2727 modulation_level: float,

2728 actuator_countdown: int,

2729 *,

2730 cycle_countdown: int | None = None,

2731 ) -> Command:

2732 """Announce the internal cycling state of a heating actuator. (c.f. parser_3ef1)

2733 This is for use by a faked BDR91A or similar.

2734

2735 This method constructs an RP-type (request/response) command to report the

2736 internal cycling state of a heating actuator, such as a BDR91A relay. It provides

2737 detailed timing information about the actuator's modulation cycle.

2738

2739 :param src_id: The device ID of the actuator sending the report (e.g., '13:123456').

2740 Must be a device type compatible with BDR91A.

2741 :type src_id: DeviceIdT | str

2742 :param dst_id: The device ID of the intended recipient of this report.

2743 :type dst_id: DeviceIdT | str

2744 :param modulation_level: The current modulation level as a float between 0.0 and 1.0.

2745 - 0.0: Actuator is fully off

2746 - 1.0: Actuator is fully on

2747 - Values in between represent partial modulation (if supported)

2748 :type modulation_level: float

2749 :param actuator_countdown: Time in seconds until the next actuator cycle state change.

2750 This is used for PWM (Pulse Width Modulation) control.

2751 :type actuator_countdown: int

2752 :param cycle_countdown: Optional time in seconds until the next complete cycle.

2753 If None, indicates the cycle is not currently active.

2754 :type cycle_countdown: int | None

2755 :return: A configured Command object ready to be sent to the system.

2756 :rtype: Command

2757 :raises CommandInvalid: If the source device ID is not a valid BDR-type device.

2758

2759 .. note::

2760 This is primarily used for testing or simulating BDR91A relay modules.

2761 The method automatically handles the conversion of timing values to the

2762 appropriate hexadecimal format required by the RAMSES-II protocol.

2763

2764 The command uses the 3EF1 code, which is specifically designed for

2765 reporting detailed actuator cycling information.

2766 """

2767 # RP --- 13:049798 18:006402 --:------ 3EF1 007 00-0126-0126-00-FF

2768

2769 if src_id[:2] != DEV_TYPE_MAP.BDR:

2770 raise exc.CommandInvalid(

2771 f"Faked device {src_id} has an unsupported device type: "

2772 f"device_id should be like {DEV_TYPE_MAP.BDR}:xxxxxx"

2773 )

2774

2775 payload = "00"

2776 payload += f"{cycle_countdown:04X}" if cycle_countdown is not None else "7FFF"

2777 payload += f"{actuator_countdown:04X}"

2778 payload += hex_from_percent(modulation_level)

2779 payload += "FF"

2780 return cls._from_attrs(RP, Code._3EF1, payload, addr0=src_id, addr1=dst_id)

2781

2782 @classmethod # constructor for internal use only

2783 def _puzzle(cls, msg_type: str | None = None, message: str = "") -> Command:

2784 """Construct a puzzle command used for device discovery and version reporting.

2785

2786 This internal method creates a special 'puzzle' command used during device

2787 discovery and version reporting. The command format varies based on the

2788 message type and content.

2789

2790 :param msg_type: The type of puzzle message to create. If None, it will be

2791 automatically determined based on the presence of a message:

2792 - '10': Version request (empty message)

2793 - '12': Version response (with message)

2794 Other valid types include '11' and '13' for specific message formats,

2795 and '20' and above for timestamp-based messages.

2796 :type msg_type: str | None

2797 :param message: The message content to include in the puzzle.

2798 Format depends on msg_type:

2799 - For type '10': Should be empty (version request)

2800 - For type '11': Should be a 10-character string (MAC address)

2801 - For type '12': Version string (e.g., 'v0.20.0')

2802 - For other types: Arbitrary message content

2803 :type message: str

2804 :return: A configured Command object with the puzzle message.

2805 :rtype: Command

2806 :raises AssertionError: If msg_type is not in LOOKUP_PUZZ.

2807

2808 .. note::

2809 This is an internal method used by the RAMSES-II protocol for device

2810 discovery and version reporting. The message format varies:

2811

2812 - Type '10': Version request (empty message)

2813 - Type '11': MAC address report (special format)

2814 - Type '12': Version response (includes version string)

2815 - Type '13': Basic message (no timestamp)

2816 - Type '20+': Timestamped message (high precision)

2817

2818 The method automatically handles timestamp generation and message

2819 formatting based on the message type.

2820 """

2821 if msg_type is None:

2822 msg_type = "12" if message else "10"

2823

2824 assert msg_type in LOOKUP_PUZZ, f"Invalid/deprecated Puzzle type: {msg_type}"

2825

2826 payload = f"00{msg_type}"

2827

2828 if int(msg_type, 16) >= int("20", 16):

2829 payload += f"{int(timestamp() * 1e7):012X}"

2830 elif msg_type != "13":

2831 payload += f"{int(timestamp() * 1000):012X}"

2832

2833 if msg_type == "10":

2834 payload += hex_from_str(f"v{VERSION}")

2835 elif msg_type == "11":

2836 payload += hex_from_str(message[:4] + message[5:7] + message[8:])

2837 else:

2838 payload += hex_from_str(message)

2839

2840 return cls.from_attrs(I_, ALL_DEV_ADDR.id, Code._PUZZ, payload[:48])

2841

2842

2843# A convenience dict

2844 CODE_API_MAP = {

2845 f"{RP}|{Code._3EF1}": Command.put_actuator_cycle, # . has a test (RP, not I)

2846 f"{I_}|{Code._3EF0}": Command.put_actuator_state,

2847 f"{I_}|{Code._1FC9}": Command.put_bind,

2848 f"{W_}|{Code._1FC9}": Command.put_bind, # NOTE: same class method as I|1FC9

2849 f"{W_}|{Code._22F7}": Command.set_bypass_position,

2850 f"{I_}|{Code._1298}": Command.put_co2_level, # . has a test

2851 f"{RQ}|{Code._1F41}": Command.get_dhw_mode,

2852 f"{W_}|{Code._1F41}": Command.set_dhw_mode, # . has a test

2853 f"{RQ}|{Code._10A0}": Command.get_dhw_params,

2854 f"{W_}|{Code._10A0}": Command.set_dhw_params, # . has a test

2855 f"{RQ}|{Code._1260}": Command.get_dhw_temp,

2856 f"{I_}|{Code._1260}": Command.put_dhw_temp, # . has a test (empty)

2857 f"{I_}|{Code._22F1}": Command.set_fan_mode,

2858 f"{W_}|{Code._2411}": Command.set_fan_param,

2859 f"{I_}|{Code._12A0}": Command.put_indoor_humidity, # . has a test

2860 f"{RQ}|{Code._1030}": Command.get_mix_valve_params,

2861 f"{W_}|{Code._1030}": Command.set_mix_valve_params, # . has a test

2862 f"{RQ}|{Code._3220}": Command.get_opentherm_data,

2863 f"{I_}|{Code._1290}": Command.put_outdoor_temp,

2864 f"{I_}|{Code._2E10}": Command.put_presence_detected,

2865 f"{RQ}|{Code._0008}": Command.get_relay_demand,

2866 f"{RQ}|{Code._0404}": Command.get_schedule_fragment, # . has a test

2867 f"{W_}|{Code._0404}": Command.set_schedule_fragment,

2868 f"{RQ}|{Code._0006}": Command.get_schedule_version,

2869 f"{I_}|{Code._30C9}": Command.put_sensor_temp, # . has a test

2870 f"{RQ}|{Code._0100}": Command.get_system_language,

2871 f"{RQ}|{Code._0418}": Command.get_system_log_entry,

2872 f"{RQ}|{Code._2E04}": Command.get_system_mode, # . has a test

2873 f"{W_}|{Code._2E04}": Command.set_system_mode,

2874 f"{RQ}|{Code._313F}": Command.get_system_time,

2875 f"{W_}|{Code._313F}": Command.set_system_time, # . has a test

2876 f"{RQ}|{Code._1100}": Command.get_tpi_params,

2877 f"{W_}|{Code._1100}": Command.set_tpi_params, # . has a test

2878 f"{I_}|{Code._0002}": Command.put_weather_temp,

2879 f"{RQ}|{Code._000A}": Command.get_zone_config,

2880 f"{W_}|{Code._000A}": Command.set_zone_config, # . has a test

2881 f"{RQ}|{Code._2349}": Command.get_zone_mode,

2882 f"{W_}|{Code._2349}": Command.set_zone_mode, # . has a test

2883 f"{RQ}|{Code._0004}": Command.get_zone_name,

2884 f"{W_}|{Code._0004}": Command.set_zone_name, # . has a test

2885 f"{RQ}|{Code._2309}": Command.get_zone_setpoint,

2886 f"{W_}|{Code._2309}": Command.set_zone_setpoint, # . has a test

2887 f"{RQ}|{Code._30C9}": Command.get_zone_temp,

2888 f"{RQ}|{Code._12B0}": Command.get_zone_window_state,

2889 f"{I_}|{Code._31DA}": Command.get_hvac_fan_31da, # . has a test

2890} # TODO: RQ|0404 (Zone & DHW)