Skip to content

Commit 32cab84

Browse files
committed
Improve active operation mode
1 parent 65150f9 commit 32cab84

5 files changed

Lines changed: 88 additions & 59 deletions

File tree

‎custom_components/econext/const.py‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,25 @@ def decode_current_season(raw: float | int) -> str | None:
9292
return "winter"
9393
return None
9494

95-
# Current active mode - API parameter 495 HeatingOrCooling (read-only)
96-
ACTIVE_MODE_MAPPING: dict[int, str] = {
97-
0: "standby",
98-
3: "cooling",
99-
4: "heating",
100-
}
101-
102-
ACTIVE_MODE_OPTIONS: list[str] = list(ACTIVE_MODE_MAPPING.values())
95+
# Current active mode - API parameter 495 HeatingOrCooling (read-only).
96+
# This is a bitfield, not a flat enum:
97+
# bit 0 (1) = summer season bit 1 (2) = cooling active bit 2 (4) = heating active
98+
# Observed: 0 winter-idle, 1 summer-idle, 3 summer-cooling, 4 winter-heating, 5 summer-heating.
99+
# The heating bit does not say whether the heat goes to the tank or the radiators,
100+
# so the flap valve (param 83: 0 = CH, 3 = DHW) is used to flag DHW production.
101+
ACTIVE_MODE_OPTIONS: list[str] = ["standby", "cooling", "heating", "dhw"]
102+
103+
104+
def decode_active_mode(mode_raw: float | int, flap_raw: float | int | None) -> str:
105+
"""Decode HeatingOrCooling (495) + flapValveStates (83) into a mode."""
106+
mode = int(mode_raw)
107+
if mode & 0x4: # heating bit
108+
if flap_raw is not None and int(flap_raw) == 3: # flap routed to DHW
109+
return "dhw"
110+
return "heating"
111+
if mode & 0x2: # cooling bit
112+
return "cooling"
113+
return "standby"
103114

104115
# Active preset mode - for circuits in schedule mode (read-only, computed)
105116
ACTIVE_PRESET_MODE_OPTIONS: list[str] = ["eco", "comfort"]
@@ -383,7 +394,6 @@ class EconextButtonEntityDescription:
383394
entity_category=EntityCategory.DIAGNOSTIC,
384395
icon="mdi:sun-snowflake",
385396
options=ACTIVE_MODE_OPTIONS,
386-
value_map=ACTIVE_MODE_MAPPING,
387397
),
388398
# Decoded from workState2 (param 162); reflects the controller's current
389399
# season disposition even when the heat pump is idle - what the panel icon shows.

‎custom_components/econext/sensor.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
HEATPUMP_SCHEDULE_DIAGNOSTIC_SENSORS,
2323
HEATPUMP_SENSORS,
2424
SILENT_MODE_SCHEDULE_DIAGNOSTIC_SENSORS,
25+
decode_active_mode,
2526
get_alarm_name,
2627
)
2728
from .coordinator import EconextCoordinator
@@ -87,7 +88,10 @@ async def async_setup_entry(
8788
for description in CONTROLLER_SENSORS:
8889
# Only add if parameter exists in data
8990
if coordinator.get_param(description.param_id) is not None:
90-
entities.append(EconextSensor(coordinator, description))
91+
if description.key == "active_operating_mode":
92+
entities.append(EconextActiveOperatingModeSensor(coordinator, description))
93+
else:
94+
entities.append(EconextSensor(coordinator, description))
9195
else:
9296
_LOGGER.debug(
9397
"Skipping sensor %s - parameter %s not found",
@@ -372,6 +376,26 @@ def _is_value_valid(self) -> bool:
372376
return True
373377

374378

379+
class EconextActiveOperatingModeSensor(EconextSensor):
380+
"""active_operating_mode: decodes the HeatingOrCooling (495) bitfield.
381+
382+
Uses the flap valve position (param 83: 3 = DHW) to distinguish DHW from
383+
space heating.
384+
"""
385+
386+
_FLAP_VALVE_PARAM = "83"
387+
388+
@property
389+
def native_value(self) -> str | None:
390+
"""Return the decoded operating mode."""
391+
value = self._get_param_value()
392+
if value is None:
393+
return None
394+
flap_param = self.coordinator.get_param(self._FLAP_VALVE_PARAM)
395+
flap_raw = flap_param.get("value") if flap_param else None
396+
return decode_active_mode(value, flap_raw)
397+
398+
375399
class EconextScheduleDiagnosticSensor(EconextSensor):
376400
"""Sensor that decodes schedule bitfields into human-readable format.
377401

‎custom_components/econext/strings.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@
118118
"active_operating_mode": {
119119
"name": "Active operating mode",
120120
"state": {
121-
"summer": "Summer",
122-
"winter": "Winter",
123-
"auto": "Auto",
121+
"standby": "Standby",
122+
"cooling": "Cooling",
123+
"heating": "Heating",
124+
"dhw": "Domestic Hot Water",
124125
"unknown": "Unknown"
125126
}
126127
},

‎custom_components/econext/translations/en.json‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,10 @@
118118
"active_operating_mode": {
119119
"name": "Active operating mode",
120120
"state": {
121-
"summer": "Summer",
122-
"winter": "Winter",
123-
"auto": "Auto",
121+
"standby": "Standby",
122+
"cooling": "Cooling",
123+
"heating": "Heating",
124+
"dhw": "Domestic Hot Water",
124125
"unknown": "Unknown"
125126
}
126127
},

‎tests/test_sensor.py‎

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -237,60 +237,53 @@ def test_flap_valve_enum_value(self, coordinator: EconextCoordinator) -> None:
237237
assert sensor.native_value == "ch"
238238
assert sensor._attr_options == ["ch", "dhw", "unknown"]
239239

240-
def test_active_operating_mode_handles_value_4(self, coordinator: EconextCoordinator) -> None:
241-
"""Test active_operating_mode enum handles value 4 (heating)."""
242-
from custom_components.econext.const import ACTIVE_MODE_MAPPING, ACTIVE_MODE_OPTIONS
240+
def _active_mode_sensor(self, coordinator: EconextCoordinator, mode: int, flap: int):
241+
"""Build an active_operating_mode sensor with param 495 and flap valve 83 set."""
242+
from custom_components.econext.const import ACTIVE_MODE_OPTIONS
243+
from custom_components.econext.sensor import EconextActiveOperatingModeSensor
243244

244-
coordinator.data["495"]["value"] = 4
245+
coordinator.data["495"]["value"] = mode
246+
coordinator.data["83"] = {"value": flap, "name": "flapValveStates", "info": 23}
245247

246248
description = EconextSensorEntityDescription(
247249
key="active_operating_mode",
248250
param_id="495",
249251
device_class=SensorDeviceClass.ENUM,
250252
options=ACTIVE_MODE_OPTIONS,
251-
value_map=ACTIVE_MODE_MAPPING,
252253
)
254+
return EconextActiveOperatingModeSensor(coordinator, description)
253255

254-
sensor = EconextSensor(coordinator, description)
255-
256+
def test_active_operating_mode_winter_heating(self, coordinator: EconextCoordinator) -> None:
257+
"""Value 4 (winter + heating bit), flap on CH -> heating."""
258+
sensor = self._active_mode_sensor(coordinator, mode=4, flap=0)
256259
assert sensor.native_value == "heating"
257260
assert "heating" in sensor._attr_options
258261

259-
def test_active_operating_mode_standby(self, coordinator: EconextCoordinator) -> None:
260-
"""Test active_operating_mode enum returns standby for value 0."""
261-
from custom_components.econext.const import ACTIVE_MODE_MAPPING, ACTIVE_MODE_OPTIONS
262-
263-
coordinator.data["495"]["value"] = 0
264-
265-
description = EconextSensorEntityDescription(
266-
key="active_operating_mode",
267-
param_id="495",
268-
device_class=SensorDeviceClass.ENUM,
269-
options=ACTIVE_MODE_OPTIONS,
270-
value_map=ACTIVE_MODE_MAPPING,
271-
)
272-
273-
sensor = EconextSensor(coordinator, description)
262+
def test_active_operating_mode_winter_idle_standby(self, coordinator: EconextCoordinator) -> None:
263+
"""Value 0 (winter idle, no bits) -> standby."""
264+
sensor = self._active_mode_sensor(coordinator, mode=0, flap=0)
265+
assert sensor.native_value == "standby"
274266

267+
def test_active_operating_mode_summer_idle_standby(self, coordinator: EconextCoordinator) -> None:
268+
"""Value 1 (summer season bit only, HP idle) -> standby."""
269+
sensor = self._active_mode_sensor(coordinator, mode=1, flap=0)
275270
assert sensor.native_value == "standby"
276271

277272
def test_active_operating_mode_cooling(self, coordinator: EconextCoordinator) -> None:
278-
"""Test active_operating_mode enum returns cooling for value 3."""
279-
from custom_components.econext.const import ACTIVE_MODE_MAPPING, ACTIVE_MODE_OPTIONS
280-
281-
coordinator.data["495"]["value"] = 3
282-
283-
description = EconextSensorEntityDescription(
284-
key="active_operating_mode",
285-
param_id="495",
286-
device_class=SensorDeviceClass.ENUM,
287-
options=ACTIVE_MODE_OPTIONS,
288-
value_map=ACTIVE_MODE_MAPPING,
289-
)
273+
"""Value 3 (summer + cooling bit) -> cooling."""
274+
sensor = self._active_mode_sensor(coordinator, mode=3, flap=0)
275+
assert sensor.native_value == "cooling"
290276

291-
sensor = EconextSensor(coordinator, description)
277+
def test_active_operating_mode_dhw(self, coordinator: EconextCoordinator) -> None:
278+
"""Value 5 (heating bit) with flap routed to DHW -> dhw."""
279+
sensor = self._active_mode_sensor(coordinator, mode=5, flap=3)
280+
assert sensor.native_value == "dhw"
281+
assert "dhw" in sensor._attr_options
292282

293-
assert sensor.native_value == "cooling"
283+
def test_active_operating_mode_heating_flap_on_ch(self, coordinator: EconextCoordinator) -> None:
284+
"""Heating bit set but flap on CH -> heating, not dhw."""
285+
sensor = self._active_mode_sensor(coordinator, mode=5, flap=0)
286+
assert sensor.native_value == "heating"
294287

295288
def test_enum_sensor_unmapped_value_returns_unknown(self, coordinator: EconextCoordinator) -> None:
296289
"""Test enum sensor returns 'unknown' for unmapped values (e.g., 65336)."""
@@ -410,16 +403,16 @@ def test_decode_current_season_helper(self) -> None:
410403

411404
def test_enum_sensor_unmapped_value_logs_warning(self, coordinator: EconextCoordinator, caplog: pytest.LogCaptureFixture) -> None:
412405
"""Test enum sensor logs a warning with the raw value for unmapped values."""
413-
from custom_components.econext.const import ACTIVE_MODE_MAPPING, ACTIVE_MODE_OPTIONS
406+
from custom_components.econext.const import HP_STATUS_WORK_MODE_MAPPING, HP_STATUS_WORK_MODE_OPTIONS
414407

415-
coordinator.data["495"]["value"] = 99
408+
coordinator.data["1350"] = {"value": 99, "name": "HPStatusWorkMode", "info": 23}
416409

417410
description = EconextSensorEntityDescription(
418-
key="active_operating_mode",
419-
param_id="495",
411+
key="hp_status_work_mode",
412+
param_id="1350",
420413
device_class=SensorDeviceClass.ENUM,
421-
options=ACTIVE_MODE_OPTIONS,
422-
value_map=ACTIVE_MODE_MAPPING,
414+
options=HP_STATUS_WORK_MODE_OPTIONS,
415+
value_map=HP_STATUS_WORK_MODE_MAPPING,
423416
)
424417

425418
sensor = EconextSensor(coordinator, description)
@@ -429,7 +422,7 @@ def test_enum_sensor_unmapped_value_logs_warning(self, coordinator: EconextCoord
429422

430423
assert result == "unknown"
431424
assert "Unmapped value 99" in caplog.text
432-
assert "active_operating_mode" in caplog.text
425+
assert "hp_status_work_mode" in caplog.text
433426

434427

435428
class TestSensorEntityCategory:

0 commit comments

Comments
 (0)