Skip to content

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 mappings
  • DeviceConfig: Type-safe configuration for device registration
  • ExtendedDeviceConfig: Extended configuration with additional fields
  • aiohomematic.model.custom.definition
  • make_custom_data_point(channel, data_point_class, device_profile, custom_config): Factory function
  • is_multi_channel_device(model, category): Check for multi-channel devices
  • get_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, and CDPD keys 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.py as type-safe dataclasses.
  • Device-to-profile mappings are registered via DeviceProfileRegistry in 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:

  1. Choose or create a CustomDataPoint subclass (optional)

  2. Most cases can use an existing CustomDataPoint subclass (e.g., CustomDpIpThermostat, CustomDpSwitch).

  3. If you need special behavior, subclass CustomDataPoint and override:

    • Use DataPointField descriptors 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 to EXPECTED_VALIDITY_RELEVANT_FIELDS in tests/contract/test_cdp_validity_contract.py.
    • @property getters if you compute an aggregate state
  4. Register the device with DeviceProfileRegistry

  5. 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,),
        },
    ),
)
  1. 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,),
        ),
    ),
)
  1. Validate
  2. Run the project tests to ensure your device is correctly registered.
  3. 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 ExtendedDeviceConfig when you need additional data points beyond the profile defaults.
  • For multi-channel devices, specify all relevant channels in the channels tuple.
  • 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 resolve
  • paramset_key: The paramset key (VALUES, MASTER, etc.)
  • dpt: Expected data point type (e.g., DpSensor, DpFloat)
  • fallback_parameters: Optional list of fallback parameter names
  • use_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 resolution
  • publish_data_point_updated_event is 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, calls Class.is_relevant_for_model(channel) and adds instances for those that apply.
  • Each CalculatedDataPoint uses CalculatedDataPointField descriptors 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:

  1. Implement a subclass of CalculatedDataPoint

  2. Set _calculated_parameter to a value from aiohomematic.const.CalculatedParameter

  3. Use CalculatedDataPointField descriptors to declare source data points:
    _dp_temp = CalculatedDataPointField(
        parameter=Parameter.TEMPERATURE,
        paramset_key=ParamsetKey.VALUES,
        dpt=DpSensor,
    )
    
  4. For fallback parameters (try alternatives if primary not found):
    _dp_temp = CalculatedDataPointField(
        parameter=Parameter.TEMPERATURE,
        paramset_key=ParamsetKey.VALUES,
        dpt=DpSensor,
        fallback_parameters=[Parameter.ACTUAL_TEMPERATURE],
    )
    
  5. For device-level fallback (try device address if not on channel):
    _dp_limit = CalculatedDataPointField(
        parameter=Parameter.LOW_BAT_LIMIT,
        paramset_key=ParamsetKey.MASTER,
        dpt=DpFloat,
        use_device_fallback=True,
    )
    
  6. Provide properties using decorators:
    • @property def value(self) -> T: return computed value
    • @property def unit(self) -> str | None: return unit string
  7. Implement staticmethod is_relevant_for_model(*, channel: ChannelProtocol) -> bool to guard which channels get this DP
  8. Override _post_init() for additional initialization after descriptor resolution

  9. Register your class

  10. Add the class to _CALCULATED_DATA_POINTS in aiohomematic.model.calculated.__init__:

    _CALCULATED_DATA_POINTS = (ApparentTemperature, ..., YourNewCalculatedDP)
    

  11. Ensure correctness

  12. The base class manages subscriptions automatically via descriptors
  13. Use helper functions in aiohomematic.model.calculated.support for 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=True or _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_points dict mapping Field to underlying generic data points
  • Subscribes to underlying data point updates and publishes combined events
  • Uses ParamsetKey.COMBINED to 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 max as raw_max * 3600 when 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 + SATURATION as 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_STATE and writes the matching DOOR_COMMAND, exposed as a SELECT-category entity
  • Uses its own parameter CombinedParameter.DOOR_MODE instead of borrowing the identity of a source parameter, so it gets its own unique_id, name and translation key
  • Holds the commanded mode in _current_value while DOOR_STATE reports POSITION_UNKNOWN (travelling)
  • note_command() lets CustomDpGarage report commands it issues directly on DOOR_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 with COMBINED_FIELD_MARKER (_is_combined_field) and calls their create_combined_dp().
  • Combined data points are stored in CustomDataPoint._combined_data_points (keyed by Field).
  • When visible=True, the combined data point is added to the channel via Channel.add_data_point() and stored in Channel._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.