Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This has been tested using on :
- `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
- `Takao M3` air conditionning
- `HUB Navizone` air conditionning gateway driving room units
- `Kelud 1750W` towel rack
- `Sauter Asama Connecté II Ventilo 1750W` towel rack

Expand Down
84 changes: 72 additions & 12 deletions custom_components/cozytouch/capability.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@
from .model import CozytouchDeviceType


def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: str): # noqa: C901
"""Get capabilities for a device."""
def get_capability_infos( # noqa: C901
modelInfos: dict,
capabilityId: int,
capabilityValue: str,
availableCapabilityIds: set[int],
):
"""Get capabilities for a device.

availableCapabilityIds is what the device actually reports. Optional
features are declared per model, but the same model id is reused across
hardware that does not always implement them, so they are only wired up
when the device backs them.
"""
modelId = modelInfos["modelId"]

capability = {"modelId": modelId, "capabilityId": capabilityId}
Expand All @@ -21,9 +32,17 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["lowestValueCapabilityId"] = 160
capability["highestValueCapabilityId"] = 161

if modelInfos.get("currentTemperatureAvailable", True):
if (
modelInfos.get("currentTemperatureAvailable", True)
and 117 in availableCapabilityIds
):
capability["currentValueCapabilityId"] = 117

# 181 carries the mode the device is really running, which is not always
# the one it was asked for
if 181 in availableCapabilityIds:
capability["hvacActionCapabilityId"] = 181

if modelInfos["type"] == CozytouchDeviceType.GAZ_BOILER:
capability["name"] = "central_heating"
capability["icon"] = "mdi:radiator"
Expand All @@ -44,21 +63,33 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["targetCoolCapabilityId"] = 177
capability["lowestCoolValueCapabilityId"] = 162
capability["highestCoolValueCapabilityId"] = 163
capability["activityCapabilityId"] = 100506
capability["ecoCapabilityId"] = 100507
capability["boostCapabilityId"] = 100505
if 100506 in availableCapabilityIds:
capability["activityCapabilityId"] = 100506
if (
modelInfos.get("ecoModeAvailable", True)
and 100507 in availableCapabilityIds
):
capability["ecoCapabilityId"] = 100507
if 100505 in availableCapabilityIds:
capability["boostCapabilityId"] = 100505
elif modelInfos["type"] == CozytouchDeviceType.HEAT_PUMP:
if capabilityId in (1, 7):
capability["name"] = "heat_pump_z1"
capability["targetCapabilityId"] = 17
if modelInfos.get("currentTemperatureAvailableZ1", True):
if (
modelInfos.get("currentTemperatureAvailableZ1", True)
and 117 in availableCapabilityIds
):
capability["currentValueCapabilityId"] = 117
else:
capability["currentValueCapabilityId"] = None
else:
capability["name"] = "heat_pump_z2"
capability["targetCapabilityId"] = 18
if modelInfos.get("currentTemperatureAvailableZ2", True):
if (
modelInfos.get("currentTemperatureAvailableZ2", True)
and 118 in availableCapabilityIds
):
capability["currentValueCapabilityId"] = 118
else:
capability["currentValueCapabilityId"] = None
Expand All @@ -74,10 +105,13 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["type"] = "climate"
capability["category"] = "sensor"

if "fanModes" in modelInfos:
if "fanModes" in modelInfos and 100801 in availableCapabilityIds:
capability["fanModeCapabilityId"] = 100801

if modelInfos.get("quietModeAvailable", False):
if (
modelInfos.get("quietModeAvailable", False)
and 100802 in availableCapabilityIds
):
capability["quietModeCapabilityId"] = 100802

if modelInfos.get("overrideModeAvailable", True):
Expand All @@ -86,9 +120,11 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["progOverrideTotalTimeCapabilityId"] = 158
capability["progOverrideTimeCapabilityId"] = 159

if "swingModes" in modelInfos:
if "swingModes" in modelInfos and 100803 in availableCapabilityIds:
capability["swingModeCapabilityId"] = 100803
capability["swingOnCapabilityId"] = 100804

if 100804 in availableCapabilityIds:
capability["swingOnCapabilityId"] = 100804

elif capabilityId == 19:
capability["name"] = "temperature_setpoint"
Expand Down Expand Up @@ -362,6 +398,15 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["icon"] = "mdi:radio-tower"

elif capabilityId == 172:
# Absence setpoint. Only the heating products act on it. An air
# conditioner reports it and stores what is written, but never reads it
# back: absence there stops the units until the return date, and the
# weekly program keeps driving 40 and 177 throughout. Exposing a number
# nothing honours would promise a setting the Cozytouch app does not
# even offer on this hardware.
if not modelInfos.get("awayModeTemperatureAvailable", True):
return {}

capability["name"] = "away_mode_temperature"
capability["type"] = "temperature_adjustment_number"
capability["category"] = "sensor"
Expand Down Expand Up @@ -610,6 +655,15 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["category"] = "diag"
capability["icon"] = "mdi:tag"

elif capabilityId == 100261:
# Per-room mirror of the hub's away mode flag (152). The room units carry
# it alongside the absence window in 100260, which stays unmapped: the
# window is written for the whole setup from the hub, not room by room.
capability["name"] = "away_mode"
capability["type"] = "binary"
capability["category"] = "sensor"
capability["icon"] = "mdi:airplane"

elif capabilityId == 100402:
capability["name"] = "number_of_hours_burner"
capability["type"] = "int"
Expand Down Expand Up @@ -638,6 +692,12 @@ def get_capability_infos(modelInfos: dict, capabilityId: int, capabilityValue: s
capability["icon"] = "mdi:account"

elif capabilityId == 100507:
# Same story as the absence setpoint in 172: the air conditioners report
# eco mode without the Cozytouch app ever offering it. Reported is not
# supported, so let the model table decide.
if not modelInfos.get("ecoModeAvailable", True):
return {}

capability["name"] = "eco_mode"
capability["type"] = "switch"
capability["category"] = "sensor"
Expand Down
23 changes: 23 additions & 0 deletions custom_components/cozytouch/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.components.climate.const import (
Expand All @@ -33,6 +34,18 @@
PRESET_PROG = "prog"
PRESET_OVERRIDE = "override"

# The effective mode capability uses the same value scale as the requested mode,
# so it is read through the model's HVACModes and then translated. HVACMode.AUTO
# has no HVACAction counterpart on purpose: a system in auto is really heating,
# cooling or idle, and guessing which would be worse than reporting nothing.
HVAC_ACTIONS = {
HVACMode.OFF: HVACAction.OFF,
HVACMode.COOL: HVACAction.COOLING,
HVACMode.HEAT: HVACAction.HEATING,
HVACMode.DRY: HVACAction.DRYING,
HVACMode.FAN_ONLY: HVACAction.FAN,
}


# config flow setup
async def async_setup_entry(
Expand Down Expand Up @@ -193,6 +206,16 @@ def _handle_coordinator_update(self) -> None:
if currentMode in HVACModes:
self._attr_hvac_mode = HVACModes[currentMode]

# Effective mode, which can differ from the requested one: on a zoned
# install only the master picks the mode, so a slave keeps its own
# request here while actually running whatever the master imposes.
actionId = self._capability.get("hvacActionCapabilityId", None)
if actionId:
actionRaw = self.coordinator.get_capability_value(actionId)
if actionRaw is not None:
actionMode = HVACModes.get(int(actionRaw), None)
self._attr_hvac_action = HVAC_ACTIONS.get(actionMode, None)

# Target value
if self._attr_hvac_mode in (
HVACMode.OFF,
Expand Down
10 changes: 4 additions & 6 deletions custom_components/cozytouch/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,11 +428,15 @@ def get_capabilities_for_device(self, deviceId: int | None = None):
for dev in self._devices:
if dev["deviceId"] == deviceId:
modelInfos = get_model_infos(dev["modelId"])
availableCapabilityIds = {
cap["capabilityId"] for cap in dev["capabilities"]
}
for capability in dev["capabilities"]:
capability_infos = get_capability_infos(
modelInfos,
capability["capabilityId"],
capability["value"],
availableCapabilityIds,
)

if capability_infos is None and self._create_unknown:
Expand Down Expand Up @@ -461,12 +465,6 @@ def get_capabilities_for_device(self, deviceId: int | None = None):

return capabilities

def get_capability_infos(
self, modelId: int, capabilityId: int, capabilityValue: str
):
"""Get capability infos."""
return get_capability_infos(modelId, capabilityId, capabilityValue)

def get_capability_value(
self, capabilityId: int, defaultIfNotExist: str | None = "0"
):
Expand Down
22 changes: 20 additions & 2 deletions custom_components/cozytouch/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* fanModes : list of value/mode pairs
* swingModes : list of value/mode pairs
* quietModeAvailable : enable quiet mode availability (default : False)
* awayModeTemperatureAvailable : enable the absence setpoint (default : True)
* ecoModeAvailable : enable eco mode availability (default : True)

""" # noqa: D205

Expand Down Expand Up @@ -181,6 +183,7 @@ def get_model_infos(modelId: int, zoneName: str | None = None):
elif modelId == 556:
modelInfos["name"] = "Naviclim Hub"
modelInfos["type"] = CozytouchDeviceType.HUB
modelInfos["awayModeTemperatureAvailable"] = False
modelInfos["HVACModes"] = {
0: HVACMode.OFF,
}
Expand All @@ -191,7 +194,16 @@ def get_model_infos(modelId: int, zoneName: str | None = None):
modelInfos["HVACModes"] = {
0: HVACMode.OFF,
}


elif modelId == 1758:
# AC gateway, drives the same 557-561 units as the 556 Naviclim hub
modelInfos["name"] = "HUB Navizone"
modelInfos["type"] = CozytouchDeviceType.HUB
modelInfos["awayModeTemperatureAvailable"] = False
modelInfos["HVACModes"] = {
0: HVACMode.OFF,
}

elif (modelId >= 557 and modelId <= 561) or modelId == 1734:
name = "Air Conditioner "
if zoneName is not None:
Expand All @@ -202,8 +214,14 @@ def get_model_infos(modelId: int, zoneName: str | None = None):
modelInfos["name"] = name + "(#" + str(modelId - 1733) + ")"

modelInfos["type"] = CozytouchDeviceType.AC
modelInfos["currentTemperatureAvailable"] = False
modelInfos["quietModeAvailable"] = True
modelInfos["awayModeTemperatureAvailable"] = False

# The room units behind a Naviclim/Navizone hub report 100507, but the
# Cozytouch app offers no eco mode for them anywhere. 1734 is a separate
# product and is left alone, no report either way on that one.
if modelId <= 561:
modelInfos["ecoModeAvailable"] = False

modelInfos["fanModes"] = {
1: FAN_LOW,
Expand Down