From 7b0ed1b57d5e7c282073bcaf7877c29720b4b535 Mon Sep 17 00:00:00 2001 From: mpcaddy <4596353+mpcaddy@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:15:39 +0000 Subject: [PATCH 1/3] add ClimateController and additional sensors for IRCv2 --- custom_components/loxone/sensor.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/custom_components/loxone/sensor.py b/custom_components/loxone/sensor.py index 4eed791..35f9c7b 100644 --- a/custom_components/loxone/sensor.py +++ b/custom_components/loxone/sensor.py @@ -32,7 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import LoxoneEntity, MiniServer +from . import LoxoneEntity 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) @@ -216,10 +216,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) @@ -376,15 +376,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 +411,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 +421,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): From 3fb9b537d49ac6535a9b7d824f2ca9e87c7071f8 Mon Sep 17 00:00:00 2001 From: Jaroslav Martasek Date: Sat, 4 Jul 2026 20:06:46 +0200 Subject: [PATCH 2/3] Rework climate controller enhancements Based on the original PR #463 work by @mpcaddy, this rework adds: Fixes: - Event listener lifecycle: async_added_to_hass() + async_on_remove() - Mode list copied before mutating (avoids shared kwargs corruption) - possibleCapabilities: .get() with default (older firmware compat) - Separate IRoomControllerV2 loop in switch (avoids breaking climate) - Safe .get() access in climate_handler New features: - Door/Window pause: shows as translated preset when openWindow active - Entity categories: sensors = DIAGNOSTIC, override switch = CONFIG - Translations: en/de/cs for preset mode names (schedule, paused) - translation_key on LoxoneRoomControllerV2 for HA-native i18n - Proper device grouping: all IRC entities share one HA device Co-Authored-By: Lilly Code --- custom_components/loxone/climate.py | 265 ++++++++-------- custom_components/loxone/const.py | 7 +- custom_components/loxone/sensor.py | 289 ++++++++++-------- custom_components/loxone/switch.py | 153 +++++----- custom_components/loxone/translations/cs.json | 16 + custom_components/loxone/translations/de.json | 14 + custom_components/loxone/translations/en.json | 14 + 7 files changed, 435 insertions(+), 323 deletions(-) create mode 100644 custom_components/loxone/translations/cs.json diff --git a/custom_components/loxone/climate.py b/custom_components/loxone/climate.py index ebb7e20..68bcb56 100644 --- a/custom_components/loxone/climate.py +++ b/custom_components/loxone/climate.py @@ -10,7 +10,8 @@ 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 @@ -19,8 +20,10 @@ from voluptuous import All, Optional, Range from . import LoxoneEntity -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 .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 _LOGGER = logging.getLogger(__name__) @@ -127,7 +130,9 @@ 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") @@ -138,7 +143,9 @@ 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 @@ -312,7 +319,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 +330,46 @@ 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._range_possible = bool(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 + ) - 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 +378,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(): @@ -367,28 +388,29 @@ async def event_handler(self, event): self.schedule_update_ha_state() 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 + uuid = self._stateAttribUuids.get(name) + if uuid is None: + return default + return self._stateAttribValues.get(uuid, default) @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,101 +419,90 @@ 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 @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" - # The Loxone Config app allows the designer to set an arbitrary - # format string for the room controller's input temperature sensor. - # We assume that the format string contains the unit of temperature, - # and default to Celsius if not. format_str = self.details.get("format") if format_str is None: @@ -508,7 +519,6 @@ def temperature_unit(self) -> str: @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self.get_state_value("tempTarget") @property @@ -518,48 +528,53 @@ 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] + 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.""" - mode_id = next((mode["id"] for mode in self._modeList if mode["name"] == preset_mode), None) + 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.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 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 35f9c7b..2765121 100644 --- a/custom_components/loxone/sensor.py +++ b/custom_components/loxone/sensor.py @@ -27,13 +27,14 @@ 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 -from .const import CLIMATE_EVENT, CONF_ACTIONID, DOMAIN, SENDDOMAIN, THROTTLE_KEEP_ALIVE_TIME +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 +55,18 @@ } ) +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: "Overridden by source", +} + class LoxoneEntityDescription(SensorEntityDescription, frozen_or_thawed=True): """ @@ -151,17 +164,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, @@ -256,73 +258,53 @@ 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)) + # 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", "") + ) - 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)) + 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(_): 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) @@ -557,82 +539,135 @@ 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 + + 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..906481c 100644 --- a/custom_components/loxone/switch.py +++ b/custom_components/loxone/switch.py @@ -5,9 +5,10 @@ https://github.com/JoDehli/PyLoxone """ -from functools import cached_property import json import logging +from functools import cached_property +from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -45,7 +46,8 @@ 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 +75,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 +308,82 @@ 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.""" - self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="override/1")) - self._state = True + """Trigger comfort override (mode 1).""" + self.hass.bus.fire( + SENDDOMAIN, dict(uuid=self.uuidAction, value="override/1") + ) + self._attr_is_on = 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 + """Stop the active override.""" + self.hass.bus.fire( + SENDDOMAIN, dict(uuid=self.uuidAction, value="stopOverride") + ) + 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_device_info = get_or_create_device( + self.uuidAction, self.name, "LightControllerV2", self.room + ) + self._attr_name = f"{self.name} Presence Detection" + self.type = "LightPresenceSwitch" @cached_property def unique_id(self) -> str: @@ -385,23 +391,30 @@ def unique_id(self) -> str: return self._presence_id async def async_turn_on(self, **kwargs: Any) -> None: - self.hass.bus.async_fire(SENDDOMAIN, dict(uuid=self.uuidAction + "/presence", value="on")) + 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.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..492ab40 --- /dev/null +++ b/custom_components/loxone/translations/cs.json @@ -0,0 +1,16 @@ +{ + "entity": { + "climate": { + "room_controller_v2": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Časový plán", + "paused_door_window": "Pozastaveno (Dveře/Okno)" + } + } + } + } + } + } +} diff --git a/custom_components/loxone/translations/de.json b/custom_components/loxone/translations/de.json index 0412a76..9189eb4 100644 --- a/custom_components/loxone/translations/de.json +++ b/custom_components/loxone/translations/de.json @@ -1,4 +1,18 @@ { + "entity": { + "climate": { + "room_controller_v2": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Zeitplan", + "paused_door_window": "Pausiert (Tür/Fenster)" + } + } + } + } + } + }, "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..9a42026 100644 --- a/custom_components/loxone/translations/en.json +++ b/custom_components/loxone/translations/en.json @@ -1,4 +1,18 @@ { + "entity": { + "climate": { + "room_controller_v2": { + "state_attributes": { + "preset_mode": { + "state": { + "schedule": "Schedule", + "paused_door_window": "Paused (Door/Window)" + } + } + } + } + } + }, "config": { "abort": { "single_instance_allowed": "Already configured. Only a single configuration possible." From 91d6f66d16bb82f1f60a09a0586fb27de07d2990 Mon Sep 17 00:00:00 2001 From: Jaroslav Martasek Date: Sun, 5 Jul 2026 13:49:58 +0200 Subject: [PATCH 3/3] Localise OVERRIDE_REASONS via HA translation system Use translation_key + enum state keys instead of hardcoded English strings. Adds translations for en, de, cs. --- custom_components/loxone/climate.py | 48 +++++++------------ custom_components/loxone/sensor.py | 36 ++++++-------- custom_components/loxone/switch.py | 27 +++-------- custom_components/loxone/translations/cs.json | 15 ++++++ custom_components/loxone/translations/de.json | 15 ++++++ custom_components/loxone/translations/en.json | 15 ++++++ 6 files changed, 84 insertions(+), 72 deletions(-) diff --git a/custom_components/loxone/climate.py b/custom_components/loxone/climate.py index 68bcb56..9defc9f 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,10 +19,8 @@ from voluptuous import All, Optional, Range from . import LoxoneEntity -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 .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 _LOGGER = logging.getLogger(__name__) @@ -130,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") @@ -143,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 @@ -342,9 +335,7 @@ def __init__(self, **kwargs): cool_possible = possible_capabilities & 2 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 - ) + 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.""" @@ -388,10 +379,8 @@ async def event_handler(self, event): self.schedule_update_ha_state() def get_state_value(self, name, default=None): - uuid = self._stateAttribUuids.get(name) - if uuid is None: - return default - return self._stateAttribValues.get(uuid, default) + 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): @@ -503,6 +492,10 @@ def hvac_modes(self) -> list[HVACMode]: @property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" + # The Loxone Config app allows the designer to set an arbitrary + # format string for the room controller's input temperature sensor. + # We assume that the format string contains the unit of temperature, + # and default to Celsius if not. format_str = self.details.get("format") if format_str is None: @@ -519,6 +512,7 @@ def temperature_unit(self) -> str: @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" + return self.get_state_value("tempTarget") @property @@ -548,9 +542,7 @@ def preset_modes(self): 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, @@ -562,18 +554,12 @@ 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 - ) + 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="stopOverride") - ) + 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.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value=f"override/{mode_id}")) self.schedule_update_ha_state() diff --git a/custom_components/loxone/sensor.py b/custom_components/loxone/sensor.py index 2765121..8b77338 100644 --- a/custom_components/loxone/sensor.py +++ b/custom_components/loxone/sensor.py @@ -33,8 +33,7 @@ from homeassistant.util import dt as dt_util from . import LoxoneEntity -from .const import (CLIMATE_EVENT, CONF_ACTIONID, DOMAIN, EVENT, SENDDOMAIN, - THROTTLE_KEEP_ALIVE_TIME) +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 @@ -56,15 +55,15 @@ ) 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: "Overridden by source", + 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", } @@ -269,9 +268,7 @@ async def async_setup_entry( 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", "") - ) + device_info = get_or_create_device(irc["uuidAction"], irc["name"], "RoomControllerV2", irc.get("room", "")) if "overrideReason" in states: entities.append(LoxoneRoomControllerOverrideSensor( @@ -302,9 +299,7 @@ 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) @@ -575,13 +570,14 @@ class LoxoneRoomControllerOverrideSensor(SensorEntity): _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_native_value = "none" self._attr_options = list(OVERRIDE_REASONS.values()) self._parent_uuid = parent_uuid @@ -615,9 +611,7 @@ def __init__(self, **kwargs): self._cool_demand = 0 self.type = "ClimateController" - 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, e): update = False diff --git a/custom_components/loxone/switch.py b/custom_components/loxone/switch.py index 906481c..3a6c82d 100644 --- a/custom_components/loxone/switch.py +++ b/custom_components/loxone/switch.py @@ -5,9 +5,9 @@ https://github.com/JoDehli/PyLoxone """ +from functools import cached_property import json import logging -from functools import cached_property from typing import Any from homeassistant.components.switch import SwitchEntity @@ -47,7 +47,6 @@ async def async_setup_entry( entities = [] 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"]: @@ -323,9 +322,7 @@ def __init__(self, **kwargs): 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 - ) + self._attr_device_info = get_or_create_device(self.uuidAction, self._base_name, "RoomControllerV2", self.room) @cached_property def unique_id(self) -> str: @@ -334,17 +331,13 @@ def unique_id(self) -> str: def turn_on(self, **kwargs): """Trigger comfort override (mode 1).""" - self.hass.bus.fire( - SENDDOMAIN, dict(uuid=self.uuidAction, value="override/1") - ) + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="override/1")) self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Stop the active override.""" - self.hass.bus.fire( - SENDDOMAIN, dict(uuid=self.uuidAction, value="stopOverride") - ) + self.hass.bus.fire(SENDDOMAIN, dict(uuid=self.uuidAction, value="stopOverride")) self._attr_is_on = False self.schedule_update_ha_state() @@ -379,9 +372,7 @@ class LoxoneLightPresenceSwitch(LoxoneEntity, SwitchEntity): 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._attr_device_info = get_or_create_device(self.uuidAction, self.name, "LightControllerV2", self.room) self._attr_name = f"{self.name} Presence Detection" self.type = "LightPresenceSwitch" @@ -391,16 +382,12 @@ def unique_id(self) -> str: return self._presence_id async def async_turn_on(self, **kwargs: Any) -> None: - self.hass.bus.async_fire( - SENDDOMAIN, dict(uuid=self.uuidAction + "/presence", value="on") - ) + 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.hass.bus.async_fire(SENDDOMAIN, dict(uuid=self.uuidAction + "/presence", value="off")) self._attr_is_on = False self.async_schedule_update_ha_state() diff --git a/custom_components/loxone/translations/cs.json b/custom_components/loxone/translations/cs.json index 492ab40..2daa378 100644 --- a/custom_components/loxone/translations/cs.json +++ b/custom_components/loxone/translations/cs.json @@ -11,6 +11,21 @@ } } } + }, + "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 9189eb4..b1ab4f1 100644 --- a/custom_components/loxone/translations/de.json +++ b/custom_components/loxone/translations/de.json @@ -11,6 +11,21 @@ } } } + }, + "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": { diff --git a/custom_components/loxone/translations/en.json b/custom_components/loxone/translations/en.json index 9a42026..879b787 100644 --- a/custom_components/loxone/translations/en.json +++ b/custom_components/loxone/translations/en.json @@ -11,6 +11,21 @@ } } } + }, + "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": {