diff --git a/custom_components/loxone/climate.py b/custom_components/loxone/climate.py index ebb7e20..9defc9f 100644 --- a/custom_components/loxone/climate.py +++ b/custom_components/loxone/climate.py @@ -19,7 +19,7 @@ from voluptuous import All, Optional, Range from . import LoxoneEntity -from .const import CLIMATE_EVENT, CONF_HVAC_AUTO_MODE, SENDDOMAIN +from .const import CLIMATE_EVENT, CONF_HVAC_AUTO_MODE, PRESET_PAUSED_WINDOW, PRESET_SCHEDULE, SENDDOMAIN from .helpers import add_room_and_cat_to_value_values, get_all, get_or_create_device from .miniserver import get_miniserver_from_hass @@ -312,7 +312,9 @@ def set_hvac_mode(self, hvac_mode: str): class LoxoneRoomControllerV2(LoxoneEntity, ClimateEntity, ABC): - """Loxone room controller""" + """Loxone room controller V2 with demand tracking and dynamic capabilities.""" + + _attr_translation_key = "room_controller_v2" def __init__(self, **kwargs): super().__init__(**kwargs) @@ -321,33 +323,44 @@ def __init__(self, **kwargs): self._stateAttribUuids = kwargs["states"] self._stateAttribValues = {} self.type = "RoomControllerV2" - self._modeList = kwargs["details"]["timerModes"] - self._modeList.append({"id": "stop", "name": "Schedule"}) self._demand = 0 - possible_capabilities = int(self.details["possibleCapabilities"]) + + # Copy mode list to avoid mutating shared kwargs data + self._modeList = list(kwargs["details"]["timerModes"]) + self._modeList.append({"id": "stop", "name": PRESET_SCHEDULE}) + + # Determine heating/cooling capabilities from bitmask + possible_capabilities = kwargs["details"].get("possibleCapabilities", 3) heat_possible = possible_capabilities & 1 cool_possible = possible_capabilities & 2 - self._range_possible = heat_possible and cool_possible - - self.hass.bus.async_listen(CLIMATE_EVENT, self.climate_handler) + self._range_possible = bool(heat_possible and cool_possible) self._attr_device_info = get_or_create_device(self.unique_id, self.name, self.type, self.room) + async def async_added_to_hass(self): + """Register event listener once entity is added to HA.""" + await super().async_added_to_hass() + unsub = self.hass.bus.async_listen(CLIMATE_EVENT, self.climate_handler) + self.async_on_remove(unsub) + @property - def supported_features(self): - _features = ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.PRESET_MODE - mode = self.get_state_value("operatingMode") - if mode is not None and mode > -1: - _features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - if self._range_possible and mode and mode in {0, 3} # 0 = Auto Heat/Cool, 3 = Manual Heat/Cool - else ClimateEntityFeature.TARGET_TEMPERATURE - ) - return _features + def supported_features(self) -> ClimateEntityFeature: + """Return supported features based on device capabilities.""" + features = ( + ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + op_mode = self.get_state_value("operatingMode") + if self._range_possible and op_mode in (0, 3): + features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + return features def climate_handler(self, event): - if event.data["uuid"] == self.uuidAction and self._demand != event.data["value"]: - self._demand = event.data["value"] + """Handle climate demand events from ClimateController.""" + if event.data.get("uuid") == self.uuidAction: + self._demand = event.data.get("value", 0) self.schedule_update_ha_state() def get_mode_from_id(self, mode_id): @@ -356,7 +369,6 @@ def get_mode_from_id(self, mode_id): return mode["name"] async def event_handler(self, event): - # _LOGGER.debug(f"Climate Event data: {event.data}") update = False for key in set(self._stateAttribUuids.values()) & event.data.keys(): @@ -372,23 +384,22 @@ def get_state_value(self, name, default=None): @property def extra_state_attributes(self): - """Return device specific state attributes. - - Implemented by platform classes. - """ + """Return device specific state attributes.""" return { **self._attr_extra_state_attributes, "is_overridden": self.is_overridden, - "demand": self._demand, } @property def is_overridden(self) -> bool: _override_entries = self.get_state_value("overrideEntries") if _override_entries: - _override_entries = json.loads(_override_entries) - if isinstance(_override_entries, list) and len(_override_entries) > 0: - return True + try: + parsed = json.loads(_override_entries) + if isinstance(parsed, list) and len(parsed) > 0: + return True + except (json.JSONDecodeError, TypeError): + pass return False @property @@ -397,91 +408,84 @@ def current_temperature(self): return self.get_state_value("tempActual") def set_temperature(self, **kwargs): - """Set new target temperature""" - update = False - op_mode = self.get_state_value("operatingMode", -1) - if op_mode in {0, 3} and self._range_possible: - target_temp_high = kwargs.get("target_temp_high") - target_temp_low = kwargs.get("target_temp_low") - if target_temp_low: - comfort_temperature = self.get_state_value("comfortTemperature") - if comfort_temperature != target_temp_low: - self.hass.bus.fire( - SENDDOMAIN, - dict( - uuid=self.uuidAction, - value=f"setComfortTemperature/{target_temp_low}", - ), - ) - update = True - if target_temp_high: - comfort_temperature_cool = self.get_state_value("comfortTemperatureCool") - if comfort_temperature_cool != target_temp_high: - self.hass.bus.fire( - SENDDOMAIN, - dict( - uuid=self.uuidAction, - value=f"setComfortTemperatureCool/{target_temp_high}", - ), - ) - update = True - elif op_mode > 2 and not self.is_overridden: # Set manual temp if any of the manual modes selected - manual_temperature = self.get_state_value("tempTarget") - if manual_temperature != kwargs["temperature"]: + """Set new target temperature.""" + op_mode = self.get_state_value("operatingMode") + + if op_mode is not None and op_mode > 2: + # Manual mode — set manual temperature directly + if "temperature" in kwargs: self.hass.bus.fire( SENDDOMAIN, dict( uuid=self.uuidAction, - value=f"setManualTemperature/{kwargs['temperature']}", + value=f'setManualTemperature/{kwargs["temperature"]}', ), ) - update = True - else: # Set comfort temp offset otherwise - current_offset = self.get_state_value("comfortTemperatureOffset") - new_offset = kwargs["temperature"] - self.get_state_value("comfortTemperature") - if current_offset != new_offset: - self.hass.bus.fire( - SENDDOMAIN, - dict(uuid=self.uuidAction, value=f"setComfortModeTemp/{new_offset}"), - ) - update = True + elif self._range_possible and op_mode in (0, 3): + # Range mode — set comfort heating/cooling temps separately + if "target_temp_high" in kwargs: + comfort_cool = self.get_state_value("comfortTemperatureCool") + if comfort_cool is not None: + new_offset = kwargs["target_temp_high"] - comfort_cool + if new_offset != 0: + self.hass.bus.fire( + SENDDOMAIN, + dict(uuid=self.uuidAction, value=f"setComfortModeTempCool/{new_offset}"), + ) + if "target_temp_low" in kwargs: + comfort_heat = self.get_state_value("comfortTemperature") + if comfort_heat is not None: + new_offset = kwargs["target_temp_low"] - comfort_heat + if new_offset != 0: + self.hass.bus.fire( + SENDDOMAIN, + dict(uuid=self.uuidAction, value=f"setComfortModeTemp/{new_offset}"), + ) + else: + # Auto/single target — set comfort temp offset + if "temperature" in kwargs: + comfort = self.get_state_value("comfortTemperature") + if comfort is not None: + new_offset = kwargs["temperature"] - comfort + self.hass.bus.fire( + SENDDOMAIN, + dict(uuid=self.uuidAction, value=f"setComfortModeTemp/{new_offset}"), + ) - if update: - self.schedule_update_ha_state() + self.schedule_update_ha_state() @property def hvac_action(self) -> HVACAction | None: - """Return the current HVAC action (heating, cooling).""" - prepare_state = self.get_state_value("prepareState") - if prepare_state == 1: + """Return the current HVAC action (heating, cooling, idle).""" + if self.get_state_value("openWindow"): + return HVACAction.OFF + if self.get_state_value("prepareState") == 1: return HVACAction.PREHEATING - if prepare_state == -1 or self._demand == -1: + if self._demand == -1: return HVACAction.COOLING if self._demand == 1: return HVACAction.HEATING - return None + return HVACAction.IDLE @property def hvac_mode(self) -> HVACMode | None: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ + """Return hvac operation ie. heat, cool mode.""" return OPMODES[self.get_state_value("operatingMode")] @property def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ + """Return the list of available hvac operation modes based on capabilities.""" modes = [HVACMode.AUTO, HVACMode.OFF] - capabilities = self.get_state_value("capabilities") - if capabilities is not None: - capabilities = int(capabilities) - if capabilities & 1: + if self._range_possible: + modes.append(HVACMode.HEAT_COOL) + modes.append(HVACMode.HEAT) + modes.append(HVACMode.COOL) + else: + # Single capability — check which one + possible = self.details.get("possibleCapabilities", 3) + if possible & 1: modes.append(HVACMode.HEAT) - if capabilities & 2: + if possible & 2: modes.append(HVACMode.COOL) return modes @@ -518,48 +522,45 @@ def target_temperature_step(self) -> float | None: @property def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp. - - Requires SUPPORT_PRESET_MODE. - """ + """Return the current preset mode.""" + if self.get_state_value("openWindow"): + return PRESET_PAUSED_WINDOW return self.get_mode_from_id(self.get_state_value("activeMode")) @property def preset_modes(self): - """Return a list of available preset modes. - - Requires SUPPORT_PRESET_MODE. - """ - if self.hvac_mode == HVACMode.AUTO or self.is_overridden: - return [mode["name"] for mode in self._modeList] - return [mode["name"] for mode in self._modeList if mode["id"] != "stop"] + """Return a list of available preset modes.""" + modes = [mode["name"] for mode in self._modeList] + # Hide "Schedule" when not in auto mode or not overridden + op_mode = self.get_state_value("operatingMode") + if op_mode != 0 and not self.is_overridden: + modes = [m for m in modes if m != PRESET_SCHEDULE] + # Include the paused indicator when window is open + if self.get_state_value("openWindow"): + modes.append(PRESET_PAUSED_WINDOW) + return modes def set_hvac_mode(self, hvac_mode: str): """Set new target hvac mode.""" - target_mode = self._autoMode if hvac_mode == HVACMode.AUTO else OPMODETOLOXONE[hvac_mode] self.hass.bus.fire( SENDDOMAIN, dict(uuid=self.uuidAction, value=f"setOperatingMode/{target_mode}"), ) - self.schedule_update_ha_state() - # if the mode selected is a manual one, we set the target temperature too - # if hvac_mode != HVACMode.AUTO: - # self.set_temperature(temperature=self.get_state_value("comfortTemperature")) - def set_preset_mode(self, preset_mode: str): """Set new preset mode.""" + if preset_mode == PRESET_PAUSED_WINDOW: + return # Informational only — controlled by window sensor mode_id = next((mode["id"] for mode in self._modeList if mode["name"] == preset_mode), None) if mode_id is not None: if mode_id == "stop": - self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value=f"stopOverride")) - self.schedule_update_ha_state() + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="stopOverride")) else: self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value=f"override/{mode_id}")) - self.schedule_update_ha_state() + self.schedule_update_ha_state() # ------------------ AC CONTROL -------------------------------------------------------- diff --git a/custom_components/loxone/const.py b/custom_components/loxone/const.py index 6fab59a..148e382 100644 --- a/custom_components/loxone/const.py +++ b/custom_components/loxone/const.py @@ -38,6 +38,12 @@ SENDDOMAIN = "loxone_send" SECUREDSENDDOMAIN = "loxone_send_secured" +CLIMATE_EVENT = "loxone_climate" + +# Climate preset names (translatable) +PRESET_SCHEDULE = "schedule" +PRESET_PAUSED_WINDOW = "paused_door_window" + DEFAULT = "" ATTR_UUID = "uuid" @@ -48,7 +54,6 @@ ATTR_DEVICE = "device" ATTR_AREA_CREATE = "create_areas" DOMAIN_DEVICES = "devices" -CLIMATE_EVENT = "loxone_climate" CONF_ACTIONID = "uuidAction" CONF_SCENE_GEN = "generate_scenes" diff --git a/custom_components/loxone/sensor.py b/custom_components/loxone/sensor.py index 4eed791..8b77338 100644 --- a/custom_components/loxone/sensor.py +++ b/custom_components/loxone/sensor.py @@ -27,13 +27,13 @@ UnitOfVolumeFlowRate) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import LoxoneEntity, MiniServer -from .const import CLIMATE_EVENT, CONF_ACTIONID, DOMAIN, SENDDOMAIN, THROTTLE_KEEP_ALIVE_TIME +from . import LoxoneEntity +from .const import CLIMATE_EVENT, CONF_ACTIONID, DOMAIN, EVENT, SENDDOMAIN, THROTTLE_KEEP_ALIVE_TIME from .helpers import (add_room_and_cat_to_value_values, clean_unit, get_all, get_or_create_device) from .miniserver import get_miniserver_from_hass @@ -54,6 +54,18 @@ } ) +OVERRIDE_REASONS = { + 0: "none", + 1: "presence", + 2: "window_open", + 3: "comfort_override", + 4: "eco_override", + 5: "eco_plus_override", + 6: "prepare_heat_up", + 7: "prepare_cool_down", + 8: "overridden_by_source", +} + class LoxoneEntityDescription(SensorEntityDescription, frozen_or_thawed=True): """ @@ -151,17 +163,6 @@ class LoxoneEntityDescription(SensorEntityDescription, frozen_or_thawed=True): ) """Units that map to exactly one device class without needing keyword disambiguation.""" -OVERRIDE_REASONS = { - 0: "None", - 1: "Presence", - 2: "Window Open", - 3: "Comfort Override", - 4: "Eco Override", - 5: "Eco+ Override", - 6: "Prepare State Heat Up", - 7: "Prepare State Cool Down", - 8: "Overriden by source", -} def match_sensor_description( unit: str, @@ -216,10 +217,10 @@ async def async_setup_entry( miniserver = get_miniserver_from_hass(hass) loxconfig = miniserver.lox_config.json - entities: list[Any] = [LoxoneKeepAliveSensor(miniserver.serial)] + entities: list[Any] = [LoxoneKeepAliveSensor()] if "softwareVersion" in loxconfig: - entities.append(LoxoneVersionSensor(miniserver.serial, loxconfig["softwareVersion"])) + entities.append(LoxoneVersionSensor(loxconfig["softwareVersion"])) for sensor in get_all(loxconfig, "InfoOnlyAnalog"): sensor = add_room_and_cat_to_value_values(loxconfig, sensor) @@ -256,66 +257,42 @@ async def async_setup_entry( } entities.append(LoxoneMeterSensor(**subsensor)) - for sensor in get_all(loxconfig, ["ClimateControllerUS", "ClimateController"]): - sensor = add_room_and_cat_to_value_values(loxconfig, sensor) - entities.append(LoxoneClimateController(**sensor)) - - for sensor in get_all(loxconfig, "IRoomControllerV2"): - sensor = add_room_and_cat_to_value_values(loxconfig, sensor) - device = get_or_create_device(sensor["uuidAction"], sensor["name"], sensor["type"], sensor["room"]) - - states_list = [ - ("overrideReason", "Override Reason", False, SensorDeviceClass.ENUM, LoxoneRoomControllerOverrideSensor), - ( - "comfortTemperature", - "Comfort Temperature", - True, - SensorDeviceClass.TEMPERATURE, - LoxoneRoomControllerTemperatureSensor, - ), - ( - "comfortTemperatureCool", - "Comfort Temperature Cool", - True, - SensorDeviceClass.TEMPERATURE, - LoxoneRoomControllerTemperatureSensor, - ), - ] - - if sensor["details"]["connectedInputs"] > 0: - # Bits from StructureFile pdf - # ■ Bit 1 Comfort-Temperature Heating - # ■ Bit 2 Comfort-Temperature Cooling - # ■ Bit 3 Comfort Temperature Heat+Cooling - # ■ Bit 4 Allowed Comfort-Tolerance - # ■ Bit 5 Lower absent Temperature - # ■ Bit 6 Upper absent Temperature - # ■ Bit 7 Allowed deviation absent - # ■ Bit 8 Shading temperature heating - # ■ Bit 9 Shading temperature cooling - # ■ Bit 10 Frostprotect Temperature - # ■ Bit 11 HeatProtect Temperature - # ■ Bit 12 Mode input - # ■ Bit 13 CO2-Level - # ■ Bit 14 Indoor Humidity - """""" - - for state_key, name_suffix, include_format, device_class, sensor_class in states_list: - if state_key in sensor["states"]: - room_controller_sensor = { - "type": name_suffix, - "device_info": device, - "device_class": device_class, - "parent_id": sensor["uuidAction"], - "uuidAction": sensor["states"][state_key], - "room": sensor.get("room", ""), - "cat": sensor.get("cat", ""), - "name": f"{sensor['name']} {name_suffix}", - "details": {"format": sensor["details"]["format"] if include_format else ""}, - "async_add_devices": async_add_entities, - "config_entry": config_entry, - } - entities.append(sensor_class(**room_controller_sensor)) + # Climate controller demand sensors + for ctrl_type in ("ClimateController", "ClimateControllerUS"): + for ctrl in get_all(loxconfig, ctrl_type): + ctrl = add_room_and_cat_to_value_values(loxconfig, ctrl) + ctrl_kwargs = {**ctrl, "type": "climate_controller", "hass": hass} + entities.append(LoxoneClimateController(**ctrl_kwargs)) + + # IRoomControllerV2 sub-sensors: override reason + comfort temperatures + for irc in get_all(loxconfig, "IRoomControllerV2"): + irc = add_room_and_cat_to_value_values(loxconfig, irc) + states = irc.get("states", {}) + device_info = get_or_create_device(irc["uuidAction"], irc["name"], "RoomControllerV2", irc.get("room", "")) + + if "overrideReason" in states: + entities.append(LoxoneRoomControllerOverrideSensor( + name=f"{irc['name']} Override Reason", + uuid=states["overrideReason"], + device_info=device_info, + parent_uuid=irc["uuidAction"], + )) + + if "comfortTemperature" in states: + entities.append(LoxoneRoomControllerTemperatureSensor( + name=f"{irc['name']} Comfort Temperature", + uuid=states["comfortTemperature"], + device_info=device_info, + parent_uuid=irc["uuidAction"], + )) + + if "comfortTemperatureCool" in states: + entities.append(LoxoneRoomControllerTemperatureSensor( + name=f"{irc['name']} Comfort Temperature Cool", + uuid=states["comfortTemperatureCool"], + device_info=device_info, + parent_uuid=irc["uuidAction"], + )) @callback def async_add_sensors(_): @@ -376,15 +353,14 @@ class LoxoneKeepAliveSensor(LoxoneEntity, SensorEntity): _attr_unique_id = "loxone_keep_alive_sensor_uuid" _attr_device_class = SensorDeviceClass.TIMESTAMP # tell HA this is a timestamp - def __init__(self, miniserver_serial, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - self._miniserver_serial = miniserver_serial self._attr_native_value = None @cached_property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self._miniserver_serial}-{self._attr_unique_id}" + return self._attr_unique_id async def event_handler(self, e): if "keep_alive" in e.data and e.data["keep_alive"] == "received": @@ -412,9 +388,8 @@ class LoxoneVersionSensor(LoxoneEntity, SensorEntity): _attr_icon = "mdi:information-outline" _attr_unique_id = "loxone_software_version" - def __init__(self, minisersver_serial, version_list, **kwargs): + def __init__(self, version_list, **kwargs): super().__init__(**kwargs) - self._miniserver_serial = minisersver_serial try: self._attr_native_value = ".".join([str(x) for x in version_list]) except Exception: @@ -423,7 +398,7 @@ def __init__(self, minisersver_serial, version_list, **kwargs): @cached_property def unique_id(self) -> str: """Return a unique ID.""" - return f"{self._miniserver_serial}-{self._attr_native_value}" + return self._attr_unique_id class LoxoneTextSensor(LoxoneEntity, SensorEntity): @@ -559,82 +534,134 @@ def create_device_info_from_sensor(sensor) -> DeviceInfo: ) -class LoxoneRoomControllerTemperatureSensor(LoxoneSensor, SensorEntity): - def __init__(self, **kwargs): - super().__init__(**kwargs) - device_info = kwargs.get("device_info", None) - if device_info: - self._attr_device_info = device_info +# -------------- Climate controller sensors (PR #463) -------------------------------- -class LoxoneRoomControllerOverrideSensor(LoxoneEntity, SensorEntity): - def __init__(self, **kwargs): - super().__init__(**kwargs) - device_info = kwargs.get("device_info", None) - if device_info: - self._attr_device_info = device_info +class LoxoneRoomControllerTemperatureSensor(SensorEntity): + """Sensor for IRoomControllerV2 comfort temperature states.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, name: str, uuid: str, device_info: DeviceInfo, parent_uuid: str): + self._attr_name = name + self._uuid = uuid + self._attr_unique_id = uuid + self._attr_device_info = device_info + self._attr_native_value = None + self._parent_uuid = parent_uuid + + async def async_added_to_hass(self): + """Subscribe to Loxone events.""" + self.async_on_remove( + self.hass.bus.async_listen(EVENT, self.event_handler) + ) async def event_handler(self, e): - if self.uuidAction in e.data: - self._attr_native_value = OVERRIDE_REASONS[int(e.data[self.uuidAction])] + if self._uuid in e.data: + self._attr_native_value = e.data[self._uuid] + self.async_schedule_update_ha_state() + + +class LoxoneRoomControllerOverrideSensor(SensorEntity): + """Sensor for IRoomControllerV2 override reason.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_translation_key = "override_reason" + + def __init__(self, name: str, uuid: str, device_info: DeviceInfo, parent_uuid: str): + self._attr_name = name + self._uuid = uuid + self._attr_unique_id = uuid + self._attr_device_info = device_info + self._attr_native_value = "none" + self._attr_options = list(OVERRIDE_REASONS.values()) + self._parent_uuid = parent_uuid + + async def async_added_to_hass(self): + """Subscribe to Loxone events.""" + self.async_on_remove( + self.hass.bus.async_listen(EVENT, self.event_handler) + ) + + async def event_handler(self, e): + if self._uuid in e.data: + reason_code = int(e.data[self._uuid]) + self._attr_native_value = OVERRIDE_REASONS.get(reason_code, f"Unknown ({reason_code})") self.async_schedule_update_ha_state() class LoxoneClimateController(LoxoneEntity, SensorEntity): + """Climate controller sensor that fires demand events for IRoomControllerV2. + + Reads the control list from the ClimateController's state and fires + CLIMATE_EVENT for each linked room controller with the current demand + (1 = heating, -1 = cooling, 0 = idle). + """ + def __init__(self, **kwargs): super().__init__(**kwargs) - device_info = kwargs.get("device_info", None) - if device_info: - self._attr_device_info = device_info - self.type = "Climate controller" - self._attr_should_poll = False - self._stateAttribUuids = kwargs["states"] - self._stateAttribKeys = {v: k for k, v in self._stateAttribUuids.items()} + self.hass = kwargs["hass"] + self._stateAttribUuids = kwargs.get("states", {}) self._stateAttribValues = {} + self._heat_demand = 0 + self._cool_demand = 0 + self.type = "ClimateController" + + self._attr_device_info = get_or_create_device(self.unique_id, self.name, self.type, self.room) - async def event_handler(self, event): + async def event_handler(self, e): update = False - demand = -1 - for key in set(self._stateAttribUuids.values()) & event.data.keys(): - if self._stateAttribKeys[key] == "controls": - demand = 0 - self._stateAttribValues[key] = json.loads(event.data[key]) - for control in self._stateAttribValues[key]: - self.hass.bus.async_fire(CLIMATE_EVENT, dict(uuid=control["uuid"], value=control["demand"])) - if control["demand"] == 1 or control["demand"] == -1: - demand = demand + 1 + for key in set(self._stateAttribUuids.values()) & e.data.keys(): + raw = e.data[key] + # Parse JSON control lists from the Miniserver + if isinstance(raw, str) and raw.startswith("["): + try: + parsed = json.loads(raw) + self._stateAttribValues[key] = parsed + # Fire demand events for each control in the list + heat_count = 0 + cool_count = 0 + for control in parsed: + demand = control.get("demand", 0) + if demand == 1: + heat_count += 1 + elif demand == -1: + cool_count += 1 + self.hass.bus.async_fire( + CLIMATE_EVENT, + {"uuid": control["uuid"], "value": demand}, + ) + self._heat_demand = heat_count + self._cool_demand = cool_count + except (json.JSONDecodeError, TypeError, KeyError) as err: + _LOGGER.debug("ClimateController JSON parse error: %s", err) else: - self._stateAttribValues[key] = event.data[key] + self._stateAttribValues[key] = raw update = True - if demand > -1: - self._attr_native_value = demand - if update: - self.async_schedule_update_ha_state() + self.schedule_update_ha_state() - def get_state_value(self, name): - uuid = self._stateAttribUuids[name] - return self._stateAttribValues[uuid] if uuid in self._stateAttribValues else None + @property + def native_value(self): + """Return summary state.""" + if self._heat_demand > 0: + return f"Heating ({self._heat_demand})" + if self._cool_demand > 0: + return f"Cooling ({self._cool_demand})" + return "Idle" @property def extra_state_attributes(self): - """Return device specific state attributes.""" - heat_demand = 0 - cool_demand = 0 - controls = self.get_state_value("controls") - if controls is not None: - for control in controls: - if control["demand"] == 1: - heat_demand = heat_demand + 1 - if control["demand"] == -1: - cool_demand = cool_demand + 1 + """Return detailed demand attributes.""" return { **self._attr_extra_state_attributes, - "heat_demand": heat_demand, - "cool_demand": cool_demand, - "controls": controls, - "currentStatus": self.get_state_value("currentStatus"), - "stage": self.get_state_value("stage"), + "heat_demand": self._heat_demand, + "cool_demand": self._cool_demand, + "device_type": self.type, } diff --git a/custom_components/loxone/switch.py b/custom_components/loxone/switch.py index 9e0e721..3a6c82d 100644 --- a/custom_components/loxone/switch.py +++ b/custom_components/loxone/switch.py @@ -8,6 +8,7 @@ from functools import cached_property import json import logging +from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -45,7 +46,7 @@ async def async_setup_entry( loxconfig = miniserver.lox_config.json entities = [] - for switch_entity in get_all(loxconfig, ["Switch", "TimedSwitch", "Intercom", "IRoomControllerV2", "LightControllerV2"]): + for switch_entity in get_all(loxconfig, ["Switch", "TimedSwitch", "Intercom"]): switch_entity = add_room_and_cat_to_value_values(loxconfig, switch_entity) if switch_entity["type"] in ["Switch"]: @@ -73,40 +74,26 @@ async def async_setup_entry( new_switch = LoxoneIntercomSubControl(**_) entities.append(new_switch) - elif switch_entity["type"] == "IRoomControllerV2": - if "overrideEntries" in switch_entity["states"]: - overrride_switch = { - "room_controller": switch_entity, - "type": "RoomControllerOverride", - "parent_id": switch_entity["uuidAction"], - "uuidAction": switch_entity["uuidAction"], - "overrideUuid": switch_entity["states"]["overrideEntries"], - "room": switch_entity.get("room", ""), - "cat": switch_entity.get("cat", ""), - "name": switch_entity.get("name", ""), - "async_add_devices": async_add_entities, - "config_entry": config_entry, - } - override_entity = LoxoneRoomControllerOverride(**overrride_switch) - entities.append(override_entity) - elif switch_entity["type"] == "LightControllerV2": - if switch_entity.get("presence", None): - presence_switch = { - "lightcontroller_id": switch_entity.get("uuidAction", None), - "lightcontroller_name": switch_entity.get("name", None), - "type": "RoomControllerOverride", - "parent_id": switch_entity["uuidAction"], - "uuidAction": switch_entity["uuidAction"], - "presenceUuid": switch_entity["states"]["presence"], - "room": switch_entity.get("room", ""), - "cat": switch_entity.get("cat", ""), - "name": switch_entity.get("name", ""), - "async_add_devices": async_add_entities, - "config_entry": config_entry, - "states": switch_entity["states"], - } - presence_entity = LoxoneLightPresenceSwitch(**presence_switch) - entities.append(presence_entity) + + # IRoomControllerV2 comfort override switch + for irc in get_all(loxconfig, "IRoomControllerV2"): + irc = add_room_and_cat_to_value_values(loxconfig, irc) + states = irc.get("states", {}) + if "overrideEntries" in states: + override_kwargs = {**irc, "type": "RoomControllerOverride"} + entities.append(LoxoneRoomControllerOverride(**override_kwargs)) + + # LightControllerV2 presence detection switch + for lc in get_all(loxconfig, "LightControllerV2"): + lc = add_room_and_cat_to_value_values(loxconfig, lc) + if lc.get("presence", None): + presence_kwargs = { + **lc, + "type": "LightPresenceSwitch", + "presenceUuid": lc["states"]["presence"], + } + entities.append(LoxoneLightPresenceSwitch(**presence_kwargs)) + async_add_entities(entities) @@ -320,64 +307,74 @@ def extra_state_attributes(self): } -class LoxoneRoomControllerOverride(LoxoneSwitch): +class LoxoneRoomControllerOverride(LoxoneEntity, SwitchEntity): + """Switch to trigger/stop comfort override on IRoomControllerV2.""" + + _attr_available = True + _attr_is_on = False + _attr_entity_category = EntityCategory.CONFIG + def __init__(self, **kwargs): - self._override_id = kwargs["overrideUuid"] super().__init__(**kwargs) - self._attr_device_info = get_or_create_device(self.uuidAction, self.name, "IRoomControllerV2", self.room) - self.name = f"{self.name} Override" + self._override_uuid = self.states.get("overrideEntries") + self._base_name = self.name # Original IRC name before modification + self._attr_name = f"{self.name} Comfort Override" + self.type = "RoomControllerOverride" + + # Use uuidAction (not unique_id) so this groups with the climate entity + self._attr_device_info = get_or_create_device(self.uuidAction, self._base_name, "RoomControllerV2", self.room) + + @cached_property + def unique_id(self) -> str: + """Return unique ID based on override state UUID.""" + return f"{self.uuidAction}_override" def turn_on(self, **kwargs): - """Turn the switch on.""" + """Trigger comfort override (mode 1).""" self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="override/1")) - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs): - """Turn the switch off.""" + """Stop the active override.""" self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="stopOverride")) - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - async def event_handler(self, event): - if self._override_id in event.data: - override_list = json.loads(event.data[self._override_id]) - if isinstance(override_list, list): - self._state = len(override_list) > 0 - if not self._attr_available: - self._attr_available = True + async def event_handler(self, e): + if self._override_uuid and self._override_uuid in e.data: + raw = e.data[self._override_uuid] + try: + entries = json.loads(raw) if isinstance(raw, str) else raw + self._attr_is_on = isinstance(entries, list) and len(entries) > 0 + except (json.JSONDecodeError, TypeError): + self._attr_is_on = False self.async_schedule_update_ha_state() @property def extra_state_attributes(self): - """Return device specific state attributes. - - Implemented by platform classes. - """ + """Return device specific state attributes.""" return { - "uuid": self._override_id, + "uuid": self.uuidAction, "room": self.room, - "category": self.cat, "device_type": self.type, "platform": "loxone", } - @cached_property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._override_id - -class LoxoneLightPresenceSwitch(LoxoneSwitch): +class LoxoneLightPresenceSwitch(LoxoneEntity, SwitchEntity): """Representation of a light controller presence detection switch.""" + _attr_available = False + _attr_is_on = False _attr_entity_category = EntityCategory.CONFIG def __init__(self, **kwargs): self._presence_id = kwargs["presenceUuid"] super().__init__(**kwargs) self._attr_device_info = get_or_create_device(self.uuidAction, self.name, "LightControllerV2", self.room) - self.name = f"{self.name} Presence Detection" + self._attr_name = f"{self.name} Presence Detection" + self.type = "LightPresenceSwitch" @cached_property def unique_id(self) -> str: @@ -386,22 +383,25 @@ def unique_id(self) -> str: async def async_turn_on(self, **kwargs: Any) -> None: self.hass.bus.async_fire(SENDDOMAIN, dict(uuid=self.uuidAction + "/presence", value="on")) + self._attr_is_on = True self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: self.hass.bus.async_fire(SENDDOMAIN, dict(uuid=self.uuidAction + "/presence", value="off")) + self._attr_is_on = False self.async_schedule_update_ha_state() async def event_handler(self, event): request_update = False if self._presence_id in event.data: active = event.data[self._presence_id] - new_state = True if active == 7.0 else False + new_state = active == 7.0 if new_state != self._attr_is_on: self._attr_is_on = new_state request_update = True if not self._attr_available: self._attr_available = True + request_update = True if request_update: self.async_schedule_update_ha_state() diff --git a/custom_components/loxone/translations/cs.json b/custom_components/loxone/translations/cs.json new file mode 100644 index 0000000..2daa378 --- /dev/null +++ b/custom_components/loxone/translations/cs.json @@ -0,0 +1,31 @@ +{ + "entity": { + "climate": { + "room_controller_v2": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Časový plán", + "paused_door_window": "Pozastaveno (Dveře/Okno)" + } + } + } + } + }, + "sensor": { + "override_reason": { + "state": { + "none": "Žádný", + "presence": "Přítomnost", + "window_open": "Otevřené okno", + "comfort_override": "Dočasný komfortní režim", + "eco_override": "Dočasný Eco režim", + "eco_plus_override": "Dočasný Eco+ režim", + "prepare_heat_up": "Příprava vytápění", + "prepare_cool_down": "Příprava chlazení", + "overridden_by_source": "Řízeno nadřazeným zdrojem" + } + } + } + } +} diff --git a/custom_components/loxone/translations/de.json b/custom_components/loxone/translations/de.json index 0412a76..b1ab4f1 100644 --- a/custom_components/loxone/translations/de.json +++ b/custom_components/loxone/translations/de.json @@ -1,4 +1,33 @@ { + "entity": { + "climate": { + "room_controller_v2": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Zeitplan", + "paused_door_window": "Pausiert (Tür/Fenster)" + } + } + } + } + }, + "sensor": { + "override_reason": { + "state": { + "none": "Keine", + "presence": "Anwesenheit", + "window_open": "Fenster offen", + "comfort_override": "Komfort-Übersteuerung", + "eco_override": "Eco-Übersteuerung", + "eco_plus_override": "Eco+-Übersteuerung", + "prepare_heat_up": "Aufheizen vorbereiten", + "prepare_cool_down": "Abkühlen vorbereiten", + "overridden_by_source": "Von Quelle übersteuert" + } + } + } + }, "config": { "abort": { "single_instance_allowed": "Bereits konfiguriert" diff --git a/custom_components/loxone/translations/en.json b/custom_components/loxone/translations/en.json index 036b268..879b787 100644 --- a/custom_components/loxone/translations/en.json +++ b/custom_components/loxone/translations/en.json @@ -1,4 +1,33 @@ { + "entity": { + "climate": { + "room_controller_v2": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Schedule", + "paused_door_window": "Paused (Door/Window)" + } + } + } + } + }, + "sensor": { + "override_reason": { + "state": { + "none": "None", + "presence": "Presence", + "window_open": "Window Open", + "comfort_override": "Comfort Override", + "eco_override": "Eco Override", + "eco_plus_override": "Eco+ Override", + "prepare_heat_up": "Prepare State Heat Up", + "prepare_cool_down": "Prepare State Cool Down", + "overridden_by_source": "Overridden by source" + } + } + } + }, "config": { "abort": { "single_instance_allowed": "Already configured. Only a single configuration possible."