#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
"""RAMSES RF - a RAMSES-II protocol decoder & analyser.

Heating devices.
"""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Callable

from ..const import (
    DEV_ROLE_MAP,
    DEV_TYPE,
    DEV_TYPE_MAP,
    DOMAIN_TYPE_MAP,
    SZ_DEVICES,
    SZ_DOMAIN_ID,
    SZ_HEAT_DEMAND,
    SZ_PRESSURE,
    SZ_PRIORITY,
    SZ_RELAY_DEMAND,
    SZ_RETRIES,
    SZ_SETPOINT,
    SZ_TEMPERATURE,
    SZ_UFH_IDX,
    SZ_WINDOW_OPEN,
    SZ_ZONE_IDX,
    SZ_ZONE_MASK,
    SZ_ZONE_TYPE,
    ZON_ROLE_MAP,
    __dev_mode__,
)
from ..entity_base import Entity, Parent, class_by_attr
from ..helpers import shrink
from ..protocol.address import NON_DEV_ADDR
from ..protocol.command import Command, Priority, _mk_cmd
from ..protocol.const import SZ_BINDINGS
from ..protocol.exceptions import InvalidPayloadError
from ..protocol.opentherm import (
    MSG_ID,
    MSG_NAME,
    MSG_TYPE,
    PARAMS_MSG_IDS,
    SCHEMA_MSG_IDS,
    STATUS_MSG_IDS,
    VALUE,
    OtMsgType,
)
from ..protocol.ramses import CODES_OF_HEAT_DOMAIN_ONLY, CODES_ONLY_FROM_CTL
from ..schemas import SCH_TCS, SZ_ACTUATORS, SZ_CIRCUITS
from .base import BatteryState, Device, DeviceHeat, Fakeable

# skipcq: PY-W2000
from ..const import (  # noqa: F401, isort: skip, pylint: disable=unused-import
    I_,
    RP,
    RQ,
    W_,
    F9,
    FA,
    FC,
    FF,
    Code,
)

if TYPE_CHECKING:
    from ..protocol import Address, Message
    from ..system import Zone


SZ_BURNER_HOURS = "burner_hours"
SZ_BURNER_STARTS = "burner_starts"
SZ_BURNER_FAILED_STARTS = "burner_failed_starts"
SZ_CH_PUMP_HOURS = "ch_pump_hours"
SZ_CH_PUMP_STARTS = "ch_pump_starts"
SZ_DHW_BURNER_HOURS = "dhw_burner_hours"
SZ_DHW_BURNER_STARTS = "dhw_burner_starts"
SZ_DHW_PUMP_HOURS = "dhw_pump_hours"
SZ_DHW_PUMP_STARTS = "dhw_pump_starts"
SZ_FLAME_SIGNAL_LOW = "flame_signal_low"

SZ_BOILER_OUTPUT_TEMP = "boiler_output_temp"
SZ_BOILER_RETURN_TEMP = "boiler_return_temp"
SZ_BOILER_SETPOINT = "boiler_setpoint"
SZ_CH_MAX_SETPOINT = "ch_max_setpoint"
SZ_CH_SETPOINT = "ch_setpoint"
SZ_CH_WATER_PRESSURE = "ch_water_pressure"
SZ_DHW_FLOW_RATE = "dhw_flow_rate"
SZ_DHW_SETPOINT = "dhw_setpoint"
SZ_DHW_TEMP = "dhw_temp"
SZ_MAX_REL_MODULATION = "max_rel_modularion"
SZ_OEM_CODE = "oem_code"
SZ_OUTSIDE_TEMP = "outside_temp"
SZ_REL_MODULATION_LEVEL = "rel_modulation_level"

SZ_CH_ACTIVE = "ch_active"
SZ_CH_ENABLED = "ch_enabled"
SZ_COOLING_ACTIVE = "cooling_active"
SZ_COOLING_ENABLED = "cooling_enabled"
SZ_DHW_ACTIVE = "dhw_active"
SZ_DHW_BLOCKING = "dhw_blocking"
SZ_DHW_ENABLED = "dhw_enabled"
SZ_FAULT_PRESENT = "fault_present"
SZ_FLAME_ACTIVE = "flame_active"
SZ_SUMMER_MODE = "summer_mode"
SZ_OTC_ACTIVE = "otc_active"


DEV_MODE = __dev_mode__  # and False

_LOGGER = logging.getLogger(__name__)
if DEV_MODE:
    _LOGGER.setLevel(logging.DEBUG)


class Actuator(Fakeable, DeviceHeat):  # 3EF0, 3EF1 (for 10:/13:)
    # .I --- 13:109598 --:------ 13:109598 3EF0 003 00C8FF                # event-driven, 00/C8
    # RP --- 13:109598 18:002563 --:------ 0008 002 00C8                  # 00/C8, as abobe
    # RP --- 13:109598 18:002563 --:------ 3EF1 007 0000BF-00BFC8FF       # 00/C8, as above

    # RP --- 10:048122 18:140805 --:------ 3EF1 007 007FFF-003C2A10       # 10:s only RP, always 7FFF
    # RP --- 13:109598 18:199952 --:------ 3EF1 007 0001B8-01B800FF       # 13:s only RP

    # RP --- 10:047707 18:199952 --:------ 3EF0 009 001110-0A00FF-033100  # 10:s only RP
    # RP --- 10:138926 34:010253 --:------ 3EF0 006 002E11-0000FF         # 10:s only RP
    # .I --- 13:209679 --:------ 13:209679 3EF0 003 00C8FF                # 13:s only  I

    ACTUATOR_CYCLE = "actuator_cycle"
    ACTUATOR_ENABLED = "actuator_enabled"  # boolean
    ACTUATOR_STATE = "actuator_state"
    MODULATION_LEVEL = "modulation_level"  # percentage (0.0-1.0)

    def _handle_msg(self, msg: Message) -> None:  # NOTE: active
        super()._handle_msg(msg)

        if isinstance(self, OtbGateway):
            return

        if (
            msg.code == Code._3EF0
            and msg.verb == I_  # will be a 13:
            and not self._faked
            and not self._gwy.config.disable_discovery
            and not self._gwy.config.disable_sending
        ):
            # self._make_cmd(Code._0008, qos={SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 1})
            self._make_cmd(Code._3EF1, qos={SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 1})

    @property
    def actuator_cycle(self) -> None | dict:  # 3EF1
        return self._msg_value(Code._3EF1)

    @property
    def actuator_state(self) -> None | dict:  # 3EF0
        return self._msg_value(Code._3EF0)

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.ACTUATOR_CYCLE: self.actuator_cycle,
            self.ACTUATOR_STATE: self.actuator_state,
        }


class HeatDemand(DeviceHeat):  # 3150

    HEAT_DEMAND = SZ_HEAT_DEMAND  # percentage valve open (0.0-1.0)

    @property
    def heat_demand(self) -> None | float:  # 3150
        return self._msg_value(Code._3150, key=self.HEAT_DEMAND)

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.HEAT_DEMAND: self.heat_demand,
        }


class Setpoint(DeviceHeat):  # 2309

    SETPOINT = SZ_SETPOINT  # degrees Celsius

    @property
    def setpoint(self) -> None | float:  # 2309
        return self._msg_value(Code._2309, key=self.SETPOINT)

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.SETPOINT: self.setpoint,
        }


class Weather(Fakeable, DeviceHeat):  # 0002

    TEMPERATURE = SZ_TEMPERATURE  # degrees Celsius

    def _bind(self):
        #
        #
        #

        def callback(msg):
            pass

        super()._bind()
        self._bind_request(Code._0002, callback=callback)

    @property
    def temperature(self) -> None | float:  # 0002
        return self._msg_value(Code._0002, key=self.TEMPERATURE)

    # @check_faking_enabled
    @temperature.setter
    def temperature(self, value) -> None:  # 0002
        if not self._faked:
            raise RuntimeError(f"Faking is not enabled for {self}")
        cmd = Command.put_outdoor_temp(self.id, value)
        # cmd = Command.put_zone_temp(
        #     self._gwy.hgi.id if self == self._gwy.hgi._faked_thm else self.id, value
        # )
        self._send_cmd(cmd)

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.TEMPERATURE: self.temperature,
        }


class RelayDemand(Fakeable, DeviceHeat):  # 0008
    # Some either 00/C8, others 00-C8
    # .I --- 01:145038 --:------ 01:145038 0008 002 0314  # ZON valve zone (ELE too?)
    # .I --- 01:145038 --:------ 01:145038 0008 002 F914  # HTG valve
    # .I --- 01:054173 --:------ 01:054173 0008 002 FA00  # DHW valve
    # .I --- 01:145038 --:------ 01:145038 0008 002 FC14  # appliance_relay

    # RP --- 13:109598 18:199952 --:------ 0008 002 0000
    # RP --- 13:109598 18:199952 --:------ 0008 002 00C8

    RELAY_DEMAND = SZ_RELAY_DEMAND  # percentage (0.0-1.0)

    def _setup_discovery_cmds(self) -> None:
        super()._setup_discovery_cmds()

        if not self._faked:  # discover_flag & Discover.STATUS and
            self._add_discovery_cmd(Command.get_relay_demand(self.id), 60 * 15)

    def _handle_msg(self, msg: Message) -> None:  # NOTE: active
        if msg.src.id == self.id:
            super()._handle_msg(msg)
            return

        if (
            self._gwy.config.disable_sending
            or not self._faked
            or self._child_id is None
            or self._child_id
            not in (
                v for k, v in msg.payload.items() if k in (SZ_DOMAIN_ID, SZ_ZONE_IDX)
            )
        ):
            return

        if msg.code == Code._3EF0 and msg.verb == I_:  # NOT RP
            # should't use for RP as RQ's might be polled quite often
            cmd = Command.get_relay_demand(self.id)
            self._send_cmd(cmd, qos={SZ_PRIORITY: Priority.LOW, SZ_RETRIES: 1})

        # elif msg.code == Code._0009:  # can only be I, from a controller
        # elif msg.code == Code._3B00...:

        if not self._faked or msg.verb != RQ:  # duplicated, above
            return

        # TODO: handle relay_failsafe, reply to RQs
        if msg.code == Code._0008 and msg.verb == RQ:  # NOTE: WIP for FAKING
            # 076  I --- 01:054173 --:------ 01:054173 0008 002 037C
            mod_level = msg.payload[self.RELAY_DEMAND]
            if mod_level is not None:
                mod_level = 1.0 if mod_level > 0 else 0

            cmd = Command.put_actuator_state(self.id, mod_level)
            qos = {SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 3}
            [self._send_cmd(cmd, **qos) for _ in range(1)]  # type: ignore[func-returns-value]

        elif msg.code == Code._3EF1 and msg.verb == RQ:  # NOTE: WIP for FAKING
            mod_level = 1.0

            cmd = Command.put_actuator_cycle(self.id, msg.src.id, mod_level, 600, 600)
            qos = {SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 3}
            [self._send_cmd(cmd, **qos) for _ in range(1)]  # type: ignore[func-returns-value]

    def _bind(self):
        # .I --- 01:054173 --:------ 01:054173 1FC9 018 03-0008-04D39D FC-3B00-04D39D 03-1FC9-04D39D
        # .W --- 13:123456 01:054173 --:------ 1FC9 006 00-3EF0-35E240
        # .I --- 01:054173 13:123456 --:------ 1FC9 006 00-FFFF-04D39D

        def callback(msg):
            pass

        super()._bind()
        self._bind_waiting(Code._3EF0, callback=callback)

    @property
    def relay_demand(self) -> None | float:  # 0008
        return self._msg_value(Code._0008, key=self.RELAY_DEMAND)

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.RELAY_DEMAND: self.relay_demand,
        }


class DhwTemperature(Fakeable, DeviceHeat):  # 1260

    TEMPERATURE = SZ_TEMPERATURE  # degrees Celsius

    def _bind(self):
        #
        #
        #

        def callback(msg):
            self.set_parent(msg.src, child_id=FA, is_sensor=True)

        super()._bind()
        self._bind_request(Code._1260, callback=callback)

    @property
    def temperature(self) -> None | float:  # 1260
        return self._msg_value(Code._1260, key=self.TEMPERATURE)

    # @check_faking_enabled
    @temperature.setter
    def temperature(self, value) -> None:  # 1260
        if not self._faked:
            raise RuntimeError(f"Faking is not enabled for {self}")
        self._send_cmd(Command.put_dhw_temp(value))
        # lf._send_cmd(Command.get_dhw_temp(self.ctl.id, self.zone.idx))

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.TEMPERATURE: self.temperature,
        }


class Temperature(Fakeable, DeviceHeat):  # 30C9
    def _bind(self):
        # .I --- 34:145039 --:------ 34:145039 1FC9 012 00-30C9-8A368F 00-1FC9-8A368F
        # .W --- 01:054173 34:145039 --:------ 1FC9 006 03-2309-04D39D  # real CTL
        # .I --- 34:145039 01:054173 --:------ 1FC9 006 00-30C9-8A368F

        def callback(msg):  # TODO: needs work
            """Use the accept pkt to determine the zone/domain id."""
            child_id = msg.payload[SZ_BINDINGS][0][0]
            self.set_parent(msg.src, child_id=child_id, is_sensor=True)

        super()._bind()
        self._bind_request(Code._30C9, callback=callback)

    @property
    def temperature(self) -> None | float:  # degrees Celsius
        return self._msg_value(Code._30C9, key=SZ_TEMPERATURE)

    # @check_faking_enabled
    @temperature.setter
    def temperature(self, value) -> None:
        if not self._faked:
            raise RuntimeError(f"Faking is not enabled for {self}")
        self._send_cmd(Command.put_sensor_temp(self.id, value))
        # lf._send_cmd(Command.get_zone_temp(self.ctl.id, self.zone.idx))

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            SZ_TEMPERATURE: self.temperature,
        }


class RfgGateway(DeviceHeat):  # RFG (30:)
    """The RFG100 base class."""

    _SLUG: str = DEV_TYPE.RFG


class Controller(DeviceHeat):  # CTL (01):
    """The Controller base class."""

    _SLUG: str = DEV_TYPE.CTL

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

        # self.ctl = None
        self.tcs = None  # TODO: = self?
        self._make_tcs_controller(**kwargs)  # NOTE: must create_from_schema first

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

        self.tcs._handle_msg(msg)

    def _make_tcs_controller(self, *, msg=None, **schema) -> None:  # CH/DHW
        """Attach a TCS (create/update as required) after passing it any msg."""

        def get_system(*, msg=None, **schema) -> Any:  # System:
            """Return a TCS (temperature control system), create it if required.

            Use the schema to create/update it, then pass it any msg to handle.

            TCSs are uniquely identified by a controller ID.
            If a TCS is created, attach it to this device (which should be a CTL).
            """

            from ..system import system_factory

            schema = shrink(SCH_TCS(schema))

            if not self.tcs:
                self.tcs = system_factory(self, msg=msg, **schema)

            elif schema:
                self.tcs._update_schema(**schema)

            if msg:
                self.tcs._handle_msg(msg)
            return self.tcs

        super()._make_tcs_controller(msg=None, **schema)

        self.tcs = get_system(msg=msg, **schema)


class Programmer(Controller):  # PRG (23):
    """The Controller base class."""

    _SLUG: str = DEV_TYPE.PRG


class UfhController(Parent, DeviceHeat):  # UFC (02):
    """The UFC class, the HCE80 that controls the UFH zones."""

    _SLUG: str = DEV_TYPE.UFC

    HEAT_DEMAND = SZ_HEAT_DEMAND

    _STATE_ATTR = SZ_HEAT_DEMAND

    # 12:27:24.398 067  I --- 02:000921 --:------ 01:191718 3150 002 0360
    # 12:27:24.546 068  I --- 02:000921 --:------ 01:191718 3150 002 065A
    # 12:27:24.693 067  I --- 02:000921 --:------ 01:191718 3150 002 045C
    # 12:27:24.824 059  I --- 01:191718 --:------ 01:191718 3150 002 FC5C
    # 12:27:24.857 067  I --- 02:000921 --:------ 02:000921 3150 006 0060-015A-025C

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

        self._child_id = FA  # NOTE: domain_id, HACK: UFC

        self.circuit_by_id = {f"{i:02X}": {} for i in range(8)}

        self._setpoints: Message = None  # type: ignore[assignment]
        self._heat_demand: Message = None  # type: ignore[assignment]
        self._heat_demands: Message = None  # type: ignore[assignment]
        self._relay_demand: Message = None  # type: ignore[assignment]
        self._relay_demand_fa: Message = None  # type: ignore[assignment]

        self._iz_controller = True

    def _setup_discovery_cmds(self) -> None:
        super()._setup_discovery_cmds()

        # Only RPs are: 0001, 0005/000C, 10E0, 000A/2309 & 22D0

        self._add_discovery_cmd(
            _mk_cmd(RQ, Code._0005, f"00{DEV_ROLE_MAP.UFH}", self.id), 60 * 60 * 24
        )
        # TODO: this needs work
        # if discover_flag & Discover.PARAMS:  # only 2309 has any potential?
        for ufc_idx in self.circuit_by_id:
            self._add_discovery_cmd(
                _mk_cmd(RQ, Code._000A, ufc_idx, self.id), 60 * 60 * 6
            )
            self._add_discovery_cmd(
                _mk_cmd(RQ, Code._2309, ufc_idx, self.id), 60 * 60 * 6
            )

        for ufc_idx in range(8):  # type: ignore[assignment]
            payload = f"{ufc_idx:02X}{DEV_ROLE_MAP.UFH}"
            self._add_discovery_cmd(
                _mk_cmd(RQ, Code._000C, payload, self.id), 60 * 60 * 24
            )

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

        # Several assumptions ar emade, regarding 000C pkts:
        # - UFC bound only to CTL (not, e.g. SEN)
        # - all circuits bound to the same controller

        if msg.code == Code._0005:  # system_zones
            # {'zone_type': '09', 'zone_mask': [1, 1, 1, 1, 1, 0, 0, 0], 'zone_class': 'underfloor_heating'}

            if msg.payload[SZ_ZONE_TYPE] not in (ZON_ROLE_MAP.ACT, ZON_ROLE_MAP.UFH):
                return  # ignoring ZON_ROLE_MAP.SEN for now

            for idx, flag in enumerate(msg.payload[SZ_ZONE_MASK]):
                ufh_idx = f"{idx:02X}"
                if not flag:
                    self.circuit_by_id[ufh_idx] = {SZ_ZONE_IDX: None}
                elif SZ_ZONE_IDX not in self.circuit_by_id[ufh_idx]:
                    self._make_cmd(Code._000C, payload=f"{ufh_idx}{DEV_ROLE_MAP.UFH}")

        elif msg.code == Code._0008:  # relay_demand, TODO: use msg DB?
            if msg.payload.get(SZ_DOMAIN_ID) == FC:
                self._relay_demand = msg
            else:  # FA
                self._relay_demand_fa = msg

        elif msg.code == Code._000C:  # zone_devices
            # {'zone_type': '09', 'ufh_idx': '00', 'zone_idx': '09', 'device_role': 'ufh_actuator', 'devices': ['01:095421']}
            # {'zone_type': '09', 'ufh_idx': '07', 'zone_idx': None, 'device_role': 'ufh_actuator', 'devices': []}

            if msg.payload[SZ_ZONE_TYPE] not in (ZON_ROLE_MAP.ACT, ZON_ROLE_MAP.UFH):
                return  # ignoring ZON_ROLE_MAP.SEN for now

            ufh_idx = msg.payload[SZ_UFH_IDX]  # circuit idx
            self.circuit_by_id[ufh_idx] = {SZ_ZONE_IDX: msg.payload[SZ_ZONE_IDX]}
            if msg.payload[SZ_ZONE_IDX] is not None:  # [SZ_DEVICES][0] will be the CTL
                self.set_parent(
                    self._gwy.get_device(msg.payload[SZ_DEVICES][0]).tcs,
                    # child_id=msg.payload[SZ_ZONE_IDX],
                )

        elif msg.code == Code._22C9:  # ufh_setpoints
            # .I --- 02:017205 --:------ 02:017205 22C9 024 00076C0A280101076C0A28010...
            # .I --- 02:017205 --:------ 02:017205 22C9 006 04076C0A2801
            self._setpoints = msg

        elif msg.code == Code._3150:  # heat_demands
            if isinstance(msg.payload, list):  # the circuit demands
                self._heat_demands = msg
            elif msg.payload.get(SZ_DOMAIN_ID) == FC:
                self._heat_demand = msg
            elif (
                (zone_idx := msg.payload.get(SZ_ZONE_IDX))
                and (tcs := msg.dst.tcs)
                and (zone := tcs.zone_by_idx.get(zone_idx))
            ):
                zone._handle_msg(msg)

        # elif msg.code not in (Code._10E0, Code._22D0):
        #     print("xxx")

        # "0008|FA/FC", "22C9|array", "22D0|none", "3150|ZZ/array(/FC?)"

    def get_circuit(self, cct_idx, *, msg=None, **schema) -> Any:
        """Return a UFH circuit, create it if required.

        First, use the schema to create/update it, then pass it any msg to handle.

        Circuits are uniquely identified by a UFH controller ID|cct_idx pair.
        If a circuit is created, attach it to this UFC.
        """

        schema = {}  # shrink(SCH_CCT(schema))

        cct = self.child_by_id.get(cct_idx)
        if not cct:
            cct = UfhCircuit(self, cct_idx)
            self.child_by_id[cct_idx] = cct
            self.childs.append(cct)

        elif schema:
            cct._update_schema(**schema)

        if msg:
            cct._handle_msg(msg)
        return cct

    # @property
    # def circuits(self) -> dict:  # 000C
    #     return self.circuit_by_id

    @property
    def heat_demand(self) -> None | float:  # 3150|FC (there is also 3150|FA)
        return self._msg_value_msg(self._heat_demand, key=self.HEAT_DEMAND)

    @property
    def heat_demands(self) -> None | dict:  # 3150|ufh_idx array
        # return self._heat_demands.payload if self._heat_demands else None
        return self._msg_value_msg(self._heat_demands)

    @property
    def relay_demand(self) -> None | dict:  # 0008|FC
        return self._msg_value_msg(self._relay_demand, key=SZ_RELAY_DEMAND)

    @property
    def relay_demand_fa(self) -> None | dict:  # 0008|FA
        return self._msg_value_msg(self._relay_demand_fa, key=SZ_RELAY_DEMAND)

    @property
    def setpoints(self) -> None | dict:  # 22C9|ufh_idx array
        if self._setpoints is None:
            return

        return {
            c[SZ_UFH_IDX]: {
                k: v for k, v in c.items() if k in ("temp_low", "temp_high")
            }
            for c in self._setpoints.payload
        }

    @property  # id, type
    def schema(self) -> dict[str, Any]:
        return {
            **super().schema,
            SZ_CIRCUITS: self.circuit_by_id,
        }

    @property  # setpoint, config, mode (not schedule)
    def params(self) -> dict[str, Any]:
        return {
            **super().params,
            SZ_CIRCUITS: self.setpoints,
        }

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            SZ_HEAT_DEMAND: self.heat_demand,
            SZ_RELAY_DEMAND: self.relay_demand,
            f"{SZ_RELAY_DEMAND}_fa": self.relay_demand_fa,
        }


class DhwSensor(DhwTemperature, BatteryState):  # DHW (07): 10A0, 1260
    """The DHW class, such as a CS92."""

    _SLUG: str = DEV_TYPE.DHW

    DHW_PARAMS = "dhw_params"
    TEMPERATURE = SZ_TEMPERATURE

    _STATE_ATTR = SZ_TEMPERATURE

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

        self._child_id = FA  # NOTE: domain_id

    def _handle_msg(self, msg: Message) -> None:  # NOTE: active
        super()._handle_msg(msg)

        # The following is required, as CTLs don't send such every sync_cycle
        if msg.code == Code._1260 and self.ctl and not self._gwy.config.disable_sending:
            # update the controller DHW temp
            self._send_cmd(Command.get_dhw_temp(self.ctl.id))

    @property
    def dhw_params(self) -> None | dict:  # 10A0
        return self._msg_value(Code._10A0)

    @property
    def params(self) -> dict[str, Any]:
        return {
            **super().params,
            self.DHW_PARAMS: self.dhw_params,
        }


class OutSensor(Weather):  # OUT: 17
    """The OUT class (external sensor), such as a HB85/HB95."""

    _SLUG: str = DEV_TYPE.OUT

    # LUMINOSITY = "luminosity"  # lux
    # WINDSPEED = "windspeed"  # km/h

    _STATE_ATTR = SZ_TEMPERATURE


class OtbGateway(Actuator, HeatDemand):  # OTB (10): 3220 (22D9, others)
    """The OTB class, specifically an OpenTherm Bridge (R8810A Bridge)."""

    # see: https://www.opentherm.eu/request-details/?post_ids=2944
    # see: https://www.automatedhome.co.uk/vbulletin/showthread.php?6400-(New)-cool-mode-in-Evohome

    _SLUG: str = DEV_TYPE.OTB

    _STATE_ATTR = SZ_REL_MODULATION_LEVEL

    OT_TO_RAMSES = {
        "00": Code._3EF0,  # master/slave status (actuator_state)
        "01": Code._22D9,  # boiler_setpoint
        "0E": Code._3EF0,  # max_rel_modulation_level (is a PARAM?)
        "11": Code._3EF0,  # rel_modulation_level (actuator_state, also Code._3EF1)
        "12": Code._1300,  # ch_water_pressure
        "13": Code._12F0,  # dhw_flow_rate
        "19": Code._3200,  # boiler_output_temp
        "1A": Code._1260,  # dhw_temp
        "1B": Code._1290,  # outside_temp
        "1C": Code._3210,  # boiler_return_temp
        "38": Code._10A0,  # dhw_setpoint (is a PARAM)
        "39": Code._1081,  # ch_max_setpoint (is a PARAM)
    }
    RAMSES_TO_OT = {v: k for k, v in OT_TO_RAMSES.items() if v != Code._3EF0}

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)

        self._child_id = FC  # NOTE: domain_id

        self._msgz[str(Code._3220)] = {RP: {}}  # self._msgz[Code._3220][RP][msg_id]

        # lf._use_ot = self._gwy.config.use_native_ot
        self._msgs_ot: dict[str, Message] = {}
        # lf._msgs_ot_ctl_polled = {}

    def _setup_discovery_cmds(self) -> None:
        def which_cmd(use_native_ot: str, msg_id: str) -> None | Command:
            """Create a OT cmd, or its RAMSES equivalent, depending."""
            # we know RQ|3220 is an option, question is: use that, or RAMSES or nothing?
            if use_native_ot in ("always", "prefer"):
                return Command.get_opentherm_data(self.id, msg_id)
            if msg_id in self.OT_TO_RAMSES:  # is: in ("avoid", "never")
                return _mk_cmd(RQ, self.OT_TO_RAMSES[msg_id], "00", self.id)
            if use_native_ot == "avoid":
                return Command.get_opentherm_data(self.id, msg_id)
            return None  # use_native_ot == "never"

        super()._setup_discovery_cmds()

        # always send RQ|3EF0 and RQ|3220|00 (status), regardless of use_native_ot
        self._add_discovery_cmd(Command.get_opentherm_data(self.id, "00"), 60)
        self._add_discovery_cmd(_mk_cmd(RQ, Code._3EF0, "00", self.id), 60)

        for msg_id in SCHEMA_MSG_IDS:  # From OT v2.2: version numbers
            if cmd := which_cmd(self._gwy.config.use_native_ot, msg_id):
                self._add_discovery_cmd(cmd, 24 * 3600, delay=180)

        for msg_id in PARAMS_MSG_IDS:  # params or L/T state
            if cmd := which_cmd(self._gwy.config.use_native_ot, msg_id):
                self._add_discovery_cmd(cmd, 3600, delay=90)

        for msg_id in STATUS_MSG_IDS:  # except "00", see above
            if msg_id == "00":
                continue
            if cmd := which_cmd(self._gwy.config.use_native_ot, msg_id):
                self._add_discovery_cmd(cmd, 300, delay=15)

        if DEV_MODE:  # TODO: these are WIP, but do vary in payload
            for code in (
                Code._2401,  # WIP - modulation_level + flags?
                Code._3221,  # R8810A/20A
                Code._3223,  # R8810A/20A
            ):
                self._add_discovery_cmd(_mk_cmd(RQ, code, "00", self.id), 60)

        if False and DEV_MODE:  # TODO: these are WIP, appear FIXED in payload
            for code in (
                Code._0150,  # payload always "000000", R8820A only?
                Code._1098,  # payload always "00C8",   R8820A only?
                Code._10B0,  # payload always "0000",   R8820A only?
                Code._1FD0,  # payload always "0000000000000000"
                Code._2400,  # payload always "0000000F"
                Code._2410,  # payload always "000000000000000000000000010000000100000C"
                Code._2420,  # payload always "0000001000000...
            ):  # TODO: to test against BDR91T
                cmd = _mk_cmd(RQ, code, "00", self.id)
                self._add_discovery_cmd(cmd, 300, delay=300)

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

        (self._handle_3220 if msg.code == Code._3220 else self._handle_code)(msg)

    def _handle_3220(self, msg: Message) -> None:
        if msg.payload[MSG_TYPE] == OtMsgType.RESERVED:  # workaround
            return

        msg_id = f"{msg.payload[MSG_ID]:02X}"
        self._msgs_ot[msg_id] = msg

        if DEV_MODE:  # here to follow state changes
            self._send_cmd(_mk_cmd(RQ, Code._2401, "00", self.id))  # oem code
            if msg_id != "73":
                self._send_cmd(Command.get_opentherm_data(self.id, "73"))  # oem code

            # TODO: this is development code - will be rationalised, eventually
            if self._gwy.config.use_native_ot and (
                code := self.OT_TO_RAMSES.get(msg_id)
            ):
                self._send_cmd(_mk_cmd(RQ, code, "00", self.id))

        if msg._pkt.payload[6:] == "47AB" or msg._pkt.payload[4:] == "121980":
            self.deprecate_cmd(msg._pkt, ctx=msg_id)

        else:
            # 18:50:32.524 ... RQ --- 18:013393 10:048122 --:------ 3220 005 0080730000
            # 18:50:32.547 ... RP --- 10:048122 18:013393 --:------ 3220 005 00B0730000  # -reserved-
            # 18:55:32.601 ... RQ --- 18:013393 10:048122 --:------ 3220 005 0080730000
            # 18:55:32.630 ... RP --- 10:048122 18:013393 --:------ 3220 005 00C07300CB  # Read-Ack, 'value': 203
            reset = msg.payload[MSG_TYPE] not in (
                OtMsgType.DATA_INVALID,
                OtMsgType.UNKNOWN_DATAID,
                # OtMsgType.RESERVED,  # some always reserved, others sometimes so
            )
            self.deprecate_cmd(msg._pkt, ctx=msg_id, reset=reset)

    def _handle_code(self, msg: Message) -> None:
        if msg.code == Code._3EF0 and msg.verb == I_:  # chasing flags
            self._send_cmd(
                Command.get_opentherm_data(self.id, "00"),
                qos={SZ_PRIORITY: Priority.HIGH, SZ_RETRIES: 1},
            )
            return

        if msg.code in (Code._10A0, Code._3EF1) or msg.len != 3:
            return

        if msg._pkt.payload[2:] == "7FFF" or (
            msg.code == Code._1300 and msg._pkt.payload[2:] == "09F6"
        ):
            self.deprecate_cmd(msg._pkt)
        else:
            self.deprecate_cmd(msg._pkt, reset=True)

    def _ot_msg_flag(self, msg_id, flag_idx) -> None | bool:
        if flags := self._ot_msg_value(msg_id):
            return bool(flags[flag_idx])
        return None

    @staticmethod
    def _ot_msg_name(msg) -> str:
        return (
            msg.payload[MSG_NAME]
            if isinstance(msg.payload[MSG_NAME], str)
            else f"{msg.payload[MSG_ID]:02X}"
        )

    def _ot_msg_value(self, msg_id) -> None | int | float | list:
        if (
            self.is_pollable_cmd(Code._3220, ctx=msg_id)
            and self._msgs_ot.get(msg_id)
            and not self._msgs_ot[msg_id]._expired
        ):
            return self._msgs_ot[msg_id].payload.get(VALUE)  # TODO: value_hb/_lb

    def _result_by_callback(
        self, cbk_ot: None | Callable, cbk_ramses: None | Callable
    ) -> None | Any:
        """Return a value using OpenTherm or RAMSES as per `config.use_native_ot`."""

        if self._gwy.config.use_native_ot == "always":
            return cbk_ot() if cbk_ot else None
        if self._gwy.config.use_native_ot == "prefer":
            if cbk_ot and (result := cbk_ot()) is not None:
                return result

        result_ramses = cbk_ramses() if cbk_ramses is not None else None
        if self._gwy.config.use_native_ot == "avoid" and result_ramses is None:
            return cbk_ot() if cbk_ot else None
        return result_ramses  # incl. use_native_ot == "never"

    def _result_by_lookup(self, code, *args, **kwargs) -> None | Any:
        """Return a value using OpenTherm or RAMSES as per `config.use_native_ot`."""
        # assert code in self.RAMSES_TO_OT and kwargs.get("key"):

        if self._gwy.config.use_native_ot == "always":
            return self._ot_msg_value(self.RAMSES_TO_OT[code])
        if self._gwy.config.use_native_ot == "prefer":
            if (result_ot := self._ot_msg_value(self.RAMSES_TO_OT[code])) is not None:
                return result_ot

        result_ramses = self._msg_value(code, *args, **kwargs)
        if self._gwy.config.use_native_ot == "avoid" and result_ramses is None:
            return self._ot_msg_value(self.RAMSES_TO_OT[code])
        return result_ramses  # incl. use_native_ot == "never"

    def _result_by_value(
        self, result_ot: None | Any, result_ramses: None | Any
    ) -> None | Any:
        """Return a value using OpenTherm or RAMSES as per `config.use_native_ot`."""

        if self._gwy.config.use_native_ot == "always":
            return result_ot
        if self._gwy.config.use_native_ot == "prefer":
            if result_ot is not None:
                return result_ot

        elif self._gwy.config.use_native_ot == "avoid" and result_ramses is None:
            return result_ot
        return result_ramses  # incl. use_native_ot == "never"

    @property  # TODO
    def bit_2_4(self) -> None | bool:  # 2401 - WIP
        return self._msg_flag(Code._2401, "_flags_2", 4)

    @property  # TODO
    def bit_2_5(self) -> None | bool:  # 2401 - WIP
        return self._msg_flag(Code._2401, "_flags_2", 5)

    @property  # TODO
    def bit_2_6(self) -> None | bool:  # 2401 - WIP
        return self._msg_flag(Code._2401, "_flags_2", 6)

    @property  # TODO
    def bit_2_7(self) -> None | bool:  # 2401 - WIP
        return self._msg_flag(Code._2401, "_flags_2", 7)

    @property  # TODO
    def bit_3_7(self) -> None | bool:  # 3EF0 (byte 3, only OTB)
        return self._msg_flag(Code._3EF0, "_flags_3", 7)

    @property  # TODO
    def bit_6_6(self) -> None | bool:  # 3EF0 ?dhw_enabled (byte 3, only R8820A?)
        return self._msg_flag(Code._3EF0, "_flags_3", 6)

    @property  # TODO
    def percent(self) -> None | float:  # 2401 - WIP
        return self._msg_value(Code._2401, key="_percent_3")

    @property  # TODO
    def value(self) -> None | int:  # 2401 - WIP
        return self._msg_value(Code._2401, key="_value_2")

    @property
    def boiler_output_temp(self) -> None | float:  # 3220|19, or 3200
        return self._result_by_lookup(Code._3200, key=SZ_TEMPERATURE)

    @property
    def boiler_return_temp(self) -> None | float:  # 3220|1C, or 3210
        return self._result_by_lookup(Code._3210, key=SZ_TEMPERATURE)

    @property
    def boiler_setpoint(self) -> None | float:  # 3220|01, or 22D9
        return self._result_by_lookup(Code._22D9, key=SZ_SETPOINT)

    @property
    def ch_max_setpoint(self) -> None | float:  # 3220|39, or 1081
        return self._result_by_lookup(Code._1081, key=SZ_SETPOINT)

    @property  # TODO
    def ch_setpoint(self) -> None | float:  # 3EF0 (byte 7, only R8820A?), TODO: no OT
        return self._result_by_value(
            None, self._msg_value(Code._3EF0, key=SZ_CH_SETPOINT)
        )

    @property
    def ch_water_pressure(self) -> None | float:  # 3220|12, or 1300
        result = self._result_by_lookup(Code._1300, key=SZ_PRESSURE)
        return None if result == 25.5 else result  # HACK: to make more rigourous

    @property
    def dhw_flow_rate(self) -> None | float:  # 3220|13, or 12F0
        return self._result_by_lookup(Code._12F0, key=SZ_DHW_FLOW_RATE)

    @property
    def dhw_setpoint(self) -> None | float:  # 3220|38, or 10A0
        return self._result_by_lookup(Code._10A0, key=SZ_SETPOINT)

    @property
    def dhw_temp(self) -> None | float:  # 3220|1A, or 1260
        return self._result_by_lookup(Code._1260, key=SZ_TEMPERATURE)

    @property  # TODO
    def max_rel_modulation(
        self,
    ) -> None | float:  # 3220|0E, or 3EF0 (byte 8, only R8820A?)
        if self._gwy.config.use_native_ot == "prefer":  # HACK
            return self._msg_value(Code._3EF0, key=SZ_MAX_REL_MODULATION)
        return self._result_by_value(
            self._ot_msg_value("0E"),
            self._msg_value(Code._3EF0, key=SZ_MAX_REL_MODULATION),
        )

    @property
    def oem_code(self) -> None | float:  # 3220|73, no known RAMSES equivalent
        return self._ot_msg_value("73")

    @property
    def outside_temp(self) -> None | float:  # 3220|1B, 1290
        return self._result_by_lookup(Code._1290, key=SZ_TEMPERATURE)

    @property  # HACK
    def rel_modulation_level(self) -> None | float:  # 3220|11, or 3EF0/3EF1
        if self._gwy.config.use_native_ot == "prefer":  # HACK (there'll always be 3EF0)
            return self._msg_value((Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL)
        return self._result_by_value(
            self._ot_msg_value("11"),
            self._msg_value((Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL),
        )

    @property  # HACK
    def ch_active(self) -> None | bool:  # 3220|00, or 3EF0 (byte 3, only R8820A?)
        if self._gwy.config.use_native_ot == "prefer":  # HACK (there'll always be 3EF0)
            return self._msg_value(Code._3EF0, key=SZ_CH_ACTIVE)
        return self._result_by_value(
            self._ot_msg_flag("00", 8 + 1),
            self._msg_value(Code._3EF0, key=SZ_CH_ACTIVE),
        )

    @property  # HACK
    def ch_enabled(self) -> None | bool:  # 3220|00, or 3EF0 (byte 6, only R8820A?)
        if self._gwy.config.use_native_ot == "prefer":  # HACK (there'll always be 3EF0)
            return self._msg_value(Code._3EF0, key=SZ_CH_ENABLED)
        return self._result_by_value(
            self._ot_msg_flag("00", 0),
            self._msg_value(Code._3EF0, key=SZ_CH_ENABLED),
        )

    @property
    def cooling_active(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 8 + 4), None)

    @property
    def cooling_enabled(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 2), None)

    @property  # HACK
    def dhw_active(self) -> None | bool:  # 3220|00, or 3EF0 (byte 3, only OTB)
        if self._gwy.config.use_native_ot == "prefer":  # HACK (there'll always be 3EF0)
            return self._msg_value(Code._3EF0, key=SZ_DHW_ACTIVE)
        return self._result_by_value(
            self._ot_msg_flag("00", 8 + 2),
            self._msg_value(Code._3EF0, key=SZ_DHW_ACTIVE),
        )

    @property
    def dhw_blocking(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 6), None)

    @property
    def dhw_enabled(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 1), None)

    @property
    def fault_present(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 8), None)

    @property  # HACK
    def flame_active(self) -> None | bool:  # 3220|00, or 3EF0 (byte 3, only OTB)
        if self._gwy.config.use_native_ot == "prefer":  # HACK (there'll always be 3EF0)
            return self._msg_value(Code._3EF0, key=SZ_FLAME_ACTIVE)
        return self._result_by_value(
            self._ot_msg_flag("00", 8 + 3),
            self._msg_value(Code._3EF0, key=SZ_FLAME_ACTIVE),
        )

    @property
    def otc_active(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 3), None)

    @property
    def summer_mode(self) -> None | bool:  # 3220|00, TODO: no known RAMSES
        return self._result_by_value(self._ot_msg_flag("00", 5), None)

    @property
    def opentherm_schema(self) -> dict:
        result = {
            self._ot_msg_name(v): v.payload
            for k, v in self._msgs_ot.items()
            if self._supported_cmds_ctx.get(int(k, 16)) and int(k, 16) in SCHEMA_MSG_IDS
        }
        return {
            m: {k: v for k, v in p.items() if k.startswith(VALUE)}
            for m, p in result.items()
        }

    @property
    def opentherm_counters(self) -> dict:
        # for msg_id in ("71", "72", ...):
        return {
            SZ_BURNER_HOURS: self._ot_msg_value("78"),
            SZ_BURNER_STARTS: self._ot_msg_value("74"),
            SZ_BURNER_FAILED_STARTS: self._ot_msg_value("71"),
            SZ_CH_PUMP_HOURS: self._ot_msg_value("79"),
            SZ_CH_PUMP_STARTS: self._ot_msg_value("75"),
            SZ_DHW_BURNER_HOURS: self._ot_msg_value("7B"),
            SZ_DHW_BURNER_STARTS: self._ot_msg_value("77"),
            SZ_DHW_PUMP_HOURS: self._ot_msg_value("7A"),
            SZ_DHW_PUMP_STARTS: self._ot_msg_value("76"),
            SZ_FLAME_SIGNAL_LOW: self._ot_msg_value("72"),
        }  # 0x73 is OEM diagnostic code...

    @property
    def opentherm_params(self) -> dict:
        result = {
            self._ot_msg_name(v): v.payload
            for k, v in self._msgs_ot.items()
            if self._supported_cmds_ctx.get(int(k, 16)) and int(k, 16) in PARAMS_MSG_IDS
        }
        return {
            m: {k: v for k, v in p.items() if k.startswith(VALUE)}
            for m, p in result.items()
        }

    @property
    def opentherm_status(self) -> dict:
        return {
            SZ_BOILER_OUTPUT_TEMP: self._ot_msg_value("19"),
            SZ_BOILER_RETURN_TEMP: self._ot_msg_value("1C"),
            SZ_BOILER_SETPOINT: self._ot_msg_value("01"),
            SZ_CH_MAX_SETPOINT: self._ot_msg_value("39"),
            SZ_CH_WATER_PRESSURE: self._ot_msg_value("12"),
            SZ_DHW_FLOW_RATE: self._ot_msg_value("13"),
            SZ_DHW_SETPOINT: self._ot_msg_value("38"),
            SZ_DHW_TEMP: self._ot_msg_value("1A"),
            SZ_OEM_CODE: self._ot_msg_value("73"),
            SZ_OUTSIDE_TEMP: self._ot_msg_value("1B"),
            SZ_REL_MODULATION_LEVEL: self._ot_msg_value("11"),
            #
            SZ_CH_ACTIVE: self._ot_msg_flag("00", 8 + 1),
            SZ_CH_ENABLED: self._ot_msg_flag("00", 0),
            SZ_COOLING_ACTIVE: self._ot_msg_flag("00", 8 + 4),
            SZ_COOLING_ENABLED: self._ot_msg_flag("00", 2),
            SZ_DHW_ACTIVE: self._ot_msg_flag("00", 8 + 2),
            SZ_DHW_BLOCKING: self._ot_msg_flag("00", 6),
            SZ_DHW_ENABLED: self._ot_msg_flag("00", 1),
            SZ_FAULT_PRESENT: self._ot_msg_flag("00", 8),
            SZ_FLAME_ACTIVE: self._ot_msg_flag("00", 8 + 3),
            SZ_SUMMER_MODE: self._ot_msg_flag("00", 5),
            SZ_OTC_ACTIVE: self._ot_msg_flag("00", 3),
        }

    @property
    def ramses_schema(self) -> dict:
        return {}

    @property
    def ramses_params(self) -> dict:
        return {
            SZ_MAX_REL_MODULATION: self.max_rel_modulation,
        }

    @property
    def ramses_status(self) -> dict:
        return {
            SZ_BOILER_OUTPUT_TEMP: self._msg_value(Code._3200, key=SZ_TEMPERATURE),
            SZ_BOILER_RETURN_TEMP: self._msg_value(Code._3210, key=SZ_TEMPERATURE),
            SZ_BOILER_SETPOINT: self._msg_value(Code._22D9, key=SZ_SETPOINT),
            SZ_CH_MAX_SETPOINT: self._msg_value(Code._1081, key=SZ_SETPOINT),
            SZ_CH_SETPOINT: self._msg_value(Code._3EF0, key=SZ_CH_SETPOINT),
            SZ_CH_WATER_PRESSURE: self._msg_value(Code._1300, key=SZ_PRESSURE),
            SZ_DHW_FLOW_RATE: self._msg_value(Code._12F0, key=SZ_DHW_FLOW_RATE),
            SZ_DHW_SETPOINT: self._msg_value(Code._1300, key=SZ_SETPOINT),
            SZ_DHW_TEMP: self._msg_value(Code._1260, key=SZ_TEMPERATURE),
            SZ_OUTSIDE_TEMP: self._msg_value(Code._1290, key=SZ_TEMPERATURE),
            SZ_REL_MODULATION_LEVEL: self._msg_value(
                (Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL
            ),
            #
            SZ_CH_ACTIVE: self._msg_value(Code._3EF0, key=SZ_CH_ACTIVE),
            SZ_CH_ENABLED: self._msg_value(Code._3EF0, key=SZ_CH_ENABLED),
            SZ_DHW_ACTIVE: self._msg_value(Code._3EF0, key=SZ_DHW_ACTIVE),
            SZ_FLAME_ACTIVE: self._msg_value(Code._3EF0, key=SZ_FLAME_ACTIVE),
        }

    @property
    def traits(self) -> dict[str, Any]:
        return {
            **super().traits,
            "opentherm_traits": self.supported_cmds_ot,
            "ramses_ii_traits": self.supported_cmds,
        }

    @property
    def schema(self) -> dict[str, Any]:
        return {
            **super().schema,
            "opentherm_schema": self.opentherm_schema,
            "ramses_ii_schema": self.ramses_schema,
        }

    @property
    def params(self) -> dict[str, Any]:
        return {
            **super().params,
            "opentherm_params": self.opentherm_params,
            "ramses_ii_params": self.ramses_params,
        }

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,  # incl. actuator_cycle, actuator_state
            #
            SZ_BOILER_OUTPUT_TEMP: self.boiler_output_temp,
            SZ_BOILER_RETURN_TEMP: self.boiler_return_temp,
            SZ_BOILER_SETPOINT: self.boiler_setpoint,
            SZ_CH_SETPOINT: self.ch_setpoint,
            SZ_CH_MAX_SETPOINT: self.ch_max_setpoint,
            SZ_CH_WATER_PRESSURE: self.ch_water_pressure,
            SZ_DHW_FLOW_RATE: self.dhw_flow_rate,
            SZ_DHW_SETPOINT: self.dhw_setpoint,
            SZ_DHW_TEMP: self.dhw_temp,
            SZ_OEM_CODE: self.oem_code,
            SZ_OUTSIDE_TEMP: self.outside_temp,
            SZ_REL_MODULATION_LEVEL: self.rel_modulation_level,
            #
            SZ_CH_ACTIVE: self.ch_active,
            SZ_CH_ENABLED: self.ch_enabled,
            SZ_COOLING_ACTIVE: self.cooling_active,
            SZ_COOLING_ENABLED: self.cooling_enabled,
            SZ_DHW_ACTIVE: self.dhw_active,
            SZ_DHW_BLOCKING: self.dhw_blocking,
            SZ_DHW_ENABLED: self.dhw_enabled,
            SZ_FAULT_PRESENT: self.fault_present,
            SZ_FLAME_ACTIVE: self.flame_active,
            SZ_SUMMER_MODE: self.summer_mode,
            SZ_OTC_ACTIVE: self.otc_active,
            #
            # "status_opentherm": self.opentherm_status,
            # "status_ramses_ii": self.ramses_status,
        }


class Thermostat(BatteryState, Setpoint, Temperature):  # THM (..):
    """The THM/STA class, such as a TR87RF."""

    _SLUG: str = DEV_TYPE.THM

    _STATE_ATTR = SZ_TEMPERATURE

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

        if msg.verb != I_ or self._iz_controller is not None:
            return

        # NOTE: this has only been tested on a 12:, does it work for a 34: too?
        if all(
            (
                msg._addrs[0] is self.addr,
                msg._addrs[1] is NON_DEV_ADDR,
                msg._addrs[2] is self.addr,
            )
        ):
            if self._iz_controller is None:
                # _LOGGER.info(f"{msg!r} # IS_CONTROLLER (10): is FALSE")
                self._iz_controller = False
            elif self._iz_controller:  # TODO: raise CorruptStateError
                _LOGGER.error(f"{msg!r} # IS_CONTROLLER (11): was TRUE, now False")

            if msg.code in CODES_ONLY_FROM_CTL:  # TODO: raise CorruptPktError
                _LOGGER.error(f"{msg!r} # IS_CONTROLLER (12); is CORRUPT PKT")

        elif all(
            (
                msg._addrs[0] is NON_DEV_ADDR,
                msg._addrs[1] is NON_DEV_ADDR,
                msg._addrs[2] is self.addr,
            )
        ):
            if self._iz_controller is None:
                # _LOGGER.info(f"{msg!r} # IS_CONTROLLER (20): is TRUE")
                self._iz_controller = msg
                self._make_tcs_controller(msg=msg)
            elif self._iz_controller is False:  # TODO: raise CorruptStateError
                _LOGGER.error(f"{msg!r} # IS_CONTROLLER (21): was FALSE, now True")


class BdrSwitch(Actuator, RelayDemand):  # BDR (13):
    """The BDR class, such as a BDR91.

    BDR91s can be used in six disctinct modes, including:
    - x2 boiler controller (FC/TPI): either traditional, or newer heat pump-aware
    - x1 electric heat zones (0x/ELE)
    - x1 zone valve zones (0x/VAL)
    - x2 DHW thingys (F9/DHW, FA/DHW)
    """

    _SLUG: str = DEV_TYPE.BDR

    ACTIVE = "active"
    TPI_PARAMS = "tpi_params"

    _STATE_ATTR = "active"

    # def __init__(self, *args, **kwargs) -> None:
    #     super().__init__(*args, **kwargs)

    #     if kwargs.get(SZ_DOMAIN_ID) == FC:  # TODO: F9/FA/FC, zone_idx
    #         self.ctl._set_app_cntrl(self)

    def _setup_discovery_cmds(self) -> None:
        """Discover BDRs.

        The BDRs have one of six roles:
         - heater relay *or* a heat pump relay (alternative to an OTB)
         - DHW hot water valve *or* DHW heating valve
         - Zones: Electric relay *or* Zone valve relay

        They all seem to respond thus (TODO: heat pump/zone valve relay):
         - all BDR91As will (erractically) RP to these RQs
             0016, 1FC9 & 0008, 1100, 3EF1
         - all BDR91As will *not* RP to these RQs
             0009, 10E0, 3B00, 3EF0
         - a BDR91A will *periodically* send an I/3B00/00C8 if it is the heater relay
        """

        super()._setup_discovery_cmds()

        if self._faked:
            return

        self._add_discovery_cmd(Command.get_tpi_params(self.id), 6 * 3600)  # params
        self._add_discovery_cmd(
            _mk_cmd(RQ, Code._3EF1, "00", self.id),
            60 if self._child_id in (F9, FA, FC) else 300,
        )  # status

    @property
    def active(self) -> None | bool:  # 3EF0, 3EF1
        """Return the actuator's current state."""
        result = self._msg_value((Code._3EF0, Code._3EF1), key=self.MODULATION_LEVEL)
        return None if result is None else bool(result)

    @property
    def role(self) -> None | str:
        """Return the role of the BDR91A (there are six possibilities)."""

        # TODO: use self._parent?
        if self._child_id in DOMAIN_TYPE_MAP:
            return DOMAIN_TYPE_MAP[self._child_id]
        elif self._parent:
            return self._parent.heating_type  # TODO: only applies to zones

        # if Code._3B00 in self._msgs and self._msgs[Code._3B00].verb == I_:
        #     self._is_tpi = True
        # if Code._1FC9 in self._msgs and self._msgs[Code._1FC9].verb == RP:
        #     if Code._3B00 in self._msgs[Code._1FC9].raw_payload:
        #         self._is_tpi = True

        return None

    @property
    def tpi_params(self) -> None | dict:  # 1100
        return self._msg_value(Code._1100)

    @property
    def schema(self) -> dict[str, Any]:
        return {
            **super().schema,
            "role": self.role,
        }

    @property
    def params(self) -> dict[str, Any]:
        return {
            **super().params,
            self.TPI_PARAMS: self.tpi_params,
        }

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.ACTIVE: self.active,
        }


class TrvActuator(BatteryState, HeatDemand, Setpoint, Temperature):  # TRV (04):
    """The TRV class, such as a HR92."""

    _SLUG: str = DEV_TYPE.TRV

    WINDOW_OPEN = SZ_WINDOW_OPEN  # boolean

    _STATE_ATTR = SZ_HEAT_DEMAND

    @property
    def heat_demand(self) -> None | float:  # 3150
        if (heat_demand := super().heat_demand) is None:
            if self._msg_value(Code._3150) is None and self.setpoint is False:
                return 0  # instead of None (no 3150s sent when setpoint is False)
        return heat_demand

    @property
    def window_open(self) -> None | bool:  # 12B0
        return self._msg_value(Code._12B0, key=self.WINDOW_OPEN)

    @property
    def status(self) -> dict[str, Any]:
        return {
            **super().status,
            self.WINDOW_OPEN: self.window_open,
        }


class JimDevice(Actuator):  # BDR (08):
    _SLUG: str = DEV_TYPE.JIM


class JstDevice(RelayDemand):  # BDR (31):
    _SLUG: str = DEV_TYPE.JST


class UfhCircuit(Entity):
    """The UFH circuit class (UFC:circuit is much like CTL/TCS:zone).

    NOTE: for circuits, there's a difference between :
     - `self.ctl`: the UFH controller, and
     - `self.tcs.ctl`: the Evohome controller
    """

    _SLUG: str = None  # type: ignore[assignment]

    def __init__(self, ufc, ufh_idx: str) -> None:
        super().__init__(ufc._gwy)

        self.id: str = f"{ufc.id}_{ufh_idx}"

        self.ufc: UfhController = ufc
        self._child_id = ufh_idx

        # TODO: _ctl should be: .ufc? .ctl?
        self._ctl: Controller = None  # type: ignore[assignment]
        self._zone: None | Zone = None

    # def __str__(self) -> str:
    #     return f"{self.id} ({self._zone and self._zone._child_id})"

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

        # FIXME:
        if msg.code == Code._000C and msg.payload[SZ_DEVICES]:  # zone_devices

            if not (dev_ids := msg.payload[SZ_DEVICES]):
                return
            if len(dev_ids) != 1:
                raise InvalidPayloadError("No devices")

            # ctl = self._gwy.device_by_id.get(dev_ids[0])
            ctl = self._gwy.get_device(dev_ids[0])
            if not ctl or (self._ctl and self._ctl is not ctl):
                raise InvalidPayloadError("No CTL")
            self._ctl = ctl

            ctl._make_tcs_controller()
            # self.set_parent(ctl.tcs)

            zon = ctl.tcs.get_htg_zone(msg.payload[SZ_ZONE_IDX])
            if not zon:
                raise InvalidPayloadError("No Zone")
            if self._zone and self._zone is not zon:
                raise InvalidPayloadError("Wrong Zone")
            self._zone = zon

            if self not in self._zone.actuators:
                schema = {SZ_ACTUATORS: [self.ufc.id], SZ_CIRCUITS: [self.id]}
                self._zone._update_schema(**schema)

    @property
    def ufx_idx(self) -> str:
        return self._child_id

    @property
    def zone_idx(self) -> None | str:
        if self._zone:
            return self._zone._child_id


HEAT_CLASS_BY_SLUG = class_by_attr(__name__, "_SLUG")  # e.g. CTL: Controller

_HEAT_VC_PAIR_BY_CLASS = {
    DEV_TYPE.DHW: ((I_, Code._1260),),
    DEV_TYPE.OTB: ((I_, Code._3220), (RP, Code._3220)),
}


def class_dev_heat(
    dev_addr: Address, *, msg: Message = None, eavesdrop: bool = False
) -> type[Device]:
    """Return a device class, but only if the device must be from the CH/DHW group.

    May return a device class, DeviceHeat (which will need promotion).
    """

    if dev_addr.type in DEV_TYPE_MAP.THM_DEVICES:
        return HEAT_CLASS_BY_SLUG[DEV_TYPE.THM]

    try:
        slug = DEV_TYPE_MAP.slug(dev_addr.type)
    except KeyError:
        pass
    else:
        return HEAT_CLASS_BY_SLUG[slug]

    if not eavesdrop:
        raise TypeError(f"No CH/DHW class for: {dev_addr} (no eavesdropping)")

    if msg and msg.code in CODES_OF_HEAT_DOMAIN_ONLY:
        return DeviceHeat

    raise TypeError(f"No CH/DHW class for: {dev_addr} (unknown type: {dev_addr.type})")
