From 400a2cad4b13469b4da1b86548a7b46d3f8d2ff3 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Sat, 15 Aug 2026 09:25:40 +0200 Subject: [PATCH] Add a carbon dioxide sensor to homematicip_cloud (#179187) --- .../components/homematicip_cloud/sensor.py | 41 ++ .../components/homematicip_cloud/conftest.py | 363 ++++++++++++++++++ .../homematicip_cloud/test_sensor.py | 56 +++ 3 files changed, 460 insertions(+) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 0d2124a7fed6..f938ebb480d0 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -146,6 +146,12 @@ ATTR_TEMPERATURE_OFFSET = "temperature_offset" ATTR_WIND_DIRECTION = "wind_direction" ATTR_WIND_DIRECTION_VARIATION = "wind_direction_variation_in_degree" ATTR_ESI_TYPE = "type" + +CARBON_DIOXIDE_CHANNELS = ( + FunctionalChannelType.CARBON_DIOXIDE_SENSOR_CHANNEL, + FunctionalChannelType.WALL_MOUNTED_THERMOSTAT_WITH_CARBON_CHANNEL, +) + ESI_TYPE_UNKNOWN = "UNKNOWN" ESI_CONNECTED_SENSOR_TYPE_IEC = "ES_IEC" ESI_CONNECTED_SENSOR_TYPE_GAS = "ES_GAS" @@ -368,6 +374,14 @@ async def async_setup_entry( if smoke_detector_channel_data_exists(device, description.channel_field) ) + entities.extend( + HomematicipCarbonDioxideSensor(hap, device, channel.index) + for device in hap.home.devices + for channel_type in CARBON_DIOXIDE_CHANNELS + for channel in get_channels_from_device(device, channel_type) + if getattr(channel, "carbonDioxideConcentration", None) is not None + ) + async_add_entities(entities) @@ -620,6 +634,33 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity): return round(self._device.valvePosition * 100) +class HomematicipCarbonDioxideSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP carbon dioxide sensor.""" + + _attr_device_class = SensorDeviceClass.CO2 + _attr_native_unit_of_measurement = UnitOfRatio.PARTS_PER_MILLION + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: + """Initialize the carbon dioxide sensor.""" + super().__init__( + hap, + device, + channel=channel, + # the channel is not at the same index on every device + channel_real_index=channel, + is_multi_channel=True, + feature_id="carbon_dioxide", + use_description_name=True, + ) + + @property + @override + def native_value(self) -> float | None: + """Return the state.""" + return self.get_channel_or_raise().carbonDioxideConcentration + + class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity): """Representation of the HomematicIP humidity sensor.""" diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index b748577d94f0..af9337cec926 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -417,3 +417,366 @@ def mock_connection_init_fixture(): def simple_mock_auth_fixture() -> Auth: """Return a simple AsyncAuth Mock.""" return AsyncMock(spec=Auth, pin=HAPPIN, create=True) + + +@pytest.fixture(name="wall_mounted_thermostat_with_carbon_device_data") +def wall_mounted_thermostat_with_carbon_device_data_fixture() -> dict[str, Any]: + """Return fixture data for an HmIP-WGTC device.""" + return { + "availableFirmwareVersion": "1.0.18", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.18", + "firmwareVersionInteger": 65554, + "functionalChannels": { + "0": { + "altitude": None, + "bootedRecently": False, + "busConfigMismatch": None, + "coProFaulty": False, + "coProRestartNeeded": False, + "coProUpdateFailure": False, + "configPending": False, + "controlsMountingOrientation": None, + "daliBusState": None, + "dataDecodingFailedError": None, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": None, + "deviceCanBusError": None, + "deviceCommunicationError": None, + "deviceDriveError": None, + "deviceDriveModeError": None, + "deviceId": "3014F711000000000000WGTC", + "deviceOperationMode": None, + "deviceOverheated": False, + "deviceOverloaded": False, + "devicePowerFailureDetected": False, + "deviceUndervoltage": False, + "displayContrast": None, + "displayMode": None, + "displayMountingOrientation": None, + "dutyCycle": False, + "fanControlMode": None, + "frostProtectionError": None, + "frostProtectionErrorAcknowledged": None, + "functionalChannelType": "DEVICE_OPERATIONLOCK", + "groupIndex": 0, + "groups": [], + "index": 0, + "input1CoproEnabled": None, + "input2CoproEnabled": None, + "input3CoproEnabled": None, + "input4CoproEnabled": None, + "inputLayoutMode": None, + "invertedDisplayColors": None, + "label": "", + "lastBootTimestamp": 1784278839458, + "lockJammed": None, + "lowBat": None, + "mountingModuleError": None, + "mountingOrientation": None, + "multicastRoutingEnabled": False, + "noDataFromLinkyError": None, + "notRechargeableBattery": None, + "operationDays": None, + "operationLockActive": False, + "particulateMatterSensorCommunicationError": None, + "particulateMatterSensorError": None, + "powerShortCircuit": None, + "profilePeriodLimitReached": None, + "routerModuleEnabled": False, + "routerModuleSupported": False, + "rssiDeviceValue": -75, + "rssiPeerValue": None, + "sensorCommunicationError": None, + "sensorError": None, + "shortCircuitDataLine": None, + "supportedOptionalFeatures": { + "IFeatureDeviceIdentify": True, + "IFeatureDeviceMountingModuleError": True, + "IFeatureDeviceOverloaded": True, + "IFeatureProfilePeriodLimit": True, + "IFeatureRssiValue": True, + "IOptionalFeatureDeviceSwitchChannelMode": True, + "IOptionalFeatureDutyCycle": True, + }, + "switchChannelMode": "FLOOR_HEATING", + "temperatureHumiditySensorCommunicationError": None, + "temperatureHumiditySensorError": None, + "temperatureOutOfRange": False, + "ticVersionError": None, + "unreach": False, + "valveFlowError": None, + "valveWaterError": None, + }, + "1": { + "channelRole": "KEY_OR_SWITCH_FOR_GROUP", + "deviceId": "3014F711000000000000WGTC", + "functionalChannelType": "INPUT_QUICK_ACTION_DISPLAY_CHANNEL", + "groupIndex": 1, + "groups": [], + "index": 1, + "label": "", + "supportedOptionalFeatures": { + "IFeatureLightGroupSensorChannel": True, + "IFeatureShadingGroupSensorChannel": True, + }, + }, + "2": { + "channelRole": "DIMMING_ACTUATOR", + "deviceId": "3014F711000000000000WGTC", + "dimLevel": 0.02, + "functionalChannelType": "BACKLIGHT_CHANNEL", + "groupIndex": 2, + "groups": [], + "index": 2, + "label": "", + "on": True, + "powerUpDimLevel": 0.02, + "powerUpSwitchState": "PERMANENT_ON", + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": { + "IFeatureLightProfileActuatorChannel": True + }, + "switchVisualization": "LIGHT", + "userDesiredProfileMode": "AUTOMATIC", + }, + "3": { + "actualTemperature": 22.6, + "carbonDioxideConcentration": 440.0, + "channelRole": "WALL_MOUNTED_THERMOSTAT", + "deviceId": "3014F711000000000000WGTC", + "display": "ACTUAL", + "functionalChannelType": "WALL_MOUNTED_THERMOSTAT_WITH_CARBON_CHANNEL", + "groupIndex": 3, + "groups": [], + "humidity": 48, + "index": 3, + "label": "Wandthermostat (3 + 4) Child", + "setPointTemperature": 18.0, + "supportedOptionalFeatures": { + "IOptionalFeatureClimateControlDisplayCarbonSupported": True, + "IOptionalFeatureClimateControlDisplayHumidityOnlySupported": True, + "IOptionalFeatureThermostatCoolingSupported": True, + }, + "temperatureOffset": -0.5, + "vaporAmount": 9.619851889906071, + }, + "4": { + "climateControlType": "PWM_CONTROL", + "deviceId": "3014F711000000000000WGTC", + "frostProtectionTemperature": 8.0, + "functionalChannelType": "INTERNAL_SWITCH_CHANNEL", + "groupIndex": 3, + "groups": [], + "heatingValveType": "NORMALLY_CLOSE", + "index": 4, + "internalSwitchOutputEnabled": False, + "label": "Wandthermostat (3 + 4) Child", + "supportedOptionalFeatures": { + "IOptionalFeatureClimateControlType": True, + "IOptionalFeatureFloorHeatingSpecificGroupSupported": True, + }, + "valvePosition": 0.0, + "valveProtectionDuration": 5, + "valveProtectionSwitchingInterval": 14, + }, + "5": { + "channelRole": None, + "deviceId": "3014F711000000000000WGTC", + "functionalChannelType": "SWITCH_CHANNEL", + "groupIndex": 0, + "groups": [], + "index": 5, + "internalLinkConfiguration": None, + "label": "", + "on": False, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": { + "IOptionalFeaturePowerUpSwitchState": True + }, + "switchVisualization": None, + "userDesiredProfileMode": "AUTOMATIC", + }, + "6": { + "deviceId": "3014F711000000000000WGTC", + "functionalChannelType": "HEAT_DEMAND_CHANNEL", + "groupIndex": 0, + "groups": [], + "index": 6, + "label": "", + }, + "7": { + "deviceId": "3014F711000000000000WGTC", + "functionalChannelType": "DEHUMIDIFIER_DEMAND_CHANNEL", + "groupIndex": 0, + "groups": [], + "index": 7, + "label": "", + }, + "8": { + "deviceId": "3014F711000000000000WGTC", + "functionalChannelType": "CHANGE_OVER_CHANNEL", + "groupIndex": 0, + "groups": [], + "index": 8, + "label": "", + }, + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F711000000000000WGTC", + "label": "Wandthermostat mit CO2", + "lastStatusUpdate": 1784553668564, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": False, + "manufacturerCode": 1, + "measuredAttributes": { + "3": { + "actualTemperature": True, + "carbonDioxideConcentration": True, + "humidity": True, + "setPointTemperature": True, + } + }, + "modelId": 568, + "modelType": "HmIP-WGTC", + "oem": "eQ-3", + "permanentlyReachable": True, + "serializedGlobalTradeItemNumber": "3014F711000000000000WGTC", + "type": "WALL_MOUNTED_GLASS_THERMOSTAT_CARBON", + "updateState": "UP_TO_DATE", + } + + +@pytest.fixture(name="carbon_dioxide_sensor_device_data") +def carbon_dioxide_sensor_device_data_fixture() -> dict[str, Any]: + """Return fixture data for an HmIP-SCTH230 device.""" + return { + "availableFirmwareVersion": "1.0.6", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": None, + "coProFaulty": False, + "coProRestartNeeded": False, + "coProUpdateFailure": False, + "configPending": False, + "controlsMountingOrientation": None, + "deviceCommunicationError": None, + "deviceDriveError": None, + "deviceDriveModeError": None, + "deviceId": "3014F711000000000SCTH230", + "deviceOperationMode": None, + "deviceOverheated": False, + "deviceOverloaded": False, + "devicePowerFailureDetected": False, + "deviceUndervoltage": False, + "displayContrast": None, + "dutyCycle": False, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000052"], + "index": 0, + "label": "", + "lockJammed": None, + "lowBat": None, + "mountingOrientation": None, + "multicastRoutingEnabled": False, + "particulateMatterSensorCommunicationError": None, + "particulateMatterSensorError": None, + "powerShortCircuit": None, + "profilePeriodLimitReached": None, + "routerModuleEnabled": False, + "routerModuleSupported": False, + "rssiDeviceValue": -66, + "rssiPeerValue": None, + "shortCircuitDataLine": None, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": False, + "IFeatureDeviceCoProError": False, + "IFeatureDeviceCoProRestart": False, + "IFeatureDeviceCoProUpdate": False, + "IFeatureDeviceCommunicationError": False, + "IFeatureDeviceDriveError": False, + "IFeatureDeviceDriveModeError": False, + "IFeatureDeviceIdentify": False, + "IFeatureDeviceOverheated": False, + "IFeatureDeviceOverloaded": False, + "IFeatureDeviceParticulateMatterSensorCommunicationError": False, + "IFeatureDeviceParticulateMatterSensorError": False, + "IFeatureDevicePowerFailure": False, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": False, + "IFeatureDeviceTemperatureHumiditySensorError": False, + "IFeatureDeviceTemperatureOutOfRange": False, + "IFeatureDeviceUndervoltage": False, + "IFeatureMulticastRouter": False, + "IFeaturePowerShortCircuit": False, + "IFeatureProfilePeriodLimit": True, + "IFeatureRssiValue": True, + "IFeatureShortCircuitDataLine": False, + "IOptionalFeatureDeviceErrorLockJammed": False, + "IOptionalFeatureDeviceOperationMode": False, + "IOptionalFeatureDisplayContrast": False, + "IOptionalFeatureDutyCycle": True, + "IOptionalFeatureLowBat": False, + "IOptionalFeatureMountingOrientation": False, + }, + "temperatureHumiditySensorCommunicationError": None, + "temperatureHumiditySensorError": None, + "temperatureOutOfRange": False, + "unreach": False, + }, + "1": { + "actualTemperature": 25.5, + "carbonDioxideConcentration": 1181.0, + "carbonDioxideVisualisationEnabled": True, + "channelRole": "CARBON_DIOXIDE_SENSOR", + "deviceId": "3014F711000000000SCTH230", + "functionalChannelType": "CARBON_DIOXIDE_SENSOR_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000053"], + "humidity": 37, + "index": 1, + "label": "", + "vaporAmount": 8.739326558877478, + }, + "2": { + "channelRole": "SWITCH_ACTUATOR", + "deviceId": "3014F711000000000SCTH230", + "functionalChannelType": "SWITCH_CHANNEL", + "groupIndex": 2, + "groups": ["00000000-0000-0000-0000-000000000054"], + "index": 2, + "internalLinkConfiguration": None, + "label": "", + "on": False, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "supportedOptionalFeatures": { + "IFeatureAccessAuthorizationActuatorChannel": False, + "IFeatureLightGroupActuatorChannel": True, + "IFeatureLightProfileActuatorChannel": True, + "IOptionalFeatureInternalLinkConfiguration": False, + "IOptionalFeaturePowerUpSwitchState": True, + }, + "userDesiredProfileMode": "AUTOMATIC", + }, + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F711000000000SCTH230", + "label": "CO2 Sensor miko ", + "lastStatusUpdate": 1676606756797, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 435, + "modelType": "HmIP-SCTH230", + "oem": "eQ-3", + "permanentlyReachable": True, + "serializedGlobalTradeItemNumber": "3014F711000000000SCTH230", + "type": "CARBON_DIOXIDE_SENSOR", + "updateState": "UP_TO_DATE", + } diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 48573b94e0ad..9596467576dd 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -35,6 +35,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfLength, UnitOfPower, + UnitOfRatio, UnitOfSpeed, UnitOfTemperature, UnitOfVolume, @@ -1079,3 +1080,58 @@ async def test_hmip_soil_temperature_sensor( ) ha_state = hass.states.get(entity_id) assert ha_state.state == "18.3" + + +@pytest.mark.parametrize( + ("device_data_fixture", "device_label", "entity_id", "device_model", "channel"), + [ + ( + "wall_mounted_thermostat_with_carbon_device_data", + "Wandthermostat mit CO2", + "sensor.wandthermostat_mit_co2_carbon_dioxide", + "HmIP-WGTC", + 3, + ), + ( + "carbon_dioxide_sensor_device_data", + "CO2 Sensor miko ", + "sensor.co2_sensor_miko_carbon_dioxide", + "HmIP-SCTH230", + 1, + ), + ], +) +async def test_hmip_carbon_dioxide_sensor( + hass: HomeAssistant, + default_mock_hap_factory: HomematicipHAP, + request: pytest.FixtureRequest, + device_data_fixture: str, + device_label: str, + entity_id: str, + device_model: str, + channel: int, +) -> None: + """Test the carbon dioxide sensor on both channel types.""" + device_data = request.getfixturevalue(device_data_fixture) + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=[device_label], extra_devices=[device_data] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, f"{device_label} Carbon dioxide", device_model + ) + + assert ( + ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfRatio.PARTS_PER_MILLION + ) + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + await async_manipulate_test_data( + hass, + hmip_device, + "carbonDioxideConcentration", + 812.0, + channel=channel, + channel_real_index=channel, + ) + assert hass.states.get(entity_id).state == "812.0"