diff --git a/custom_components/loxone/climate.py b/custom_components/loxone/climate.py index 3ae9e8cf..ebb7e205 100644 --- a/custom_components/loxone/climate.py +++ b/custom_components/loxone/climate.py @@ -10,8 +10,7 @@ from abc import ABC from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity -from homeassistant.components.climate.const import (ClimateEntityFeature, - HVACAction, HVACMode) +from homeassistant.components.climate.const import ClimateEntityFeature, HVACAction, HVACMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant @@ -20,9 +19,8 @@ from voluptuous import All, Optional, Range from . import LoxoneEntity -from .const import CONF_HVAC_AUTO_MODE, SENDDOMAIN -from .helpers import (add_room_and_cat_to_value_values, get_all, - get_or_create_device) +from .const import CLIMATE_EVENT, CONF_HVAC_AUTO_MODE, SENDDOMAIN +from .helpers import add_room_and_cat_to_value_values, get_all, get_or_create_device from .miniserver import get_miniserver_from_hass _LOGGER = logging.getLogger(__name__) @@ -129,9 +127,7 @@ def __init__(self, **kwargs): # Set supported features self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) # Flatten UUID values - some might be lists (e.g., "temperatures") @@ -142,9 +138,7 @@ def __init__(self, **kwargs): else: self._all_uuids.add(value) - self._attr_device_info = get_or_create_device( - self.unique_id, self.name, self.type, self.room - ) + self._attr_device_info = get_or_create_device(self.unique_id, self.name, self.type, self.room) async def event_handler(self, event): update = False @@ -320,13 +314,6 @@ def set_hvac_mode(self, hvac_mode: str): class LoxoneRoomControllerV2(LoxoneEntity, ClimateEntity, ABC): """Loxone room controller""" - _attr_supported_features = ( - ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - def __init__(self, **kwargs): super().__init__(**kwargs) self.hass = kwargs["hass"] @@ -335,10 +322,33 @@ def __init__(self, **kwargs): 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"]) + 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._attr_device_info = get_or_create_device(self.unique_id, self.name, self.type, self.room) + + @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 - self._attr_device_info = get_or_create_device( - self.unique_id, self.name, self.type, self.room - ) + def climate_handler(self, event): + if event.data["uuid"] == self.uuidAction and self._demand != event.data["value"]: + self._demand = event.data["value"] + self.schedule_update_ha_state() def get_mode_from_id(self, mode_id): for mode in self._modeList: @@ -356,13 +366,9 @@ async def event_handler(self, event): if update: self.schedule_update_ha_state() - # _LOGGER.debug(f"State attribs after event handling: {self._stateAttribValues}") - - def get_state_value(self, name): - uuid = self._stateAttribUuids[name] - return ( - self._stateAttribValues[uuid] if uuid in self._stateAttribValues else None - ) + def get_state_value(self, name, default=None): + uuid = self._stateAttribUuids.get(name, None) + return self._stateAttribValues.get(uuid, default) if uuid is not None else default @property def extra_state_attributes(self): @@ -373,17 +379,14 @@ def extra_state_attributes(self): return { **self._attr_extra_state_attributes, "is_overridden": self.is_overridden, + "demand": self._demand, } @property def is_overridden(self) -> bool: - # Needed because loxone uses these variables names. Simply workaround define it also here. - true = True - false = False - null = None _override_entries = self.get_state_value("overrideEntries") if _override_entries: - _override_entries = eval(_override_entries) + _override_entries = json.loads(_override_entries) if isinstance(_override_entries, list) and len(_override_entries) > 0: return True return False @@ -395,31 +398,68 @@ def current_temperature(self): def set_temperature(self, **kwargs): """Set new target temperature""" - if ( - self.get_state_value("operatingMode") > 2 - ): # Set manual temp if any of the manual modes selected - self.hass.bus.fire( - SENDDOMAIN, - dict( - uuid=self.uuidAction, - value=f'setManualTemperature/{kwargs["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"]: + self.hass.bus.fire( + SENDDOMAIN, + dict( + uuid=self.uuidAction, + value=f"setManualTemperature/{kwargs['temperature']}", + ), + ) + update = True else: # Set comfort temp offset otherwise - new_offset = kwargs["temperature"] - self.get_state_value( - "comfortTemperature" - ) - self.hass.bus.fire( - SENDDOMAIN, - dict(uuid=self.uuidAction, value=f"setComfortModeTemp/{new_offset}"), - ) + 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 + + if update: + self.schedule_update_ha_state() @property def hvac_action(self) -> HVACAction | None: """Return the current HVAC action (heating, cooling).""" - if self.get_state_value("prepareState") == 1: + prepare_state = self.get_state_value("prepareState") + if prepare_state == 1: return HVACAction.PREHEATING - return None # return none due to unknown other state (HVACAction.IDLE, HVACAction.COOLING, HVACAction.HEATING) + if prepare_state == -1 or self._demand == -1: + return HVACAction.COOLING + if self._demand == 1: + return HVACAction.HEATING + return None @property def hvac_mode(self) -> HVACMode | None: @@ -435,13 +475,15 @@ def hvac_modes(self) -> list[HVACMode]: Need to be a subset of HVAC_MODES. """ - return [ - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.OFF, - ] + modes = [HVACMode.AUTO, HVACMode.OFF] + capabilities = self.get_state_value("capabilities") + if capabilities is not None: + capabilities = int(capabilities) + if capabilities & 1: + modes.append(HVACMode.HEAT) + if capabilities & 2: + modes.append(HVACMode.COOL) + return modes @property def temperature_unit(self) -> str: @@ -480,7 +522,6 @@ def preset_mode(self): Requires SUPPORT_PRESET_MODE. """ - # return self._activeMode return self.get_mode_from_id(self.get_state_value("activeMode")) @property @@ -489,14 +530,14 @@ def preset_modes(self): Requires SUPPORT_PRESET_MODE. """ - return [mode["name"] for mode in self._modeList] + 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"] 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] - ) + target_mode = self._autoMode if hvac_mode == HVACMode.AUTO else OPMODETOLOXONE[hvac_mode] self.hass.bus.fire( SENDDOMAIN, @@ -506,19 +547,19 @@ def set_hvac_mode(self, hvac_mode: str): self.schedule_update_ha_state() # if the mode selected is a manual one, we set the target temperature too - # if (hvac_mode != HVAC_MODE_AUTO): - # self.set_temperature({"temperature": self.target_temperature}) + # 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.""" - mode_id = next( - (mode["id"] for mode in self._modeList if mode["name"] == preset_mode), None - ) + mode_id = next((mode["id"] for mode in self._modeList if mode["name"] == preset_mode), None) if mode_id is not None: - self.hass.bus.fire( - SENDDOMAIN, dict(uuid=self.uuidAction, value=f"override/{mode_id}") - ) - self.schedule_update_ha_state() + if mode_id == "stop": + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value=f"stopOverride")) + self.schedule_update_ha_state() + else: + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value=f"override/{mode_id}")) + self.schedule_update_ha_state() # ------------------ AC CONTROL -------------------------------------------------------- diff --git a/custom_components/loxone/const.py b/custom_components/loxone/const.py index da2e4752..6fab59aa 100644 --- a/custom_components/loxone/const.py +++ b/custom_components/loxone/const.py @@ -48,6 +48,7 @@ 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/lights/colorpickers.py b/custom_components/loxone/lights/colorpickers.py index 853c6725..d759f5ae 100644 --- a/custom_components/loxone/lights/colorpickers.py +++ b/custom_components/loxone/lights/colorpickers.py @@ -100,6 +100,8 @@ async def event_handler(self, e): self._attr_color_temp_kelvin = _color[1] self._attr_brightness = round(255 * _color[0] / 100) request_update = True + elif _color.startswith("hsv"): + _LOGGER.warning("hsv not supported for TunableWhiteLight") else: _LOGGER.error("Not handled command -> %s", _color) diff --git a/custom_components/loxone/sensor.py b/custom_components/loxone/sensor.py index 0aaab644..f091cc0e 100644 --- a/custom_components/loxone/sensor.py +++ b/custom_components/loxone/sensor.py @@ -5,6 +5,7 @@ https://github.com/JoDehli/PyLoxone """ +import json import logging import re from functools import cached_property @@ -32,7 +33,7 @@ from homeassistant.util import dt as dt_util from . import LoxoneEntity -from .const import CONF_ACTIONID, DOMAIN, SENDDOMAIN, THROTTLE_KEEP_ALIVE_TIME +from .const import CLIMATE_EVENT, CONF_ACTIONID, DOMAIN, 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 @@ -149,6 +150,17 @@ 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, @@ -242,14 +254,73 @@ 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)) + @callback def async_add_sensors(_): async_add_entities(_, True) miniserver.listeners.append( - async_dispatcher_connect( - hass, miniserver.async_signal_new_device(NEW_SENSOR), async_add_sensors - ) + async_dispatcher_connect(hass, miniserver.async_signal_new_device(NEW_SENSOR), async_add_sensors) ) async_add_entities(entities, update_before_add=True) @@ -482,3 +553,84 @@ def create_DeviceInfo_from_sensor(sensor) -> DeviceInfo: manufacturer="Loxone", model=model, ) + + +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 + + +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 + + async def event_handler(self, e): + if self.uuidAction in e.data: + self._attr_native_value = OVERRIDE_REASONS[int(e.data[self.uuidAction])] + self.async_schedule_update_ha_state() + + +class LoxoneClimateController(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 + 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._stateAttribValues = {} + + async def event_handler(self, event): + 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 + else: + self._stateAttribValues[key] = event.data[key] + update = True + + if demand > -1: + self._attr_native_value = demand + + if update: + self.async_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 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 { + **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"), + } diff --git a/custom_components/loxone/switch.py b/custom_components/loxone/switch.py index 11040efb..8b1cf8e2 100644 --- a/custom_components/loxone/switch.py +++ b/custom_components/loxone/switch.py @@ -5,6 +5,8 @@ https://github.com/JoDehli/PyLoxone """ +from functools import cached_property +import json import logging from homeassistant.components.switch import SwitchEntity @@ -43,8 +45,7 @@ async def async_setup_entry( loxconfig = miniserver.lox_config.json entities = [] - for switch_entity in get_all(loxconfig, ["Switch", "TimedSwitch", "Intercom"]): - + for switch_entity in get_all(loxconfig, ["Switch", "TimedSwitch", "Intercom", "IRoomControllerV2"]): switch_entity = add_room_and_cat_to_value_values(loxconfig, switch_entity) if switch_entity["type"] in ["Switch"]: @@ -72,7 +73,22 @@ 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) async_add_entities(entities) @@ -276,3 +292,49 @@ def extra_state_attributes(self): "device_type": self.type, "platform": "loxone", } + + +class LoxoneRoomControllerOverride(LoxoneSwitch): + 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" + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="override/1")) + self._state = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="stopOverride")) + self._state = 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 + self.async_schedule_update_ha_state() + + @property + def extra_state_attributes(self): + """Return device specific state attributes. + + Implemented by platform classes. + """ + return { + "uuid": self._override_id, + "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