diff --git a/README.md b/README.md index c9ef95a..140592a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This has been tested using on : - `Atlantic Naema 2 Duo 25` gas boiler using a `Ǹavilink Radio-Connect 128` thermostat - `Atlantic Naia 2 Micro 25` gas boiler using a `Ǹavilink Radio-Connect 128` thermostat - `Atlantic Loria Duo 6006 R32` heat pump using a `Navilink Radio-Connect 128` thermostat + - `Atlantic Alfea Excellia Duo` heat pump using a `Navilink Radio-Connect 228` thermostat - `Takao M3` air conditionning - `Kelud 1750W` towel rack - `Sauter Asama Connecté II Ventilo 1750W` towel rack @@ -25,7 +26,7 @@ More informations about HACS [here](https://hacs.xyz/). #### Manually -Clone this repository and copy `custom_components/cozytouch` to your Home Assistant config durectory (ex : `config/custom_components/cozytouch`) +Clone this repository and copy `custom_components/cozytouch` to your Home Assistant config directory (ex : `config/custom_components/cozytouch`) Restart Home Assistant. diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 16c1f4e..a09ff7d 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -15,6 +15,7 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s if ( capabilityId in (1, 2, 7, 8) and capabilityId in modelInfos["HVACModesCapabilityId"] + and float(capabilityValue) < 3000 ): # Default Ids capability["targetCapabilityId"] = 40 @@ -68,9 +69,21 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability.pop("lowestValueCapabilityId") capability.pop("highestValueCapabilityId") capability["icon"] = "mdi:heat-pump" + elif modelInfos["type"] == CozytouchDeviceType.THERMOSTAT: + capability["name"] = "heat" + capability["progOverrideCapabilityId"] = 157 + capability["progOverrideTotalTimeCapabilityId"] = 158 + capability["progOverrideTimeCapabilityId"] = 159 + capability["lowestCoolValueCapabilityId"] = 162 + capability["highestCoolValueCapabilityId"] = 163 + capability["icon"] = "mdi:thermostat" else: capability["name"] = "heat" + # Add effective target temperature for model 557 + if modelId == 557: + capability["setpointTemperatureId"] = 17 + capability["type"] = "climate" capability["category"] = "sensor" @@ -147,21 +160,21 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["lowestValueCapabilityId"] = 160 capability["highestValueCapabilityId"] = 161 - elif capabilityId == 44: + elif capabilityId == 44 and modelId not in (1693,): capability["name"] = "ch_power_consumption" capability["type"] = "energy" capability["displayed_unit_of_measurement"] = UnitOfEnergy.KILO_WATT_HOUR capability["category"] = "sensor" capability["icon"] = "mdi:radiator" - elif capabilityId == 45: + elif capabilityId == 45 and modelId not in (1693,): capability["name"] = "dhw_power_consumption" capability["type"] = "energy" capability["displayed_unit_of_measurement"] = UnitOfEnergy.KILO_WATT_HOUR capability["category"] = "sensor" capability["icon"] = "mdi:faucet" - elif capabilityId == 46: + elif capabilityId == 46 and modelId not in (1693,): capability["name"] = "total_power_consumption" capability["type"] = "energy" capability["displayed_unit_of_measurement"] = UnitOfEnergy.KILO_WATT_HOUR @@ -174,19 +187,32 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["displayed_unit_of_measurement"] = UnitOfEnergy.KILO_WATT_HOUR capability["category"] = "sensor" + elif capabilityId == 60: + capability["name"] = "total_power_consumption" + capability["type"] = "energy" + capability["displayed_unit_of_measurement"] = UnitOfEnergy.KILO_WATT_HOUR + capability["category"] = "sensor" + elif capabilityId == 86: capability["name"] = "domestic_hot_water" capability["type"] = "switch" capability["category"] = "sensor" capability["icon"] = "mdi:faucet" - elif capabilityId == 87: + elif capabilityId == 87 and modelId not in (1376,): capability["name"] = "heating_mode" capability["type"] = "select" capability["category"] = "sensor" capability["icon"] = "mdi:water-boiler" capability["modelList"] = "HeatingModes" + elif capabilityId == 87 and modelId in (1376,): + capability["name"] = "heating_mode" + capability["type"] = "string" + capability["category"] = "sensor" + capability["icon"] = "mdi:water-boiler" + capability["modelList"] = "HeatingModes" + elif capabilityId == 88: capability["name"] = "model_name" capability["type"] = "string" @@ -302,9 +328,11 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["category"] = "diag" capability["icon"] = "mdi:home-floor-2" - # elif capabilityId == 157: - # # Prog override flag - # return {} + elif capabilityId == 157: + capability["name"] = "override" + capability["type"] = "switch" + capability["category"] = "sensor" + capability["icon"] = "mdi:gesture-tap" elif capabilityId == 158: if modelInfos["type"] == CozytouchDeviceType.TOWEL_RACK: @@ -345,6 +373,18 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["highest_value"] = 28 capability["step"] = 0.5 + elif capabilityId == 162: + capability["name"] = "temperature_min" + capability["type"] = "temperature" + capability["category"] = "sensor" + capability["icon"] = "mdi:thermometer-chevron-down" + + elif capabilityId == 163: + capability["name"] = "temperature_max" + capability["type"] = "temperature" + capability["category"] = "sensor" + capability["icon"] = "mdi:thermometer-chevron-up" + elif capabilityId == 165: capability["name"] = "boost_mode" capability["type"] = "switch" @@ -361,15 +401,21 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["category"] = "diag" capability["icon"] = "mdi:radio-tower" - elif capabilityId == 172: + elif capabilityId in (17, 172): capability["name"] = "away_mode_temperature" capability["type"] = "temperature_adjustment_number" capability["category"] = "sensor" - capability["lowestValueCapabilityId"] = 160 - capability["highestValueCapabilityId"] = 161 + if modelId in (557,): + capability["name"] = "temperature_setpoint" + capability["type"] = "temperature" + else: + capability["name"] = "away_mode_temperature" + capability["type"] = "temperature_adjustment_number" + capability["lowestValueCapabilityId"] = 160 + capability["highestValueCapabilityId"] = 161 elif capabilityId == 177: - if modelInfos["type"] == CozytouchDeviceType.GAZ_BOILER: + if modelInfos["type"] == CozytouchDeviceType.GAZ_BOILER or modelId in (557,): return {} capability["name"] = "target_cool_temperature" @@ -470,7 +516,7 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["category"] = "diag" capability["icon"] = "mdi:wifi" - elif capabilityId in (222, 226): + elif capabilityId in (222, 226) and modelId not in (1376,): capability["name"] = "away_mode" capability["name_0"] = "away_mode_start" capability["name_1"] = "away_mode_stop" @@ -740,6 +786,13 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s capability["lowest_value"] = 5 capability["highest_value"] = 60 capability["step"] = 5 + + elif capabilityId == 105636: + capability["name"] = "heating_mode" + capability["type"] = "select" + capability["category"] = "sensor" + capability["icon"] = "mdi:water-boiler" + capability["modelList"] = "HeatingModes" elif capabilityId == 105906: capability["name"] = "Target 105906" diff --git a/custom_components/cozytouch/climate.py b/custom_components/cozytouch/climate.py index 8143a4d..1c6107f 100644 --- a/custom_components/cozytouch/climate.py +++ b/custom_components/cozytouch/climate.py @@ -2,12 +2,14 @@ from __future__ import annotations +import asyncio import logging from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACMode, + HVACAction, ) from homeassistant.components.climate.const import ( PRESET_ACTIVITY, @@ -33,6 +35,9 @@ PRESET_PROG = "prog" PRESET_OVERRIDE = "override" +PRESET_TRANSITION_TIMEOUT = 15.0 +PRESET_TRANSITION_POLL_INTERVAL = 0.5 + # config flow setup async def async_setup_entry( @@ -97,6 +102,7 @@ def __init__( self._native_value = 0 self._current_value = None + self._current_boiler_temperature_value = None self._attr_native_step = 0.5 self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_min_temp = 0 @@ -181,6 +187,29 @@ def _configure_presets(self): if PRESET_NONE not in self._attr_preset_modes : self._attr_preset_mode = PRESET_BASIC + async def _wait_for_capability_value( + self, + capability_id, + expected_value, + *, + timeout: float = PRESET_TRANSITION_TIMEOUT, + interval: float = PRESET_TRANSITION_POLL_INTERVAL, + ) -> bool: + """Wait until a capability reaches the expected value.""" + expected = str(expected_value) + deadline = asyncio.get_running_loop().time() + timeout + + while True: + current_value = self.coordinator.get_capability_value(capability_id) + if current_value is not None and str(current_value) == expected: + return True + + if asyncio.get_running_loop().time() >= deadline: + return False + + await asyncio.sleep(interval) + await self.coordinator.async_request_refresh() + @callback def _handle_coordinator_update(self) -> None: """Update the values from the hub.""" @@ -224,6 +253,13 @@ def _handle_coordinator_update(self) -> None: self.coordinator.get_capability_value(currentValueId) ) + # Current boiler water temperature value + currentBoilerTemperatureValueId = 109 + if currentBoilerTemperatureValueId: + self._current_boiler_temperature_value = float( + self.coordinator.get_capability_value(currentBoilerTemperatureValueId) + ) + # Lowest adjustment value if ( self._attr_hvac_mode in ( @@ -363,7 +399,46 @@ def current_temperature(self): def target_temperature(self): """Return target temperature.""" return self._native_value - + + @property + def extra_state_attributes(self): + """Return the computed target temperature.""" + if "setpointTemperatureId" in self._capability: + effective_temp = self.coordinator.get_capability_value( + self._capability["setpointTemperatureId"] + ) + if effective_temp is not None: + return { + "temperature_setpoint": float(effective_temp), + } + return None + + @property + def hvac_action(self): + """Return the current HVAC action.""" + if self._attr_hvac_mode == HVACMode.OFF: + return HVACAction.OFF + + # Get effective target temperature + setpoint_temp = None + if "setpointTemperatureId" in self._capability: + setpoint_temp_value = self.coordinator.get_capability_value( + self._capability["setpointTemperatureId"] + ) + if setpoint_temp_value is not None: + setpoint_temp = float(setpoint_temp_value) + + # If no setpoint temp, fall back to target temperature + if setpoint_temp is None: + setpoint_temp = self._native_value + + # Determine action based on current vs target temperature + if self._current_boiler_temperature_value is not None and setpoint_temp is not None: + if self._current_boiler_temperature_value < setpoint_temp: + return HVACAction.HEATING + + return HVACAction.IDLE + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get("temperature") @@ -489,9 +564,20 @@ async def async_set_preset_mode(self, preset_mode): if preset_mode == PRESET_BASIC: await self.coordinator.set_capability_value(progCapabilityId, "0") - elif preset_mode == PRESET_PROG: + elif preset_mode in (PRESET_PROG, PRESET_OVERRIDE): await self.coordinator.set_capability_value(progCapabilityId, "1") + if preset_mode == PRESET_OVERRIDE: + await self.coordinator.async_request_refresh() + prog_ok = await self._wait_for_capability_value( + progCapabilityId, + "1", + ) + if not prog_ok: + _LOGGER.warning( + "Timeout while waiting for prog mode before applying override" + ) + if progOverrideCapabilityId: progOverrideTimeCapabilityId = self._capability.get( "progOverrideTimeCapabilityId", None @@ -516,6 +602,16 @@ async def async_set_preset_mode(self, preset_mode): progOverrideCapabilityId, "1" ) + await self.coordinator.async_request_refresh() + override_ok = await self._wait_for_capability_value( + progOverrideCapabilityId, + "1", + ) + if not override_ok: + _LOGGER.warning( + "Timeout while waiting for override mode to be applied" + ) + else: if progOverrideTimeCapabilityId: await self.coordinator.set_capability_value( diff --git a/custom_components/cozytouch/const.py b/custom_components/cozytouch/const.py index 14bb08f..f2814e3 100644 --- a/custom_components/cozytouch/const.py +++ b/custom_components/cozytouch/const.py @@ -30,4 +30,6 @@ class CozytouchCapabilityVariableType(IntEnum): HEATING_MODE_OFF = "off" HEATING_MODE_MANUAL = "manual" HEATING_MODE_ECO_PLUS = "eco_plus" +HEATING_MODE_ECO = "eco" HEATING_MODE_PROG = "prog" +HEATING_MODE_COMFORT = "comfort" diff --git a/custom_components/cozytouch/icons.json b/custom_components/cozytouch/icons.json index 0b35b82..630555b 100644 --- a/custom_components/cozytouch/icons.json +++ b/custom_components/cozytouch/icons.json @@ -46,9 +46,20 @@ "state_attributes": { "preset_mode": { "state": { - "basic": "mdi:thermostat", - "prog": "mdi:thermostat-auto", - "override": "mdi:thermostat-cog" + "basic": "mdi:thermometer", + "prog": "mdi:clock-outline", + "override": "mdi:timer-sand-complete" + } + } + } + }, + "heat_pump_z1": { + "state_attributes": { + "preset_mode": { + "state": { + "basic": "mdi:thermometer", + "prog": "mdi:clock-outline", + "override": "mdi:timer-sand-complete" } } } diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index aa9df2c..178a5fa 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -37,6 +37,8 @@ SWING_MODE_MIDDLE_DOWN, SWING_MODE_MIDDLE_UP, SWING_MODE_UP, + HEATING_MODE_ECO, + HEATING_MODE_COMFORT, ) @@ -185,7 +187,23 @@ def get_model_infos(modelId: int, zoneName: str | None = None): 0: HVACMode.OFF, } - elif modelId >= 557 and modelId <= 561: + elif modelId == 557: + name = "Thermostat Navilink Connect 228" + if zoneName is not None: + modelInfos["name"] = name + " (" + zoneName + ")" + + modelInfos["type"] = CozytouchDeviceType.THERMOSTAT + modelInfos["currentTemperatureAvailableZ1"] = True + modelInfos["currentTemperatureAvailableZ2"] = False + modelInfos["overrideModeAvailable"] = True + + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + 1: HVACMode.AUTO, + 4: HVACMode.HEAT, + } + + elif modelId >= 558 and modelId <= 561: name = "Air Conditioner " if zoneName is not None: modelInfos["name"] = name + "(" + zoneName + ")" @@ -238,7 +256,20 @@ def get_model_infos(modelId: int, zoneName: str | None = None): 0: HVACMode.OFF, } - elif modelId in (1369, 1376): + elif modelId == 1376: + modelInfos["name"] = "ECS Alfea Excellia" + modelInfos["type"] = CozytouchDeviceType.WATER_HEATER + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + 1: HVACMode.HEAT, + } + + modelInfos["HeatingModes"] = { + 0: HEATING_MODE_ECO, + 1: HEATING_MODE_COMFORT + } + + elif modelId in (1369, 1375): modelInfos["name"] = "Calypso Split" modelInfos["type"] = CozytouchDeviceType.WATER_HEATER modelInfos["HVACModes"] = { @@ -290,6 +321,16 @@ def get_model_infos(modelId: int, zoneName: str | None = None): 4: HVACMode.HEAT, } + elif modelId == 1391: + name = "Alfea Excellia Generator" + if zoneName is not None: + modelInfos["name"] = name + " (" + zoneName + ")" + + modelInfos["type"] = CozytouchDeviceType.UNKNOWN + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + } + elif modelId == 1444: modelInfos["name"] = "Naema 3 Micro 25" modelInfos["type"] = CozytouchDeviceType.GAZ_BOILER @@ -421,6 +462,17 @@ def get_model_infos(modelId: int, zoneName: str | None = None): 4: HEATING_MODE_PROG, } + elif modelId == 1693: + name = "Alfea Excellia User Interface" + if zoneName is not None: + modelInfos["name"] = name + " (" + zoneName + ")" + + modelInfos["type"] = CozytouchDeviceType.UNKNOWN + modelInfos["HVACModes"] = { + 0: HVACMode.OFF, + } + modelInfos["HVACModesCapabilityId"] = {} + else: modelInfos["name"] = "Unknown product (" + str(modelId) + ")" modelInfos["type"] = CozytouchDeviceType.UNKNOWN diff --git a/custom_components/cozytouch/sensor.py b/custom_components/cozytouch/sensor.py index dbcfd1a..66b6f15 100644 --- a/custom_components/cozytouch/sensor.py +++ b/custom_components/cozytouch/sensor.py @@ -723,7 +723,7 @@ def get_value(self) -> str: if strValue != "": strValue += " / " strValue += "%02d:%02d " % (hours, minutes) - strValue += " %d°C" % (prog[1]) + strValue += " %g°C" % (prog[1]) return strValue diff --git a/custom_components/cozytouch/translations/en.json b/custom_components/cozytouch/translations/en.json index be2eca3..efabe70 100644 --- a/custom_components/cozytouch/translations/en.json +++ b/custom_components/cozytouch/translations/en.json @@ -240,6 +240,7 @@ "prog_heat_saturday": { "name": "Saturday Heat prog" }, "prog_heat_sunday": { "name": "Sunday Heat prog" }, "prog_mode": { "name": "Prog Mode" }, + "override": { "name": "Override" }, "quiet_mode": { "name": "Quiet Mode" }, "radio_signal": { "name": "Radio Signal" }, "resistance": { "name": "Resistance" }, diff --git a/custom_components/cozytouch/translations/fr.json b/custom_components/cozytouch/translations/fr.json index bfa1e76..2ae521c 100644 --- a/custom_components/cozytouch/translations/fr.json +++ b/custom_components/cozytouch/translations/fr.json @@ -187,7 +187,7 @@ "heat_pump_z2": { "name": "PAC Z2" }, "hot_water_available": { "name": "Hot Water Available" }, "interface_fw": { "name": "Interface FW" }, - "temperature_setpoint": { "name": "Température Consigne" }, + "temperature_setpoint": { "name": "Température Cible" }, "model_name": { "name": "Modèle" }, "number_of_hours_burner": { "name": "Nb heures brûleur" }, "number_of_hours_ch_pump": { "name": "Nb heures pompe CH" }, @@ -240,6 +240,7 @@ "prog_heat_saturday": { "name": "Samedi Prog Heat" }, "prog_heat_sunday": { "name": "Dimanche Prog Heat" }, "prog_mode": { "name": "Mode Prog" }, + "override": { "name": "Délégation" }, "quiet_mode": { "name": "Mode Silence" }, "radio_signal": { "name": "Signal Radio" }, "resistance": { "name": "Résistance" },