Skip to content

Commit c23f4fb

Browse files
committed
Don't let an ambiguous cause code raise a fault
Two review findings on the new appliance-status and electricity metadata. The cause-only fallback kept the first-seen mapping when several display codes share a cause. Nine causes have display variants that disagree, and two of them turn a normal state into a fault: cause 273 is a 24h safety shutdown under display 3F but flame monitoring under 0U, and 280 is a restart-time fault under 7L but a fan start under 0U. 0U codes 270-274, 280 and 281 are the normal ignition sequence, so a boiler that is merely starting up with no display code would report a safety shutdown. Build the fallback only from causes whose variants agree; ambiguous ones read unknown and keep their raw codes on the entity attributes. Also revert the electricity dayAverage/monthAverage promotion to device_class=ENERGY + state_class=TOTAL + last_reset. The paths are named average, and an average that falls as well as rises is not a TOTAL: HA reads each decrease as a meter reset and the sensor becomes selectable as an Energy Dashboard source. Wrong long-term statistics are painful to unwind, so these stay plain sensors until someone watches the value across a full day on real hardware.
1 parent 2b4f3fc commit c23f4fb

2 files changed

Lines changed: 63 additions & 31 deletions

File tree

custom_components/bosch/pointtapi_entities.py

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -174,14 +174,23 @@ def _build_appliance_status_by_cause(
174174
) -> dict[int, str]:
175175
"""Build a cause-only fallback map from the pair table.
176176
177-
Keep the first seen mapping for a cause code to preserve the intended
178-
primary meaning when multiple display codes share the same cause.
177+
Only causes whose display variants all mean the same thing get a fallback.
178+
Where they disagree the cause alone does not identify the state — cause 273
179+
is a 24h safety shutdown under display 3F but normal flame monitoring under
180+
0U, and 280 is a restart-time fault under 7L but a normal fan start under
181+
0U — so those are left out and read as unknown rather than raising a fault
182+
for a boiler that is simply starting up. The raw codes stay on the entity
183+
attributes either way.
179184
"""
180185

181-
by_cause: dict[int, str] = {}
186+
candidates: dict[int, set[str]] = {}
182187
for (_display, cause), status in by_code.items():
183-
by_cause.setdefault(cause, status)
184-
return by_cause
188+
candidates.setdefault(cause, set()).add(status)
189+
return {
190+
cause: statuses.pop()
191+
for cause, statuses in candidates.items()
192+
if len(statuses) == 1
193+
}
185194

186195

187196
_APPLIANCE_STATUS_BY_CAUSE: dict[int, str] = _build_appliance_status_by_cause(
@@ -785,12 +794,6 @@ def _start_of_today() -> Any:
785794
return dt_util.start_of_local_day()
786795

787796

788-
def _start_of_month() -> Any:
789-
"""Return start of the current month in local timezone for last_reset."""
790-
now = dt_util.now()
791-
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
792-
793-
794797
# ── Hourly gas usage helper functions ────────────────────────────────────────
795798

796799

@@ -1473,35 +1476,29 @@ def _pointtapi_electricity_average_sensor_descriptions(
14731476
14741477
Exposes /energy/electricity/dayAverage and /energy/electricity/monthAverage
14751478
only when the appliance reports those resources as available.
1479+
1480+
Deliberately carries no device_class/state_class: the paths are named
1481+
"average", and an average that falls as well as rises is not a TOTAL. Given
1482+
state_class=TOTAL plus last_reset, HA reads every decrease as a meter reset
1483+
and the sensor becomes selectable as an Energy Dashboard source — wrong
1484+
statistics that are painful to unwind. Promote these only once someone has
1485+
watched the value across a full day on real hardware.
14761486
"""
14771487
if not data:
14781488
return ()
14791489

14801490
candidates = (
1481-
(
1482-
"/energy/electricity/dayAverage",
1483-
"electricity_day_average",
1484-
_start_of_today,
1485-
),
1486-
(
1487-
"/energy/electricity/monthAverage",
1488-
"electricity_month_average",
1489-
_start_of_month,
1490-
),
1491+
("/energy/electricity/dayAverage", "electricity_day_average"),
1492+
("/energy/electricity/monthAverage", "electricity_month_average"),
14911493
)
14921494

14931495
descriptions: list[BoschPoinTTAPISensorEntityDescription] = []
1494-
for path, translation_key, last_reset_fn in candidates:
1496+
for path, translation_key in candidates:
14951497
if isinstance(data.get(path), dict) and _path_available(data, path):
14961498
descriptions.append(
14971499
BoschPoinTTAPISensorEntityDescription(
14981500
key=path,
14991501
translation_key=translation_key,
1500-
device_class=SensorDeviceClass.ENERGY,
1501-
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
1502-
state_class=SensorStateClass.TOTAL,
1503-
value_fn=lambda d, p=path: _val(d, p),
1504-
last_reset_fn=last_reset_fn,
15051502
)
15061503
)
15071504
return tuple(descriptions)

unittests/test_pointtapi_new_entities.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,24 @@ def test_electricity_average_sensors_are_added_when_available(self):
194194
assert "/energy/electricity/monthAverage" in descs
195195
assert descs["/energy/electricity/dayAverage"].translation_key == "electricity_day_average"
196196
assert descs["/energy/electricity/monthAverage"].translation_key == "electricity_month_average"
197-
assert descs["/energy/electricity/dayAverage"].native_unit_of_measurement == "kWh"
198-
assert descs["/energy/electricity/monthAverage"].native_unit_of_measurement == "kWh"
199-
assert descs["/energy/electricity/dayAverage"].device_class == "energy"
200-
assert descs["/energy/electricity/monthAverage"].device_class == "energy"
197+
198+
def test_electricity_averages_carry_no_statistics_metadata(self):
199+
"""An average is not a TOTAL — keep it out of long-term statistics.
200+
201+
With state_class=TOTAL, every dip in a rolling average reads as a meter
202+
reset and the sensor becomes an Energy Dashboard source. Until someone
203+
confirms on hardware that these accumulate, they stay plain sensors.
204+
"""
205+
data = {
206+
"/energy/electricity/dayAverage": {"value": 3.21, "available": "true"},
207+
"/energy/electricity/monthAverage": {"value": 4.56},
208+
}
209+
descs = {d.key: d for d in _pointtapi_sensor_descriptions(data)}
210+
211+
for path in ("/energy/electricity/dayAverage", "/energy/electricity/monthAverage"):
212+
assert descs[path].device_class is None
213+
assert descs[path].state_class is None
214+
assert descs[path].last_reset_fn is None
201215

202216
def test_electricity_average_sensors_are_not_added_when_unavailable(self):
203217
data = {
@@ -453,6 +467,27 @@ def test_appliance_status_sensor_available_with_only_fault_flags(self):
453467
assert desc.value_fn is not None
454468
assert desc.value_fn(data) == "locking_fault_code_active"
455469

470+
def test_ambiguous_cause_alone_does_not_report_a_fault(self):
471+
"""Causes whose display variants disagree must not guess a fault.
472+
473+
273 is a 24h safety shutdown under display 3F but normal flame
474+
monitoring under 0U; 280 is a restart-time fault under 7L but a normal
475+
fan start under 0U. With no display code the cause alone cannot tell
476+
them apart, so it must read unknown rather than alarm on a boiler that
477+
is only starting up.
478+
"""
479+
descs = {d.key: d for d in _pointtapi_sensor_descriptions()}
480+
desc = descs["/system/appliance/status"]
481+
assert desc.value_fn is not None
482+
for cause in (273, 280):
483+
data = {"/system/appliance/causeCode": {"value": float(cause)}}
484+
assert desc.value_fn(data) == "unknown"
485+
# Unambiguous causes still resolve without a display code.
486+
assert (
487+
desc.value_fn({"/system/appliance/causeCode": {"value": 203.0}})
488+
== "standby_no_heat_demand"
489+
)
490+
456491
def test_appliance_status_sensor_maps_0a_305_to_dhw_lockout(self):
457492
descs = {d.key: d for d in _pointtapi_sensor_descriptions()}
458493
desc = descs["/system/appliance/status"]

0 commit comments

Comments
 (0)