From 7acc0ed1872a813289ae7b043f7fa8b8e745fe9b Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Wed, 12 Aug 2026 19:31:26 +0200 Subject: [PATCH 1/7] Add modelId 1758 (HUB Navizone) to the model table My installation exposes a device with modelId 1758 that is missing from model.py, so it falls through to the else branch and shows up as "Unknown product (1758)" in the device panel. setupviewv2 reports it with longName "HUB Navizone", modelFamily "Air_Conditioning". It is the gateway for the air conditioners, which on my setup are modelId 557-559 -- the same range the 556 Naviclim hub drives, so the two look like sibling gateway generations. Display only: CozytouchDeviceType.HUB is set but never read, since capability.py only branches on GAZ_BOILER, TOWEL_RACK, AC and HEAT_PUMP. Entities for the device were already being created correctly. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/cozytouch/model.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index 862b9bd..bf6b50b 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -191,6 +191,14 @@ 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["HVACModes"] = { + 0: HVACMode.OFF, + } elif (modelId >= 557 and modelId <= 561) or modelId == 1734: name = "Air Conditioner " From 36759cb9bf45c4b61cb4d2136bb18d15b89111a3 Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Thu, 13 Aug 2026 09:56:45 +0200 Subject: [PATCH 2/7] Add HUB Navizone to the tested devices list Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c9ef95a..8f91b93 100644 --- a/README.md +++ b/README.md @@ -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 From 31108aaedb0c41e69575b134848581beed6837af Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Thu, 13 Aug 2026 09:46:11 +0200 Subject: [PATCH 3/7] Only wire optional climate features the device actually reports fanModes, swingModes and quietModeAvailable are declared per model, and capability.py turned them into capability ids 100801-100804 unconditionally. climate.py then enables FAN_MODE and SWING_MODE purely on the presence of those keys, so the controls appeared whether or not the hardware backed them. Same for the AC presets, wired to 100505-100507 with no check. The model id cannot answer this on its own. On my setup a 1758 gateway drives modelId 557-559 units -- the same range the 556 Naviclim hub drives -- and those units report none of 100801, 100802, 100803, 100804, 100505 or 100506. Only 100507 (eco) exists. So the same model id spans hardware with different feature sets. The symptom was silent rather than a crash: get_capability_value falls back to "0" for a capability the device does not have, so the fan and swing selectors showed a fixed bogus value and writes went to an id the device ignores. get_capability_infos now takes the set of capability ids the device reports and only wires each optional feature when it is backed. Devices that do report them are unaffected. The gating in climate.py already keyed on these keys being present, so it needed no change. Also drops the unused Hub.get_capability_infos, which passed a modelId int where get_capability_infos expects a modelInfos dict and would have raised on any call. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/cozytouch/capability.py | 37 +++++++++++++++++------ custom_components/cozytouch/hub.py | 10 +++--- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 16c1f4e..64e20e9 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -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} @@ -44,9 +55,12 @@ 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 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" @@ -74,10 +88,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): @@ -86,9 +103,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" diff --git a/custom_components/cozytouch/hub.py b/custom_components/cozytouch/hub.py index cacbc94..0c745fa 100644 --- a/custom_components/cozytouch/hub.py +++ b/custom_components/cozytouch/hub.py @@ -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: @@ -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" ): From 9e1ce32b8d6729af21ed71ff477c362333a8f279 Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Thu, 13 Aug 2026 10:25:05 +0200 Subject: [PATCH 4/7] Let the reported capabilities decide whether a climate has a current temperature The 557-561 and 1734 branch set currentTemperatureAvailable = False, so currentValueCapabilityId was never wired and those climate entities had no ambient reading at all -- only a setpoint. Capability 117 is mapped as its own sensor either way, so the temperature was visible in HA while the climate entity itself reported nothing, leaving thermostat cards, current_temperature consumers and Matter bridges without it. My units do report 117, with a distinct value per room, so the flag was wrong for this hardware. Flipping it for the whole range would repeat the mistake in the other direction: get_capability_value falls back to "0" for a capability the device lacks, so a unit in that range without 117 would show a confident 0 degrees, which is worse than showing nothing. So the model flag stays as an explicit opt-out, but the device now has the final say -- the same rule this branch already applies to the fan, swing and preset ids. The AC branch no longer forces the flag off, and units that do not report 117 simply get no current temperature. Applied to the heat pump z1/z2 overrides too, for 117 and 118, so every path that wires a current temperature checks the device first. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/cozytouch/capability.py | 15 ++++++++++++--- custom_components/cozytouch/model.py | 1 - 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 64e20e9..84c8885 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -32,7 +32,10 @@ def get_capability_infos( # noqa: C901 capability["lowestValueCapabilityId"] = 160 capability["highestValueCapabilityId"] = 161 - if modelInfos.get("currentTemperatureAvailable", True): + if ( + modelInfos.get("currentTemperatureAvailable", True) + and 117 in availableCapabilityIds + ): capability["currentValueCapabilityId"] = 117 if modelInfos["type"] == CozytouchDeviceType.GAZ_BOILER: @@ -65,14 +68,20 @@ def get_capability_infos( # noqa: C901 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 diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index bf6b50b..460381e 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -210,7 +210,6 @@ 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["fanModes"] = { From c5b10da01160179134d543259afc6d3ccc1201f5 Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Thu, 13 Aug 2026 10:58:35 +0200 Subject: [PATCH 5/7] Report the mode the device is really running as hvac_action On a zoned install only the master room picks the operating mode. A slave accepts a mode write and stores it, but keeps running whatever the master imposes, so capability 7 is a request rather than a state. Capability 181 carries the effective mode. The integration only ever read 7, so HA showed the request as if it were reality: setting a slave to heat while the system cooled left the card claiming heat with nothing to contradict it. Reproduced on my install -- capability 7 = 4 on a slave, 181 = 3, and the room blowing cold. The official app has the same blind spot, colouring the room warm. 181 uses the same value scale as 7, so it is read through the model's HVACModes and translated to HVACAction. That sidesteps having to identify the master at all: every room reports what it is doing, so no guessing which one commands, which is what blocked this until now. HVACMode.AUTO is deliberately left unmapped -- a system in auto is really heating, cooling or idle, and there is no HVACAction for it, so returning nothing beats guessing. Gated on 181 being reported, like the other optional features, so devices that do not expose it are unaffected. Only 0 and 3 were observed on my hardware; 4, 7 and 8 are extrapolated from the capability 7 scale. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/cozytouch/capability.py | 5 +++++ custom_components/cozytouch/climate.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 84c8885..d60fc93 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -38,6 +38,11 @@ def get_capability_infos( # noqa: C901 ): 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" diff --git a/custom_components/cozytouch/climate.py b/custom_components/cozytouch/climate.py index 8143a4d..df2bbc6 100644 --- a/custom_components/cozytouch/climate.py +++ b/custom_components/cozytouch/climate.py @@ -7,6 +7,7 @@ from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.components.climate.const import ( @@ -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( @@ -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, From 1332468271a6fde86d2dd50c6b606fe94dd63234 Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Sun, 16 Aug 2026 11:41:49 +0200 Subject: [PATCH 6/7] Stop offering an absence setpoint the air conditioners ignore Capability 172 has been mapped for every device since the gas boiler days, so an air conditioner gets a writable "Away Mode Temperature" number. The hardware does not honour it: a Navizone setup captured mid-absence has 172 sitting at the value Home Assistant wrote days earlier, while the weekly program keeps driving the setpoints hour by hour -- 177 moved to 26 at 03:00 against the cooling slot [180, 26], and 40 moved to 21 at 07:00 against the heating slot [420, 21], both well inside the absence window. Absence on this family stops the units until the return date, which is why the Cozytouch app offers a return date and no temperature at all. Gate it behind awayModeTemperatureAvailable and clear the flag on the air conditioners and on the two hubs that drive them, leaving the heating products untouched. Map 100261 while here, the per-room mirror of the hub's away mode flag. Its neighbour 100260 carries the absence window per room and stays unmapped: the window is written for the whole setup, not room by room. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/cozytouch/capability.py | 18 ++++++++++++++++++ custom_components/cozytouch/model.py | 6 +++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index d60fc93..4e8adeb 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -395,6 +395,15 @@ def get_capability_infos( # noqa: C901 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" @@ -643,6 +652,15 @@ def get_capability_infos( # noqa: C901 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" diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index 460381e..0a2db43 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -14,6 +14,7 @@ * 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) """ # noqa: D205 @@ -181,6 +182,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, } @@ -196,10 +198,11 @@ def get_model_infos(modelId: int, zoneName: str | None = None): # 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: @@ -211,6 +214,7 @@ def get_model_infos(modelId: int, zoneName: str | None = None): modelInfos["type"] = CozytouchDeviceType.AC modelInfos["quietModeAvailable"] = True + modelInfos["awayModeTemperatureAvailable"] = False modelInfos["fanModes"] = { 1: FAN_LOW, From 5fff3207e282119adc06d38d8228ea2e4a8141ef Mon Sep 17 00:00:00 2001 From: Mathieu Le Tyrant Date: Mon, 17 Aug 2026 09:25:57 +0200 Subject: [PATCH 7/7] Stop offering an eco mode the room air conditioners do not have The room units behind a Naviclim or Navizone hub report capability 100507, so they get an eco_mode switch and PRESET_ECO on the climate. The Cozytouch app offers no eco mode for them anywhere, neither on the home screen nor in a room's own screen, and 100507 has sat at 0 since provisioning on the setup we have a capture of. Gate both entry points behind ecoModeAvailable, scoped to 557-561: those are the units a hub drives, which is what the report covers. 1734 shares the AC branch but is a separate product with nothing reported either way, so it keeps its eco mode. With activity and boost already absent from these units, dropping eco leaves the climate without PRESET_NONE, so its presets become basic, prog and override -- which is what actually drives them. Co-Authored-By: Claude Opus 5 (1M context) --- custom_components/cozytouch/capability.py | 11 ++++++++++- custom_components/cozytouch/model.py | 7 +++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/custom_components/cozytouch/capability.py b/custom_components/cozytouch/capability.py index 4e8adeb..aecefa4 100644 --- a/custom_components/cozytouch/capability.py +++ b/custom_components/cozytouch/capability.py @@ -65,7 +65,10 @@ def get_capability_infos( # noqa: C901 capability["highestCoolValueCapabilityId"] = 163 if 100506 in availableCapabilityIds: capability["activityCapabilityId"] = 100506 - if 100507 in availableCapabilityIds: + if ( + modelInfos.get("ecoModeAvailable", True) + and 100507 in availableCapabilityIds + ): capability["ecoCapabilityId"] = 100507 if 100505 in availableCapabilityIds: capability["boostCapabilityId"] = 100505 @@ -689,6 +692,12 @@ def get_capability_infos( # noqa: C901 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" diff --git a/custom_components/cozytouch/model.py b/custom_components/cozytouch/model.py index 0a2db43..9470f65 100644 --- a/custom_components/cozytouch/model.py +++ b/custom_components/cozytouch/model.py @@ -15,6 +15,7 @@ * 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 @@ -216,6 +217,12 @@ def get_model_infos(modelId: int, zoneName: str | None = None): 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, 2: FAN_MEDIUM,