Skip to content

Commit 07f1eab

Browse files
trcyberopticclaude
andcommitted
Fix event parsing: use correct PlantEventDTO field names
The coordinator was mapping non-existent API fields (message, timestamp, circuitPath, isActive) instead of the actual PlantEventDTO fields (description, timeOccurred, sourcePath, timeResolved). This caused all event sensors to show None and the error binary sensor to never trigger. Changes: - Map correct API fields: description, timeOccurred, timeResolved, sourcePath, code - Derive is_active from timeResolved (null = active) instead of non-existent isActive field - Add warning events to has_error detection (was only blocking/locking) - Extract _parse_event() helper for DRY event parsing - Update sensor value_fn lambdas for renamed dataclass fields - Update diagnostics PII redaction for renamed fields - Add 7 tests for event parsing and is_active logic Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f73cd4 commit 07f1eab

6 files changed

Lines changed: 95 additions & 31 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The integration lives in `custom_components/hoval_connect/`. User setup is email
3030
- `fan.py` — Fan entity for HV ventilation: 0–100% speed slider (`FanEntityFeature.SET_SPEED`), on/off toggle (standby ↔ temporary-change), debounced slider input (1.5s), proper cleanup via `async_will_remove_from_hass`. Only created for HV circuits.
3131
- `select.py` — Select entity for program selection (week1/week2/ecoMode/standby/constant). Shows user-defined program names from the API (`circuit.program_names`), falls back to `DEFAULT_NAMES`. Bidirectional mapping via `_display_name()` / `_api_key_from_display()`. Only created for HV/HK circuits.
3232
- `sensor.py` — 9 sensor entities per circuit (outside temp, exhaust temp, air volume, humidity actual/target, operation mode, active week/day program, program air volume) + 6 plant-level sensors (latest event type/message/time, active event count, weather condition/temperature). Diagnostic sensors use `EntityCategory.DIAGNOSTIC`.
33-
- `binary_sensor.py` — 2 binary sensors per plant (online status with connectivity class, error status with problem class)
33+
- `binary_sensor.py` — 2 binary sensors per plant (online status with connectivity class, error/warning status with problem class — triggers on active blocking/locking/warning events)
3434
- `diagnostics.py` — Diagnostic data export with automatic PII redaction
3535
- `const.py` — Constants: API URLs, OAuth client ID, token TTLs (25min ID, 12min PAT), polling interval (configurable, default 60s), circuit types + human-readable names, operation modes, duration enums (FOUR/MIDNIGHT)
3636
- `__init__.py` — Entry setup, runtime data, platform forwarding (binary_sensor, climate, fan, select, sensor), DeviceInfo helpers (`plant_device_info`, `circuit_device_info`), options update listener for dynamic polling interval changes
@@ -92,6 +92,8 @@ HK (heating), BL (boiler), WW (warm water), FRIWA (fresh water), HV (ventilation
9292
- v3 `activeProgram` enum: `constant`, `ecoMode`, `standby`, `week1`, `week2`, `manual`, `externalConstant`
9393
- The integration fetches circuits via v1 but controls via v3 — coordinator normalizes v1 values to v3 via `_V1_PROGRAM_MAP`
9494
- Weather forecast available via `get_weather()` — returns condition + temperature
95+
- `PlantEventDTO` fields: `eventType`, `description`, `timeOccurred`, `timeResolved`, `sourcePath`, `code`, `module`, `functionGroup`, `function`, `category` — event is active when `timeResolved` is null
96+
- Event types: `locking`, `blocking`, `warning`, `info`, `offline`, `ok` — the error binary sensor triggers on active `blocking`, `locking`, or `warning` events
9597

9698
## HA Compatibility Notes
9799

custom_components/hoval_connect/coordinator.py

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,16 @@ class HovalEventData:
8383
"""Parsed data for a plant event."""
8484

8585
event_type: str | None = None
86-
message: str | None = None
87-
timestamp: str | None = None
88-
circuit_path: str | None = None
89-
is_active: bool = False
86+
description: str | None = None
87+
time_occurred: str | None = None
88+
time_resolved: str | None = None
89+
source_path: str | None = None
90+
code: int | None = None
91+
92+
@property
93+
def is_active(self) -> bool:
94+
"""Event is active when it has not been resolved."""
95+
return self.time_resolved is None
9096

9197

9298
@dataclass
@@ -140,6 +146,18 @@ class HovalData:
140146
plants: dict[str, HovalPlantData] = field(default_factory=dict)
141147

142148

149+
def _parse_event(raw: dict) -> HovalEventData:
150+
"""Parse a PlantEventDTO dict into HovalEventData."""
151+
return HovalEventData(
152+
event_type=raw.get("eventType"),
153+
description=raw.get("description"),
154+
time_occurred=raw.get("timeOccurred"),
155+
time_resolved=raw.get("timeResolved"),
156+
source_path=raw.get("sourcePath"),
157+
code=raw.get("code"),
158+
)
159+
160+
143161
DEFAULT_FAN_SPEED = 40
144162

145163

@@ -387,17 +405,12 @@ async def _fetch_circuit(
387405
# Process latest event
388406
latest_result = all_results[latest_idx]
389407
if not isinstance(latest_result, BaseException) and latest_result:
390-
plant_data.latest_event = HovalEventData(
391-
event_type=latest_result.get("eventType"),
392-
message=latest_result.get("message"),
393-
timestamp=latest_result.get("timestamp"),
394-
circuit_path=latest_result.get("circuitPath"),
395-
is_active=latest_result.get("isActive", False),
396-
)
408+
plant_data.latest_event = _parse_event(latest_result)
397409
_LOGGER.debug(
398-
"Latest event: type=%s active=%s",
399-
latest_result.get("eventType"),
400-
latest_result.get("isActive"),
410+
"Latest event: type=%s active=%s desc=%s",
411+
plant_data.latest_event.event_type,
412+
plant_data.latest_event.is_active,
413+
plant_data.latest_event.description,
401414
)
402415
elif isinstance(latest_result, BaseException):
403416
_LOGGER.debug("Events endpoint not available for %s", plant_id)
@@ -406,17 +419,13 @@ async def _fetch_circuit(
406419
events_result = all_results[events_idx]
407420
if not isinstance(events_result, BaseException) and events_result:
408421
for ev in events_result[:10]:
409-
plant_data.events.append(
410-
HovalEventData(
411-
event_type=ev.get("eventType"),
412-
message=ev.get("message"),
413-
timestamp=ev.get("timestamp"),
414-
circuit_path=ev.get("circuitPath"),
415-
is_active=ev.get("isActive", False),
416-
)
417-
)
422+
plant_data.events.append(_parse_event(ev))
418423
for ev in plant_data.events:
419-
if ev.is_active and ev.event_type in ("blocking", "locking"):
424+
if ev.is_active and ev.event_type in (
425+
"blocking",
426+
"locking",
427+
"warning",
428+
):
420429
plant_data.has_error = True
421430
break
422431
elif isinstance(events_result, BaseException):

custom_components/hoval_connect/diagnostics.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@
1919
"plantExternalId",
2020
"name",
2121
"description",
22-
"circuit_path",
23-
"message",
22+
"source_path",
2423
}
2524

2625

custom_components/hoval_connect/sensor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,14 @@ class HovalPlantSensorEntityDescription(SensorEntityDescription):
122122
translation_key="latest_event_message",
123123
icon="mdi:message-alert-outline",
124124
entity_category=EntityCategory.DIAGNOSTIC,
125-
value_fn=lambda p: p.latest_event.message if p.latest_event else None,
125+
value_fn=lambda p: p.latest_event.description if p.latest_event else None,
126126
),
127127
HovalPlantSensorEntityDescription(
128128
key="latest_event_time",
129129
translation_key="latest_event_time",
130130
device_class=SensorDeviceClass.TIMESTAMP,
131131
entity_category=EntityCategory.DIAGNOSTIC,
132-
value_fn=lambda p: p.latest_event.timestamp if p.latest_event else None,
132+
value_fn=lambda p: p.latest_event.time_occurred if p.latest_event else None,
133133
),
134134
HovalPlantSensorEntityDescription(
135135
key="active_events",

tests/test_coordinator.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
from custom_components.hoval_connect.coordinator import ( # noqa: E402
3232
_V1_PROGRAM_MAP,
3333
HovalCircuitData,
34+
HovalEventData,
35+
_parse_event,
3436
_resolve_active_program_value,
3537
resolve_fan_speed,
3638
)
@@ -229,3 +231,56 @@ def test_v3_values_pass_through(self):
229231

230232
def test_none_passes_through(self):
231233
assert _V1_PROGRAM_MAP.get(None, None) is None
234+
235+
236+
class TestParseEvent:
237+
"""Tests for _parse_event() and HovalEventData."""
238+
239+
def test_parse_full_event(self):
240+
raw = {
241+
"eventType": "warning",
242+
"description": "Filterwechsel erforderlich",
243+
"timeOccurred": "2026-02-17T10:30:00Z",
244+
"timeResolved": None,
245+
"sourcePath": "520.50.0",
246+
"code": 12345,
247+
}
248+
ev = _parse_event(raw)
249+
assert ev.event_type == "warning"
250+
assert ev.description == "Filterwechsel erforderlich"
251+
assert ev.time_occurred == "2026-02-17T10:30:00Z"
252+
assert ev.time_resolved is None
253+
assert ev.source_path == "520.50.0"
254+
assert ev.code == 12345
255+
256+
def test_active_when_not_resolved(self):
257+
ev = _parse_event({"eventType": "warning", "timeResolved": None})
258+
assert ev.is_active is True
259+
260+
def test_inactive_when_resolved(self):
261+
ev = _parse_event({"eventType": "warning", "timeResolved": "2026-02-17T12:00:00Z"})
262+
assert ev.is_active is False
263+
264+
def test_active_when_time_resolved_missing(self):
265+
"""If API doesn't return timeResolved at all, event is active."""
266+
ev = _parse_event({"eventType": "blocking"})
267+
assert ev.is_active is True
268+
269+
def test_parse_empty_dict(self):
270+
ev = _parse_event({})
271+
assert ev.event_type is None
272+
assert ev.description is None
273+
assert ev.time_occurred is None
274+
assert ev.time_resolved is None
275+
assert ev.source_path is None
276+
assert ev.code is None
277+
assert ev.is_active is True # no timeResolved → active
278+
279+
def test_default_event_data_is_active(self):
280+
"""Default HovalEventData has no timeResolved so is active."""
281+
ev = HovalEventData()
282+
assert ev.is_active is True
283+
284+
def test_resolved_event_data(self):
285+
ev = HovalEventData(time_resolved="2026-02-17T12:00:00Z")
286+
assert ev.is_active is False

tests/test_diagnostics.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,4 @@ def test_coordinator_redacts_pii(self):
4949
"""Verify that names/descriptions that could identify the user are redacted."""
5050
assert "name" in REDACT_COORDINATOR
5151
assert "description" in REDACT_COORDINATOR
52-
assert "circuit_path" in REDACT_COORDINATOR
53-
assert "message" in REDACT_COORDINATOR
52+
assert "source_path" in REDACT_COORDINATOR

0 commit comments

Comments
 (0)