ADR-0025: Custom Data Point Validity Gating via Validity-Relevant Fields¶
Status¶
Accepted (2026-07-10)
Context¶
Three coexisting mechanisms¶
CustomDataPoint.is_valid (and, transitively, is_refreshed / state_uncertain) is derived from _relevant_data_points: the set of the custom data point's own readable data points that must have refreshed at least once before the custom data point is considered valid. Historically this set was produced by three different, coexisting mechanisms:
- Default "all readable" — the base
_relevant_data_pointsproperty returned every readable field data point (self._readable_data_points). - Climate blocklist —
BaseCustomDpClimateand its subclasses defined_validity_irrelevant_data_points, a set of data points to subtract from the default "all readable" set. - Per-class allowlist overrides — individual classes (
CustomDpBlind,CustomDpIpRGBWLight,CustomDpIpDrgDaliLight) overrode_relevant_data_pointsdirectly with bespoke, value-dependent logic (e.g. excludingLEVEL_2only when the device never reports it, or excluding color fields based on the current operation mode).
Failure class¶
Under the default "all readable" mechanism, every readable field data point gates validity — including secondary values such as activity/direction readbacks, group-channel readbacks, colors, and extra sensors. These secondary data points are frequently NO_CREATE and are therefore excluded from the periodic refresh (refresh_data_point_data); nothing re-polls them after a reconnect. If the CCU does not spontaneously emit an event for a secondary parameter, its data point never refreshes, and the whole custom data point — including its primary, state-carrying value — stays is_valid=False for hours after a CCU restart.
This was observed and worked around piecemeal:
- Climate (#3255, #3279): mode-independent fields (
ACTIVE_PROFILE,PARTY_MODE,CONCENTRATION,HUMIDITY, MASTER fields) had to be added to_validity_irrelevant_data_pointsone at a time as they were discovered. - Cover/switch/other custom data points: the same class of failure exists for every custom data point that still relies on the default "all readable" mechanism — e.g. a cover's
DIRECTIONorGROUP_LEVELreadback blockingis_valideven thoughLEVEL— the only value a consumer needs — has already been refreshed.
The blocklist approach does not scale: it requires enumerating every secondary field retroactively, per class, after each failure report, and the allowlist overrides duplicated similar logic ad hoc per class with no shared contract.
Decision¶
Replace all three mechanisms with a single declarative allowlist:
class CustomDataPoint(...):
# Fields whose readable data points gate validity. Assigned by subclasses.
_validity_relevant_fields: ClassVar[frozenset[Field]]
@property
def _relevant_data_points(self) -> tuple[GenericDataPointProtocolAny, ...]:
"""Return the readable data points whose fields gate validity."""
return tuple(
dp
for field, dp in self._data_points.items()
if field in self._validity_relevant_fields and dp.is_readable
)
CustomDataPointdeclares_validity_relevant_fieldsas an annotation only (no default value) — every concrete subclass must assign it explicitly, either directly or by inheriting an identical set from its parent.- The set is filtered by
is_readable: fields not present on a particular device (or resolved toDpDummy, which is never readable) drop out automatically. No special casing is needed for devices that lack an optional field. - An empty set is a deliberate, valid declaration:
all(())isTrue, so a custom data point with no validity-relevant fields is always considered valid. This is used for write-only devices (CustomDpTextDisplay), whose only readable field (BURST_LIMIT_WARNING) is a warning channel, not a state carrier. - No subclass may override
_relevant_data_pointsanymore — the frozenset is the only mechanism. This is enforced by a contract test. - A new contract test,
tests/contract/test_cdp_validity_contract.py, pins the effective_validity_relevant_fieldsset for all 27 concrete custom data point classes. Any change to a set is a deliberate, changelog-documented decision, not an incidental side effect of refactoring.
Field set examples¶
Fields are chosen to be the minimal set of state carriers, not every readable field:
- Cover/blind/dimmer classes:
{LEVEL}—DIRECTION,GROUP_LEVEL,LEVEL_2(optional slats level, per D4 in the implementation plan) no longer gate validity. - Switch/valve/access permission:
{STATE}. - Garage:
{DOOR_STATE}. - Locks:
{LOCK_STATE}/{STATE}/{BUTTON_LOCK}depending on the class. - Climate:
{TEMPERATURE, SETPOINT}plus the field that feedsmode(CONTROL_MODEfor RF,SET_POINT_MODEfor IP).ACTIVE_PROFILE,PARTY_MODE,CONCENTRATION,HUMIDITYand all MASTER fields no longer gate validity. - Sirens: the alarm-active state fields (e.g.
ACOUSTIC_ALARM_ACTIVE/OPTICAL_ALARM_ACTIVE, orSMOKE_DETECTOR_ALARM_STATUS). - Sound player:
{DIRECTION}, because itsis_on("is playing") is derived fromACTIVITY_STATE, making that the state carrier for this class. - RGBW/DALI lights:
{LEVEL}only — color values (HUE,SATURATION,COLOR_TEMPERATURE) are exposed as attributes and no longer gate validity. This is a deliberate relaxation versus the previous per-operation-mode allowlist.
The full contract table lives in tests/contract/test_cdp_validity_contract.py::EXPECTED_VALIDITY_RELEVANT_FIELDS.
Consequences¶
Positive¶
- Entities become valid as soon as their state-carrying fields refresh, regardless of whether secondary/readback fields have ever received a value. This closes the
value_state=restoredfailure class reported in #3255 and #3279. - A single, uniform mechanism replaces three overlapping ones — no more blocklist maintenance, no more bespoke per-class override logic.
- The contract test makes validity gating an explicit, reviewable contract: adding a new custom data point class fails the test until its author consciously declares
_validity_relevant_fields, and widening an existing set requires a deliberate, changelog-documented change.
Negative / trade-offs¶
- Secondary attributes (activity/direction readbacks, colors, group-channel readbacks, extra sensors) may report
Noneor a stale value while the owning custom data point is already valid — they are no longer coupled tois_valid. Consumers that need freshness guarantees for a specific secondary attribute must check that data point's ownis_refreshedstate directly. - RGBW/DALI lights slightly relax existing behavior: color fields no longer gate validity, so a light can report
is_valid=Truewith an unrefreshed color. This is accepted as consistent with the general principle (see the implementation plan,docs/plans/cdp_validity_relevant_fields_2026_07.md, decision D6). - Blinds with a permanently-absent
LEVEL_2(e.g. operated as plain shutters) are valid based onLEVELalone;tilt_positionmay stayNoneindefinitely. This is intentional (decision D4) — gating onLEVEL_2would recreate the same failure class for those devices. - No migration guide is required:
_validity_irrelevant_data_pointsand the removed per-class_relevant_data_pointsoverrides were protected (non-public) members, so this is not a public API change.
References¶
aiohomematic/model/custom/data_point.py—_relevant_data_pointsbase propertytests/contract/test_cdp_validity_contract.py— contract test pinning all 27 classesdocs/plans/cdp_validity_relevant_fields_2026_07.md— implementation plan with the full per-class rationale (design decisions D1–D8)- Issues #3255, #3279