Extension points: New device profiles, calculated and combined data points¶
This guide explains how to extend AioHomematic with:
- Custom device profiles (model/custom)
- Calculated (derived) data points (model/calculated)
- Combined (multi-parameter writable) data points (model/combined)
It targets contributors who want to add support for new device variants, expose derived metrics, or create writable data points that combine multiple underlying parameters.
Prerequisites and conventions¶
- Be familiar with the architecture overview in docs/architecture.md (Model, Device/Channel, DataPoint lifecycle).
- Prefer small, well-scoped additions. Follow existing naming conventions and module layout.
- Keep public API stable. New types should be added behind existing factory/registration functions.
Custom device profiles (model/custom)¶
Custom device profiles are used when a specific device (model) requires a bespoke grouping of its generic data points or additional behavior beyond the generic defaults.
Key modules and types:¶
- aiohomematic.model.custom.registry
DeviceProfileRegistry: Central registry for device-to-profile mappingsDeviceConfig: Type-safe configuration for device registrationExtendedDeviceConfig: Extended configuration with additional fields- aiohomematic.model.custom.definition
make_custom_data_point(channel, data_point_class, device_profile, custom_config): Factory functionis_multi_channel_device(model, category): Check for multi-channel devicesget_custom_configs(model, category): Get configurations for a model- aiohomematic.model.custom.data_point.CustomDataPoint: Base implementation that:
- Groups multiple generic data points, sets visibility, service flags, etc.
- Subscribes to underlying GenericDataPoint updates
- Provides property values derived from the grouped set
- aiohomematic.const: Contains
Field,DeviceProfile, andCDPDkeys used in profile definitions
Concepts:¶
- A CustomDataPoint instance sits on a Channel and aggregates underlying GenericDataPoints according to a device profile definition.
- Device profiles are declared in
model/custom/profile.pyas type-safe dataclasses. - Device-to-profile mappings are registered via
DeviceProfileRegistryin each entity module.
When to create a custom device profile:¶
- Generic model would misrepresent or hide essential device semantics.
- You need to group multiple parameters across one or more channels into a single coherent data point.
Steps to add a custom device profile:¶
-
Choose or create a CustomDataPoint subclass (optional)
-
Most cases can use an existing CustomDataPoint subclass (e.g.,
CustomDpIpThermostat,CustomDpSwitch). -
If you need special behavior, subclass CustomDataPoint and override:
- Use
DataPointFielddescriptors for declarative field definitions - Override
_post_init()for additional initialization after field resolution - Declare
_validity_relevant_fields: ClassVar[frozenset[Field]]— the fields that gate validity (is_valid/value_state, see ADR-0025). Every concrete class must resolve it (no base default); do not override_relevant_data_points. Add the new class toEXPECTED_VALIDITY_RELEVANT_FIELDSintests/contract/test_cdp_validity_contract.py. @propertygetters if you compute an aggregate state
- Use
-
Register the device with DeviceProfileRegistry
-
In the appropriate entity module (e.g.,
climate.py,switch.py), add a registration call:
from aiohomematic.model.custom.registry import DeviceProfileRegistry, ExtendedDeviceConfig
DeviceProfileRegistry.register(
category=DataPointCategory.CLIMATE,
models=("HmIP-NEW-DEVICE", "HmIP-NEW-DEVICE-2"), # Device model(s)
data_point_class=CustomDpIpThermostat, # CustomDataPoint subclass
profile_type=DeviceProfile.IP_THERMOSTAT, # Profile type from const.py
channels=(1,), # Primary channel(s)
schedule_channel_no=1, # Optional: schedule channel
extended=ExtendedDeviceConfig( # Optional: extended config
additional_data_points={
0: (Parameter.SOME_PARAM,),
},
),
)
- For multiple configurations per device, use
register_multiple:
DeviceProfileRegistry.register_multiple(
category=DataPointCategory.LOCK,
models="HmIP-DLD",
configs=(
DeviceConfig(
data_point_class=CustomDpIpLock,
profile_type=DeviceProfile.IP_LOCK,
),
DeviceConfig(
data_point_class=CustomDpButtonLock,
profile_type=DeviceProfile.IP_BUTTON_LOCK,
channels=(0,),
),
),
)
- Validate
- Run the project tests to ensure your device is correctly registered.
- Add specific tests for your device if possible.
Minimal example (adding a new switch device):¶
# In aiohomematic/model/custom/switch.py
from aiohomematic.const import DataPointCategory, DeviceProfile
from aiohomematic.model.custom.registry import DeviceProfileRegistry
# Register the new device
DeviceProfileRegistry.register(
category=DataPointCategory.SWITCH,
models="HmIP-MY-NEW-SWITCH",
data_point_class=CustomDpSwitch,
profile_type=DeviceProfile.IP_SWITCH,
channels=(3,), # Channel number where STATE parameter lives
)
Tips:¶
- Look at existing registrations in
climate.py,switch.py,cover.py, etc. for patterns. - Use
ExtendedDeviceConfigwhen you need additional data points beyond the profile defaults. - For multi-channel devices, specify all relevant channels in the
channelstuple. - To blacklist a device model, use
DeviceProfileRegistry.blacklist("MODEL-NAME").
Calculated (derived) data points (model/calculated)¶
Calculated data points compute values from one or more underlying GenericDataPoints and behave like read-only data points on a Channel.
Key modules and types:¶
- aiohomematic.model.calculated.field.CalculatedDataPointField: Descriptor for declarative field definitions
parameter: The parameter name to resolveparamset_key: The paramset key (VALUES, MASTER, etc.)dpt: Expected data point type (e.g., DpSensor, DpFloat)fallback_parameters: Optional list of fallback parameter namesuse_device_fallback: If True, tries device address (channel 0) if not found- aiohomematic.model.calculated.data_point.CalculatedDataPoint: Base class to inherit from
_resolve_data_point(...)/_add_device_data_point(...)for manual data point resolutionpublish_data_point_updated_eventis triggered when any source updates- Decorators: @property (use @hm_property(cached=..., log_context=...) for caching or structured log context)
- **aiohomematic.model.calculated.**init****
create_calculated_data_points(channel): factory that evaluates relevance and attaches instances to channels_CALCULATED_DATA_POINTS: tuple of registered calculated DP classes- Existing implementations for reference:
- climate.py: ApparentTemperature, DewPoint, FrostPoint, VaporConcentration
- operating_voltage_level.py: OperatingVoltageLevel
Lifecycle:¶
- On channel initialization,
create_calculated_data_points(channel)iterates all registered classes, callsClass.is_relevant_for_model(channel)and adds instances for those that apply. - Each CalculatedDataPoint uses
CalculatedDataPointFielddescriptors to declare its source data points. The base class resolves every declared field during construction and subscribes to it, so validity gating and update delivery work before the value is read for the first time. - When any source data point updates, the calculated data point's value is recomputed.
Steps to add a new calculated data point:¶
-
Implement a subclass of CalculatedDataPoint
-
Set
_calculated_parameterto a value fromaiohomematic.const.CalculatedParameter - Use
CalculatedDataPointFielddescriptors to declare source data points: - For fallback parameters (try alternatives if primary not found):
- For device-level fallback (try device address if not on channel):
- Provide properties using decorators:
@property def value(self) -> T:return computed value@property def unit(self) -> str | None:return unit string
- Implement
staticmethod is_relevant_for_model(*, channel: ChannelProtocol) -> boolto guard which channels get this DP -
Override
_post_init()for additional initialization after descriptor resolution -
Register your class
-
Add the class to
_CALCULATED_DATA_POINTSinaiohomematic.model.calculated.__init__: -
Ensure correctness
- The base class manages subscriptions automatically via descriptors
- Use helper functions in
aiohomematic.model.calculated.supportfor common calculations
Minimal template:¶
# aiohomematic/model/calculated/my_metric.py
from __future__ import annotations
from aiohomematic.const import CalculatedParameter, Parameter, ParameterType, ParamsetKey
from aiohomematic.interfaces.model import ChannelProtocol
from aiohomematic.model.calculated.data_point import CalculatedDataPoint
from aiohomematic.model.calculated.field import CalculatedDataPointField
from aiohomematic.model.generic import DpSensor
class MyMetric(CalculatedDataPoint[float | None]):
"""Calculate a custom metric from temperature and humidity."""
__slots__ = ()
_calculated_parameter = CalculatedParameter.MY_METRIC
# Declarative field definitions using descriptors
_dp_temp = CalculatedDataPointField(
parameter=Parameter.TEMPERATURE,
paramset_key=ParamsetKey.VALUES,
dpt=DpSensor,
)
_dp_hum = CalculatedDataPointField(
parameter=Parameter.HUMIDITY,
paramset_key=ParamsetKey.VALUES,
dpt=DpSensor,
)
def __init__(self, *, channel: ChannelProtocol) -> None:
"""Initialize the data point."""
super().__init__(channel=channel)
self._type = ParameterType.FLOAT
self._unit = "unit"
@staticmethod
def is_relevant_for_model(*, channel: ChannelProtocol) -> bool:
"""Return if this calculated data point is relevant for the model."""
return (
channel.get_generic_data_point(
parameter=Parameter.TEMPERATURE, paramset_key=ParamsetKey.VALUES
)
is not None
and channel.get_generic_data_point(
parameter=Parameter.HUMIDITY, paramset_key=ParamsetKey.VALUES
)
is not None
)
@property
def value(self) -> float | None:
"""Return the calculated value."""
if self._dp_temp.value is None or self._dp_hum.value is None:
return None
# Implement your calculation here
return (self._dp_temp.value + self._dp_hum.value) / 2
Notes:¶
- Use
use_device_fallback=Trueor_add_device_data_point(...)if you need to read from other channels of the same device. - The base class exposes helper attributes like
self._unit,_min/_max, etc., which you can set in__init__(). - Override
_post_init()for additional initialization that depends on resolved data points. - Keep calculations side-effect free. The base class handles event subscriptions automatically.
Combined (multi-parameter writable) data points (model/combined)¶
Combined data points write to multiple underlying data points and present a single writable value. They are the writable counterpart to calculated data points.
Key modules and types:¶
- aiohomematic.model.combined.data_point.CombinedDataPoint: Base class for combined data points
- Parameterized by
ParameterT(the value type, e.g.,float | None) - Manages
_data_pointsdict mappingFieldto underlying generic data points - Subscribes to underlying data point updates and publishes combined events
- Uses
ParamsetKey.COMBINEDto distinguish from generic and calculated data points - aiohomematic.model.combined.timer.CombinedDpTimerAction: Concrete implementation for timer value+unit pairs
- Accepts values in seconds, converts to optimal unit (S/M/H) via
recalc_unit_timer - Computes
maxasraw_max * 3600when a unit data point exists (hours to seconds) - Persists the seconds value in
_current_value(ACTION params have no CCU events) - aiohomematic.model.combined.hs_color.CombinedDpHsColor: Concrete implementation for hue+saturation pairs
- Exposes
HUE+SATURATIONas a single(hue, saturation)tuple - Converts saturation between the CCU range (0.0-1.0) and the HA range (0.0-100.0)
- aiohomematic.model.combined.garage_door_mode.CombinedDpGarageDoorMode: Concrete implementation for a garage door's discrete mode
- Reads
DOOR_STATEand writes the matchingDOOR_COMMAND, exposed as aSELECT-category entity - Uses its own parameter
CombinedParameter.DOOR_MODEinstead of borrowing the identity of a source parameter, so it gets its ownunique_id, name and translation key - Holds the commanded mode in
_current_valuewhileDOOR_STATEreportsPOSITION_UNKNOWN(travelling) note_command()letsCustomDpGaragereport commands it issues directly onDOOR_COMMAND, so the held mode never goes stale- aiohomematic.model.combined.field.CombinedTimerField / CombinedHsColorField / CombinedGarageDoorModeField: Descriptors for use in CustomDataPoint subclasses
- Declare the underlying fields with optional visibility
- Create the matching combined data point during
CustomDataPoint._create_combined_data_points() - When
visible=True, the combined data point is registered as a visible HA entity - aiohomematic.interfaces.model.CombinedDataPointProtocol: Protocol interface for combined data points
Lifecycle:¶
- During
CustomDataPoint.__init__(), after_init_data_points()resolves generic data points,_create_combined_data_points()iterates class descriptors marked withCOMBINED_FIELD_MARKER(_is_combined_field) and calls theircreate_combined_dp(). - Combined data points are stored in
CustomDataPoint._combined_data_points(keyed byField). - When
visible=True, the combined data point is added to the channel viaChannel.add_data_point()and stored inChannel._combined_data_points. - The combined data point subscribes to its underlying generic data points and publishes update events when they change.
Usage in CustomDataPoint subclasses:¶
from aiohomematic.model.combined.field import CombinedTimerField
class CustomDpSwitch(StateChangeTimerMixin, GroupStateMixin, CustomDataPoint):
"""Class for Homematic switch data point."""
__slots__ = ()
# Timer with value only (no unit DP) — internal, not visible in HA
_dp_on_time = CombinedTimerField(value_field=Field.ON_TIME_VALUE)
@bind_collector
async def turn_on(self, *, on_time: float | None = None, collector: CallParameterCollector | None = None) -> None:
"""Turn the switch on."""
if (timer := self.get_and_start_timer()) is not None:
await self._dp_on_time.send_value(value=timer, collector=collector)
await self._dp_state.turn_on(collector=collector)
class CustomDpIpSiren(CustomDataPoint):
"""Class for Homematic IP siren data point."""
__slots__ = ()
# Timer with value + unit — visible as HA number entity
_dp_duration: Final = CombinedTimerField(
value_field=Field.DURATION, unit_field=Field.DURATION_UNIT, visible=True
)
class CustomDpGarage(PositionMixin, CustomDataPoint):
"""Class for Homematic garage data point."""
__slots__ = ()
# Read/write pair with different parameters — visible as HA select entity
_dp_door_mode: Final = CombinedGarageDoorModeField(
door_state_field=Field.DOOR_STATE, door_command_field=Field.DOOR_COMMAND, visible=True
)
Key differences from CalculatedDataPoint:¶
| Aspect | CalculatedDataPoint | CombinedDataPoint |
|---|---|---|
| Direction | Read-only (computed) | Writable (sends to CCU) |
| Registration | Via _CALCULATED_DATA_POINTS tuple | Via Combined*Field descriptors on CustomDataPoint |
| ParamsetKey | CALCULATED | COMBINED |
| Visibility | Always visible | Configurable via visible parameter |
| Protocol | CalculatedDataPointProtocol | CombinedDataPointProtocol |
Testing and validation¶
- Run the test suite (see README.md for instructions) and add targeted tests for your new profile or calculated DP.
- For calculated DPs, add unit tests around your formula and a small channel/device stub if possible.
- For custom profiles, test that required GenericDataPoints are attached and visible fields behave as expected.
Documentation and discoverability¶
- After adding a new calculated data point, update aiohomematic.model.calculated.init _CALCULATED_DATA_POINTS.
- If you add a reusable helper or pattern, include a short docstring and cross-link from this page.
Where to look for examples¶
- model/calculated/climate.py and operating_voltage_level.py
- model/custom/definition.py and model/custom/data_point.py
If anything in this guide is unclear, open an issue or PR with questions and we’ll help refine these docs.