diff --git a/tests/test_device.py b/tests/test_device.py index ef52b3e85..7b3af30af 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -21,6 +21,7 @@ SIG_EP_OUTPUT, SIG_EP_TYPE, create_mock_zigpy_device, + get_entity, join_zigpy_device, zigpy_device_from_json, ) @@ -34,6 +35,9 @@ UNKNOWN, ) from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.platforms.binary_sensor import IASZone +from zha.application.platforms.light import Light from zha.application.platforms.sensor import LQISensor, RSSISensor from zha.application.platforms.switch import Switch from zha.exceptions import ZHAException @@ -820,3 +824,63 @@ async def test_quirks_v2_device_renaming(zha_gateway: Gateway) -> None: zha_device = await join_zigpy_device(zha_gateway, zigpy_dev) assert zha_device.model == "IRIS Keypad V2" assert zha_device.manufacturer == "Lowe's" + + +@pytest.mark.parametrize( + ("json_path", "primary_platform", "primary_entity_type"), + [ + # Light bulb + ( + "tests/data/devices/ikea-of-sweden-tradfri-bulb-gu10-ws-400lm.json", + Platform.LIGHT, + Light, + ), + # Night light with a bulb and a motion sensor + ( + "tests/data/devices/third-reality-inc-3rsnl02043z.json", + Platform.LIGHT, + Light, + ), + # Door sensor + ( + "tests/data/devices/centralite-3320-l.json", + Platform.BINARY_SENSOR, + IASZone, + ), + # Smart plug with energy monitoring + ( + "tests/data/devices/innr-sp-234.json", + Platform.SWITCH, + Switch, + ), + # Atmosphere sensor with humidity, temperature, and pressure + ( + "tests/data/devices/lumi-lumi-weather.json", + None, + None, + ), + ], +) +async def test_primary_entity_computation( + json_path: str, + primary_platform: Platform | None, + primary_entity_type: PlatformEntity | None, + zha_gateway: Gateway, +) -> None: + """Test primary entity computation.""" + + zigpy_dev = await zigpy_device_from_json( + zha_gateway.application_controller, + json_path, + ) + zha_device = await join_zigpy_device(zha_gateway, zigpy_dev) + + # There is a single light entity + primary = [e for e in zha_device.platform_entities.values() if e.primary] + + if primary_platform is None: + assert not primary + else: + assert primary == [ + get_entity(zha_device, primary_platform, entity_type=primary_entity_type) + ] diff --git a/zha/application/gateway.py b/zha/application/gateway.py index 60ab3ca05..4ef707485 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -329,6 +329,8 @@ def load_groups(self) -> None: def create_platform_entities(self) -> None: """Create platform entities.""" + entity_devices = set() + for platform in discovery.PLATFORMS: for platform_entity_class, args, kw_args in self.config.platforms[platform]: try: @@ -347,8 +349,13 @@ def create_platform_entities(self) -> None: _LOGGER.debug( "Platform entity data: %s", platform_entity.info_object ) + entity_devices.add(platform_entity.device) self.config.platforms[platform].clear() + # Compute primary entities after they've all been created + for device in entity_devices: + device._compute_primary_entity() + @property def radio_concurrency(self) -> int: """Maximum configured radio concurrency.""" diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index b0aedf75b..dbb99fb43 100644 --- a/zha/application/platforms/__init__.py +++ b/zha/application/platforms/__init__.py @@ -58,6 +58,7 @@ class BaseEntityInfo: entity_category: str | None entity_registry_enabled_default: bool enabled: bool = True + primary: bool # For platform entities cluster_handlers: list[ClusterHandlerInfo] @@ -117,6 +118,11 @@ class BaseEntity(LogMixin, EventBase): _attr_device_class: str | None _attr_state_class: str | None _attr_enabled: bool = True + _attr_primary: bool = False + + # When two entities both want to be primary, the one with the higher weight will be + # chosen. If there is a tie, both lose. + _attr_primary_weight: int = 0 def __init__(self, unique_id: str) -> None: """Initialize the platform entity.""" @@ -138,6 +144,21 @@ def enabled(self, value: bool) -> None: """Set the entity enabled state.""" self._attr_enabled = value + @property + def primary(self) -> bool: + """Return if the entity is the primary device control.""" + return self._attr_primary + + @primary.setter + def primary(self, value: bool) -> None: + """Set the entity as the primary device control.""" + self._attr_primary = value + + @property + def primary_weight(self) -> int: + """Return the primary weight of the entity.""" + return self._attr_primary_weight + @property def fallback_name(self) -> str | None: """Return the entity fallback name for when a translation key is unavailable.""" @@ -212,6 +233,7 @@ def info_object(self) -> BaseEntityInfo: entity_category=self.entity_category, entity_registry_enabled_default=self.entity_registry_enabled_default, enabled=self.enabled, + primary=self.primary, # Set by platform entities cluster_handlers=[], device_ieee=None, diff --git a/zha/application/platforms/binary_sensor/__init__.py b/zha/application/platforms/binary_sensor/__init__.py index c35b2b624..1702df542 100644 --- a/zha/application/platforms/binary_sensor/__init__.py +++ b/zha/application/platforms/binary_sensor/__init__.py @@ -155,6 +155,7 @@ class Occupancy(BinarySensor): _attribute_name = "occupancy" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY + _attr_primary_weight = 2 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY) @@ -162,6 +163,7 @@ class HueOccupancy(Occupancy): """ZHA Hue occupancy.""" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY + _attr_primary_weight = 3 @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) @@ -170,6 +172,7 @@ class Opening(BinarySensor): _attribute_name = "on_off" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_INPUT) @@ -203,6 +206,7 @@ class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" _attribute_name = "zone_status" + _attr_primary_weight = 3 def __init__( self, @@ -247,6 +251,7 @@ class SinopeLeakStatus(BinarySensor): _attribute_name = "leak_status" _attr_device_class = BinarySensorDeviceClass.MOISTURE + _attr_primary_weight = 1 @MULTI_MATCH( diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py index c0ba9851b..63b358e55 100644 --- a/zha/application/platforms/climate/__init__.py +++ b/zha/application/platforms/climate/__init__.py @@ -94,6 +94,7 @@ class Thermostat(PlatformEntity): ATTR_UNOCCP_COOL_SETPT, ATTR_UNOCCP_HEAT_SETPT, } + _attr_primary_weight = 10 def __init__( self, diff --git a/zha/application/platforms/cover/__init__.py b/zha/application/platforms/cover/__init__.py index 14dfe71b3..188246511 100644 --- a/zha/application/platforms/cover/__init__.py +++ b/zha/application/platforms/cover/__init__.py @@ -61,6 +61,7 @@ class Cover(PlatformEntity): "target_lift_position", "target_tilt_position", } + _attr_primary_weight = 10 def __init__( self, @@ -373,6 +374,7 @@ class Shade(PlatformEntity): _attr_device_class = CoverDeviceClass.SHADE _attr_translation_key: str = "shade" + _attr_primary_weight = 10 def __init__( self, diff --git a/zha/application/platforms/fan/__init__.py b/zha/application/platforms/fan/__init__.py index b3270a1a9..f4bb38e8f 100644 --- a/zha/application/platforms/fan/__init__.py +++ b/zha/application/platforms/fan/__init__.py @@ -80,6 +80,7 @@ class BaseFan(BaseEntity): | FanEntityFeature.TURN_ON ) _attr_translation_key: str = "fan" + _attr_primary_weight = 10 @functools.cached_property def preset_modes(self) -> list[str]: diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index 9dbdfc3eb..990453a06 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -107,6 +107,7 @@ class BaseLight(BaseEntity, ABC): "off_with_transition", "off_brightness", } + _attr_primary_weight = 10 def __init__(self, *args, **kwargs): """Initialize the light.""" diff --git a/zha/application/platforms/lock/__init__.py b/zha/application/platforms/lock/__init__.py index 7bcff82cb..94866c31e 100644 --- a/zha/application/platforms/lock/__init__.py +++ b/zha/application/platforms/lock/__init__.py @@ -36,6 +36,7 @@ class DoorLock(PlatformEntity): PLATFORM = Platform.LOCK _attr_translation_key: str = "door_lock" + _attr_primary_weight = 10 def __init__( self, diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index f5341d98c..b64651bfa 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -739,6 +739,7 @@ class Humidity(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) @@ -751,6 +752,7 @@ class SoilMoisture(Sensor): _attr_translation_key: str = "soil_moisture" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) @@ -763,6 +765,7 @@ class LeafWetness(Sensor): _attr_translation_key: str = "leaf_wetness" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) @@ -773,6 +776,7 @@ class Illuminance(Sensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = LIGHT_LUX + _attr_primary_weight = 1 def formatter(self, value: int) -> int | None: """Convert illumination data.""" @@ -810,6 +814,7 @@ class SmartEnergyMetering(PollableSensor): "status", "zcl_unit_of_measurement", } + _attr_primary_weight = 1 _ENTITY_DESCRIPTION_MAP = { 0x00: SmartEnergyMeteringEntityDescription( @@ -1131,6 +1136,7 @@ class Pressure(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 _attr_native_unit_of_measurement = UnitOfPressure.HPA + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_FLOW) @@ -1142,6 +1148,7 @@ class Flow(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 10 _attr_native_unit_of_measurement = UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR + _attr_primary_weight = 1 def formatter(self, value: int) -> datetime | int | float | str | None: """Handle unknown value state.""" @@ -1159,6 +1166,7 @@ class Temperature(Sensor): _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) @@ -1172,6 +1180,7 @@ class DeviceTemperature(Sensor): _divisor = 100 _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -1214,6 +1223,7 @@ class CarbonDioxideConcentration(Sensor): _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") @@ -1226,6 +1236,7 @@ class CarbonMonoxideConcentration(Sensor): _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_primary_weight = 1 @MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") @@ -1239,6 +1250,7 @@ class VOCLevel(Sensor): _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + _attr_primary_weight = 1 @MULTI_MATCH( @@ -1257,6 +1269,7 @@ class PPBVOCLevel(Sensor): _decimals = 0 _multiplier = 1 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names="pm25") @@ -1269,6 +1282,7 @@ class PM25(Sensor): _decimals = 0 _multiplier = 1 _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + _attr_primary_weight = 1 @MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") @@ -1281,6 +1295,7 @@ class FormaldehydeConcentration(Sensor): _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION + _attr_primary_weight = 1 @MULTI_MATCH( diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index b5ab76b17..126763fc5 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -68,6 +68,7 @@ class Siren(PlatformEntity): PLATFORM = Platform.SIREN _attr_fallback_name: str = "Siren" + _attr_primary_weight = 10 def __init__( self, diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index b5f536109..e810d36a1 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -65,6 +65,7 @@ class BaseSwitch(BaseEntity, ABC): """Common base class for zhawss switches.""" PLATFORM = Platform.SWITCH + _attr_primary_weight = 10 def __init__( self, @@ -106,6 +107,7 @@ class Switch(PlatformEntity, BaseSwitch): """ZHA switch.""" _attr_translation_key = "switch" + _attr_primary_weight = 10 def __init__( self, diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index 0cebe1856..f388a244f 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -540,6 +540,7 @@ def new( """Create new device.""" zha_dev = cls(zigpy_dev, gateway) discovery.DEVICE_PROBE.discover_device_entities(zha_dev) + return zha_dev def async_update_sw_build_id(self, sw_version: int) -> None: @@ -1087,3 +1088,33 @@ def log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: msg = f"[%s](%s): {msg}" args = (self.nwk, self.model) + args _LOGGER.log(level, msg, *args, **kwargs) + + def _compute_primary_entity(self) -> None: + """Compute the primary entity for this device.""" + candidates = [e for e in self._platform_entities.values() if e.enabled] + candidates.sort(reverse=True, key=lambda e: e.primary_weight) + + if not candidates: + return + + winner = candidates[0] + others = candidates[1:] + + # We have a clear winner + if not others or winner.primary_weight > others[0].primary_weight: + winner.primary = True + del winner.info_object + + for entity in others: + entity.primary = False + del entity.info_object + + return + + self.debug( + "Primary entity tie between %s and %s, no primary entity", winner, others[0] + ) + + for entity in candidates: + entity.primary = False + del entity.info_object