From dbba2c4afefcce8321e03afcfbbc795fa3ad33ba Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 29 Sep 2021 12:35:20 -0400 Subject: [PATCH] Add "Summation Delivered" Sensor for SmartEnergy metering ZHA channel (#56666) --- .../components/zha/core/channels/base.py | 4 + .../zha/core/channels/smartenergy.py | 144 ++++++--- .../components/zha/core/discovery.py | 29 +- .../components/zha/core/registries.py | 30 +- homeassistant/components/zha/entity.py | 40 +++ homeassistant/components/zha/sensor.py | 101 +++++- tests/components/zha/common.py | 17 +- tests/components/zha/test_discover.py | 102 +++--- tests/components/zha/test_registries.py | 71 +++++ tests/components/zha/test_sensor.py | 290 +++++++++++++++++- tests/components/zha/zha_devices_list.py | 60 +++- 11 files changed, 785 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 17f2693a090..d297b5187c0 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -148,6 +148,10 @@ class ZigbeeChannel(LogMixin): """Return the status of the channel.""" return self._status + def __hash__(self) -> int: + """Make this a hashable.""" + return hash(self._unique_id) + @callback def async_send_signal(self, signal: str, *args: Any) -> None: """Send a signal through hass dispatcher.""" diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 373d7312a4b..f3f0e76a5fb 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -1,19 +1,13 @@ """Smart energy channels module for Zigbee Home Automation.""" from __future__ import annotations +import enum +from functools import partialmethod + from zigpy.zcl.clusters import smartenergy -from homeassistant.const import ( - POWER_WATT, - TIME_HOURS, - TIME_SECONDS, - VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, - VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, -) -from homeassistant.core import callback - from .. import registries, typing as zha_typing -from ..const import REPORT_CONFIG_DEFAULT +from ..const import REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP from .base import ZigbeeChannel @@ -61,59 +55,101 @@ class Messaging(ZigbeeChannel): class Metering(ZigbeeChannel): """Metering channel.""" - REPORT_CONFIG = ({"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT},) + REPORT_CONFIG = ( + {"attr": "instantaneous_demand", "config": REPORT_CONFIG_OP}, + {"attr": "current_summ_delivered", "config": REPORT_CONFIG_DEFAULT}, + {"attr": "status", "config": REPORT_CONFIG_ASAP}, + ) ZCL_INIT_ATTRS = { - "divisor": True, - "multiplier": True, - "unit_of_measure": True, "demand_formatting": True, + "divisor": True, + "metering_device_type": True, + "multiplier": True, + "summa_formatting": True, + "unit_of_measure": True, } - unit_of_measure_map = { - 0x00: POWER_WATT, - 0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - 0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, - 0x03: f"ccf/{TIME_HOURS}", - 0x04: f"US gal/{TIME_HOURS}", - 0x05: f"IMP gal/{TIME_HOURS}", - 0x06: f"BTU/{TIME_HOURS}", - 0x07: f"l/{TIME_HOURS}", - 0x08: "kPa", - 0x09: "kPa", - 0x0A: f"mcf/{TIME_HOURS}", - 0x0B: "unitless", - 0x0C: f"MJ/{TIME_SECONDS}", + metering_device_type = { + 0: "Electric Metering", + 1: "Gas Metering", + 2: "Water Metering", + 3: "Thermal Metering", + 4: "Pressure Metering", + 5: "Heat Metering", + 6: "Cooling Metering", + 128: "Mirrored Gas Metering", + 129: "Mirrored Water Metering", + 130: "Mirrored Thermal Metering", + 131: "Mirrored Pressure Metering", + 132: "Mirrored Heat Metering", + 133: "Mirrored Cooling Metering", } + class DeviceStatusElectric(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + CHECK_METER = 1 + LOW_BATTERY = 2 + TAMPER_DETECT = 4 + POWER_FAILURE = 8 + POWER_QUALITY = 16 + LEAK_DETECT = 32 # Really? + SERVICE_DISCONNECT = 64 + RESERVED = 128 + + class DeviceStatusDefault(enum.IntFlag): + """Metering Device Status.""" + + NO_ALARMS = 0 + + class FormatSelector(enum.IntEnum): + """Format specified selector.""" + + DEMAND = 0 + SUMMATION = 1 + def __init__( self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) self._format_spec = None + self._summa_format = None @property def divisor(self) -> int: """Return divisor for the value.""" return self.cluster.get("divisor") or 1 + @property + def device_type(self) -> int | None: + """Return metering device type.""" + dev_type = self.cluster.get("metering_device_type") + if dev_type is None: + return None + return self.metering_device_type.get(dev_type, dev_type) + @property def multiplier(self) -> int: """Return multiplier for the value.""" return self.cluster.get("multiplier") or 1 - @callback - def attribute_updated(self, attrid: int, value: int) -> None: - """Handle attribute update from Metering cluster.""" - if None in (self.multiplier, self.divisor, self._format_spec): - return - super().attribute_updated(attrid, value) + @property + def status(self) -> int | None: + """Return metering device status.""" + status = self.cluster.get("status") + if status is None: + return None + if self.cluster.get("metering_device_type") == 0: + # Electric metering device type + return self.DeviceStatusElectric(status) + return self.DeviceStatusDefault(status) @property def unit_of_measurement(self) -> str: """Return unit of measurement.""" - uom = self.cluster.get("unit_of_measure", 0x7F) - return self.unit_of_measure_map.get(uom & 0x7F, "unknown") + return self.cluster.get("unit_of_measure") async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Fetch config from device and updates format specifier.""" @@ -121,29 +157,49 @@ class Metering(ZigbeeChannel): fmting = self.cluster.get( "demand_formatting", 0xF9 ) # 1 digit to the right, 15 digits to the left + self._format_spec = self.get_formatting(fmting) - r_digits = int(fmting & 0x07) # digits to the right of decimal point - l_digits = (fmting >> 3) & 0x0F # digits to the left of decimal point + fmting = self.cluster.get( + "summa_formatting", 0xF9 + ) # 1 digit to the right, 15 digits to the left + self._summa_format = self.get_formatting(fmting) + + @staticmethod + def get_formatting(formatting: int) -> str: + """Return a formatting string, given the formatting value. + + Bits 0 to 2: Number of Digits to the right of the Decimal Point. + Bits 3 to 6: Number of Digits to the left of the Decimal Point. + Bit 7: If set, suppress leading zeros. + """ + r_digits = int(formatting & 0x07) # digits to the right of decimal point + l_digits = (formatting >> 3) & 0x0F # digits to the left of decimal point if l_digits == 0: l_digits = 15 width = r_digits + l_digits + (1 if r_digits > 0 else 0) - if fmting & 0x80: - self._format_spec = "{:" + str(width) + "." + str(r_digits) + "f}" - else: - self._format_spec = "{:0" + str(width) + "." + str(r_digits) + "f}" + if formatting & 0x80: + # suppress leading 0 + return f"{{:{width}.{r_digits}f}}" - def formatter_function(self, value: int) -> int | float: + return f"{{:0{width}.{r_digits}f}}" + + def _formatter_function(self, selector: FormatSelector, value: int) -> int | float: """Return formatted value for display.""" value = value * self.multiplier / self.divisor - if self.unit_of_measurement == POWER_WATT: + if self.unit_of_measurement == 0: # Zigbee spec power unit is kW, but we show the value in W value_watt = value * 1000 if value_watt < 100: return round(value_watt, 1) return round(value_watt) + if selector == self.FormatSelector.SUMMATION: + return self._summa_format.format(value).lstrip() return self._format_spec.format(value).lstrip() + demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) + summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) + @registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Prepayment.cluster_id) class Prepayment(ZigbeeChannel): diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 49d640c3165..4d70c7aea96 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -46,7 +46,8 @@ async def async_add_entities( """Add entities helper.""" if not entities: return - to_add = [ent_cls(*args) for ent_cls, args in entities] + to_add = [ent_cls.create_entity(*args) for ent_cls, args in entities] + to_add = [entity for entity in to_add if entity is not None] _async_add_entities(to_add, update_before_add=update_before_add) entities.clear() @@ -63,6 +64,7 @@ class ProbeEndpoint: """Process an endpoint on a zigpy device.""" self.discover_by_device_type(channel_pool) self.discover_by_cluster_id(channel_pool) + self.discover_multi_entities(channel_pool) @callback def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: @@ -159,6 +161,31 @@ class ProbeEndpoint: channel = channel_class(cluster, ep_channels) self.probe_single_cluster(component, channel, ep_channels) + @staticmethod + @callback + def discover_multi_entities(channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on and discover multiple entities.""" + + remaining_channels = channel_pool.unclaimed_channels() + for channel in remaining_channels: + unique_id = f"{channel_pool.unique_id}-{channel.cluster.cluster_id}" + + matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( + channel_pool.manufacturer, + channel_pool.model, + channel, + remaining_channels, + ) + if not claimed: + continue + + channel_pool.claim_channels(claimed) + for component, ent_classes_list in matches.items(): + for entity_class in ent_classes_list: + channel_pool.async_new_entity( + component, entity_class, unique_id, claimed + ) + def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {}) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index f7f35e0755d..203867db17d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -84,7 +84,6 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR, zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, - zcl.clusters.smartenergy.Metering.cluster_id: SENSOR, } SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { @@ -247,7 +246,9 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" self._strict_registry: RegistryDictType = collections.defaultdict(dict) - self._loose_registry: RegistryDictType = collections.defaultdict(dict) + self._multi_entity_registry: RegistryDictType = collections.defaultdict( + lambda: collections.defaultdict(list) + ) self._group_registry: GroupRegistryDictType = {} def get_entity( @@ -267,6 +268,27 @@ class ZHAEntityRegistry: return default, [] + def get_multi_entity( + self, + manufacturer: str, + model: str, + primary_channel: ChannelType, + aux_channels: list[ChannelType], + components: set | None = None, + ) -> tuple[dict[str, list[CALLABLE_T]], list[ChannelType]]: + """Match ZHA Channels to potentially multiple ZHA Entity classes.""" + result: dict[str, list[CALLABLE_T]] = collections.defaultdict(list) + claimed: set[ChannelType] = set() + for component in components or self._multi_entity_registry: + matches = self._multi_entity_registry[component] + for match in sorted(matches, key=lambda x: x.weight, reverse=True): + if match.strict_matched(manufacturer, model, [primary_channel]): + claimed |= set(match.claim_channels(aux_channels)) + ent_classes = self._multi_entity_registry[component][match] + result[component].extend(ent_classes) + + return result, list(claimed) + def get_group_entity(self, component: str) -> CALLABLE_T: """Match a ZHA group to a ZHA Entity class.""" return self._group_registry.get(component) @@ -296,7 +318,7 @@ class ZHAEntityRegistry: return decorator - def loose_match( + def multipass_match( self, component: str, channel_names: Callable | set[str] | str = None, @@ -316,7 +338,7 @@ class ZHAEntityRegistry: All non empty fields of a match rule must match. """ - self._loose_registry[component][rule] = zha_entity + self._multi_entity_registry[component][rule].append(zha_entity) return zha_entity return decorator diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 50dd7e16a28..6fd68056025 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -41,12 +41,16 @@ UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): """A base class for ZHA entities.""" + _unique_id_suffix: str | None = None + def __init__(self, unique_id: str, zha_device: ZhaDeviceType, **kwargs) -> None: """Init ZHA entity.""" self._name: str = "" self._force_update: bool = False self._should_poll: bool = False self._unique_id: str = unique_id + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device: ZhaDeviceType = zha_device @@ -142,6 +146,16 @@ class BaseZhaEntity(LogMixin, entity.Entity): class ZhaEntity(BaseZhaEntity, RestoreEntity): """A base class for non group ZHA entities.""" + def __init_subclass__(cls, id_suffix: str | None = None, **kwargs) -> None: + """Initialize subclass. + + :param id_suffix: suffix to add to the unique_id of the entity. Used for multi + entities using the same channel/cluster id for the entity. + """ + super().__init_subclass__(**kwargs) + if id_suffix: + cls._unique_id_suffix = id_suffix + def __init__( self, unique_id: str, @@ -155,10 +169,26 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): ch_names = [ch.cluster.ep_attribute for ch in channels] ch_names = ", ".join(sorted(ch_names)) self._name: str = f"{zha_device.name} {ieeetail} {ch_names}" + if self._unique_id_suffix: + self._name += f" {self._unique_id_suffix}" self.cluster_channels: dict[str, ChannelType] = {} for channel in channels: self.cluster_channels[channel.name] = channel + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(unique_id, zha_device, channels, **kwargs) + @property def available(self) -> bool: """Return entity availability.""" @@ -238,6 +268,16 @@ class ZhaGroupEntity(BaseZhaEntity): """Return entity availability.""" return self._available + @classmethod + def create_entity( + cls, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs + ) -> ZhaGroupEntity | None: + """Group Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls(entity_ids, unique_id, group_id, zha_device, **kwargs) + async def _handle_group_membership_changed(self): """Handle group membership changed.""" # Make sure we don't call remove twice as members are removed diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index cc401cb1e05..342fbd58d89 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -16,17 +16,28 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, DOMAIN, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, SensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, PRESSURE_HPA, TEMP_CELSIUS, + TIME_HOURS, + TIME_SECONDS, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, + VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + VOLUME_GALLONS, + VOLUME_LITERS, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -72,6 +83,7 @@ BATTERY_SIZES = { CHANNEL_ST_HUMIDITY_CLUSTER = f"channel_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, DOMAIN) async def async_setup_entry( @@ -262,21 +274,100 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000)), 1) -@STRICT_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) class SmartEnergyMetering(Sensor): """Metering sensor.""" - SENSOR_ATTR = "instantaneous_demand" - _device_class = DEVICE_CLASS_POWER + SENSOR_ATTR: int | str = "instantaneous_demand" + _device_class: str | None = DEVICE_CLASS_POWER + _state_class: str | None = STATE_CLASS_MEASUREMENT + + unit_of_measure_map = { + 0x00: POWER_WATT, + 0x01: VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, + 0x02: VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, + 0x03: f"100 {VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR}", + 0x04: f"US {VOLUME_GALLONS}/{TIME_HOURS}", + 0x05: f"IMP {VOLUME_GALLONS}/{TIME_HOURS}", + 0x06: f"BTU/{TIME_HOURS}", + 0x07: f"l/{TIME_HOURS}", + 0x08: "kPa", # gauge + 0x09: "kPa", # absolute + 0x0A: f"1000 {VOLUME_GALLONS}/{TIME_HOURS}", + 0x0B: "unitless", + 0x0C: f"MJ/{TIME_SECONDS}", + } + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZhaDeviceType, + channels: list[ChannelType], + **kwargs, + ) -> ZhaEntity | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + se_channel = channels[0] + if cls.SENSOR_ATTR in se_channel.cluster.unsupported_attributes: + return None + + return cls(unique_id, zha_device, channels, **kwargs) def formatter(self, value: int) -> int | float: """Pass through channel formatter.""" - return self._channel.formatter_function(value) + return self._channel.demand_formatter(value) @property def native_unit_of_measurement(self) -> str: """Return Unit of measurement.""" - return self._channel.unit_of_measurement + return self.unit_of_measure_map.get(self._channel.unit_of_measurement) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device state attrs for battery sensors.""" + attrs = {} + if self._channel.device_type is not None: + attrs["device_type"] = self._channel.device_type + status = self._channel.status + if status is not None: + attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] + return attrs + + +@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): + """Smart Energy Metering summation sensor.""" + + SENSOR_ATTR: int | str = "current_summ_delivered" + _device_class: str | None = DEVICE_CLASS_ENERGY + _state_class: str = STATE_CLASS_TOTAL_INCREASING + + unit_of_measure_map = { + 0x00: ENERGY_KILO_WATT_HOUR, + 0x01: VOLUME_CUBIC_METERS, + 0x02: VOLUME_CUBIC_FEET, + 0x03: f"100 {VOLUME_CUBIC_FEET}", + 0x04: f"US {VOLUME_GALLONS}", + 0x05: f"IMP {VOLUME_GALLONS}", + 0x06: "BTU", + 0x07: VOLUME_LITERS, + 0x08: "kPa", # gauge + 0x09: "kPa", # absolute + 0x0A: f"1000 {VOLUME_CUBIC_FEET}", + 0x0B: "unitless", + 0x0C: "MJ", + } + + def formatter(self, value: int) -> int | float: + """Numeric pass-through formatter.""" + if self._channel.unit_of_measurement != 0: + return self._channel.summa_formatter(value) + + cooked = float(self._channel.multiplier * value) / self._channel.divisor + return round(cooked, 3) @STRICT_MATCH(channel_names=CHANNEL_PRESSURE) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index e87962c8d8b..b302869d9e4 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -109,6 +109,18 @@ async def send_attributes_report(hass, cluster: zigpy.zcl.Cluster, attributes: d async def find_entity_id(domain, zha_device, hass): """Find the entity id under the testing. + This is used to get the entity id in order to get the state from the state + machine so that we can test state changes. + """ + entities = await find_entity_ids(domain, zha_device, hass) + if not entities: + return None + return entities[0] + + +async def find_entity_ids(domain, zha_device, hass): + """Find the entity ids under the testing. + This is used to get the entity id in order to get the state from the state machine so that we can test state changes. """ @@ -118,10 +130,11 @@ async def find_entity_id(domain, zha_device, hass): enitiy_ids = hass.states.async_entity_ids(domain) await hass.async_block_till_done() + res = [] for entity_id in enitiy_ids: if entity_id.startswith(head): - return entity_id - return None + res.append(entity_id) + return res def async_find_group_entity_id(hass, domain, group): diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 86cdcaa1c60..d765ede0e5f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -41,6 +41,7 @@ from .zha_devices_list import ( ) NO_TAIL_ID = re.compile("_\\d$") +UNIQUE_ID_HD = re.compile(r"^(([\da-fA-F]{2}:){7}[\da-fA-F]{2}-\d{1,3})", re.X) @pytest.fixture @@ -102,12 +103,6 @@ async def test_devices( finally: zha_channels.ChannelPool.async_new_entity = orig_new_entity - entity_ids = hass_disable_services.states.async_entity_ids() - await hass_disable_services.async_block_till_done() - zha_entity_ids = { - ent for ent in entity_ids if ent.split(".")[0] in zha_const.PLATFORMS - } - if cluster_identify: called = int(zha_device_joined_restored.name == "zha_device_joined") assert cluster_identify.request.call_count == called @@ -128,26 +123,49 @@ async def test_devices( event_channels = { ch.id for pool in zha_dev.channels.pools for ch in pool.client_channels.values() } - - entity_map = device[DEV_SIG_ENT_MAP] - assert zha_entity_ids == { - e[DEV_SIG_ENT_MAP_ID] - for e in entity_map.values() - if not e.get("default_match", False) - } assert event_channels == set(device[DEV_SIG_EVT_CHANNELS]) + # build a dict of entity_class -> (component, unique_id, channels) tuple + ha_ent_info = {} for call in _dispatch.call_args_list: _, component, entity_cls, unique_id, channels = call[0] - key = (component, unique_id) - entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) - assert key in entity_map - assert entity_id is not None - no_tail_id = NO_TAIL_ID.sub("", entity_map[key][DEV_SIG_ENT_MAP_ID]) - assert entity_id.startswith(no_tail_id) - assert {ch.name for ch in channels} == set(entity_map[key][DEV_SIG_CHANNELS]) - assert entity_cls.__name__ == entity_map[key][DEV_SIG_ENT_MAP_CLASS] + for comp_id, ent_info in device[DEV_SIG_ENT_MAP].items(): + component, unique_id = comp_id + no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID]) + ha_entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + assert ha_entity_id is not None + assert ha_entity_id.startswith(no_tail_id) + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert component is ha_comp + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(ha_unique_id) + assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + + assert _dispatch.call_count == len(device[DEV_SIG_ENT_MAP]) + + entity_ids = hass_disable_services.states.async_entity_ids() + await hass_disable_services.async_block_till_done() + + zha_entity_ids = { + ent for ent in entity_ids if ent.split(".")[0] in zha_const.PLATFORMS + } + assert zha_entity_ids == { + e[DEV_SIG_ENT_MAP_ID] for e in device[DEV_SIG_ENT_MAP].values() + } def _get_first_identify_cluster(zigpy_device): @@ -279,21 +297,35 @@ async def test_discover_endpoint(device_info, channels_mock, hass): assert device_info[DEV_SIG_EVT_CHANNELS] == sorted( ch.id for pool in channels.pools for ch in pool.client_channels.values() ) - assert new_ent.call_count == len( - [ - device_info - for device_info in device_info[DEV_SIG_ENT_MAP].values() - if not device_info.get("default_match", False) - ] - ) + assert new_ent.call_count == len(list(device_info[DEV_SIG_ENT_MAP].values())) - for call_args in new_ent.call_args_list: - comp, ent_cls, unique_id, channels = call_args[0] - map_id = (comp, unique_id) - assert map_id in device_info[DEV_SIG_ENT_MAP] - entity_info = device_info[DEV_SIG_ENT_MAP][map_id] - assert {ch.name for ch in channels} == set(entity_info[DEV_SIG_CHANNELS]) - assert ent_cls.__name__ == entity_info[DEV_SIG_ENT_MAP_CLASS] + # build a dict of entity_class -> (component, unique_id, channels) tuple + ha_ent_info = {} + for call in new_ent.call_args_list: + component, entity_cls, unique_id, channels = call[0] + unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) # ieee + endpoint_id + ha_ent_info[(unique_id_head, entity_cls.__name__)] = ( + component, + unique_id, + channels, + ) + + for comp_id, ent_info in device_info[DEV_SIG_ENT_MAP].items(): + component, unique_id = comp_id + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + ha_comp, ha_unique_id, ha_channels = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert component is ha_comp + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(ha_unique_id) + assert {ch.name for ch in ha_channels} == set(ent_info[DEV_SIG_CHANNELS]) + + assert new_ent.call_count == len(device_info[DEV_SIG_ENT_MAP]) def _ch_mock(cluster): diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index b0f1a44a3d6..d202c7256dd 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -314,3 +314,74 @@ def test_weighted_match(channel, entity_registry, manufacturer, model, match_nam assert match.__name__ == match_name assert claimed == [ch_on_off] + + +def test_multi_sensor_match(channel, entity_registry): + """Test multi-entity match.""" + + s = mock.sentinel + + @entity_registry.multipass_match( + s.binary_sensor, + channel_names="smartenergy_metering", + ) + class SmartEnergySensor2: + pass + + ch_se = channel("smartenergy_metering", 0x0702) + ch_illuminati = channel("illuminance", 0x0401) + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_illuminati, + aux_channels=[ch_se, ch_illuminati], + ) + + assert s.binary_sensor not in match + assert s.component not in match + assert set(claimed) == set() + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_se, + aux_channels=[ch_se, ch_illuminati], + ) + + assert s.binary_sensor in match + assert s.component not in match + assert set(claimed) == {ch_se} + assert {cls.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__ + } + + @entity_registry.multipass_match( + s.component, channel_names="smartenergy_metering", aux_channels="illuminance" + ) + class SmartEnergySensor1: + pass + + @entity_registry.multipass_match( + s.binary_sensor, + channel_names="smartenergy_metering", + aux_channels="illuminance", + ) + class SmartEnergySensor3: + pass + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + primary_channel=ch_se, + aux_channels={ch_se, ch_illuminati}, + ) + + assert s.binary_sensor in match + assert s.component in match + assert set(claimed) == {ch_se, ch_illuminati} + assert {cls.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__, + SmartEnergySensor3.__name__, + } + assert {cls.__name__ for cls in match[s.component]} == {SmartEnergySensor1.__name__} diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index ddccb5117d5..21731da72e6 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -11,10 +11,13 @@ import zigpy.zcl.clusters.smartenergy as smartenergy from homeassistant.components.sensor import DOMAIN import homeassistant.config as config_util from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, @@ -23,6 +26,8 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + VOLUME_CUBIC_FEET, + VOLUME_CUBIC_METERS, ) from homeassistant.helpers import restore_state from homeassistant.util import dt as dt_util @@ -31,11 +36,14 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, + find_entity_ids, send_attribute_report, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_e769900a_{}" + async def async_test_humidity(hass, cluster, entity_id): """Test humidity sensor.""" @@ -65,9 +73,38 @@ async def async_test_illuminance(hass, cluster, entity_id): async def async_test_metering(hass, cluster, entity_id): - """Test metering sensor.""" + """Test Smart Energy metering sensor.""" await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100}) - assert_state(hass, entity_id, "12345.0", "unknown") + assert_state(hass, entity_id, "12345.0", None) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + + await send_attributes_report(hass, cluster, {1024: 12346, "status": 64 + 8}) + assert_state(hass, entity_id, "12346.0", None) + assert ( + hass.states.get(entity_id).attributes["status"] + == "SERVICE_DISCONNECT|POWER_FAILURE" + ) + + await send_attributes_report( + hass, cluster, {"status": 32, "metering_device_type": 1} + ) + # currently only statuses for electric meters are supported + assert hass.states.get(entity_id).attributes["status"] == "" + + +async def async_test_smart_energy_summation(hass, cluster, entity_id): + """Test SmartEnergy Summation delivered sensro.""" + + await send_attributes_report( + hass, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} + ) + assert_state(hass, entity_id, "12.32", VOLUME_CUBIC_METERS) + assert hass.states.get(entity_id).attributes["status"] == "NO_ALARMS" + assert hass.states.get(entity_id).attributes["device_type"] == "Electric Metering" + assert ( + hass.states.get(entity_id).attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY + ) async def async_test_electrical_measurement(hass, cluster, entity_id): @@ -106,40 +143,81 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): @pytest.mark.parametrize( - "cluster_id, test_func, report_count, read_plug", + "cluster_id, entity_suffix, test_func, report_count, read_plug, unsupported_attrs", ( - (measurement.RelativeHumidity.cluster_id, async_test_humidity, 1, None), + ( + measurement.RelativeHumidity.cluster_id, + "humidity", + async_test_humidity, + 1, + None, + None, + ), ( measurement.TemperatureMeasurement.cluster_id, + "temperature", async_test_temperature, 1, None, + None, + ), + ( + measurement.PressureMeasurement.cluster_id, + "pressure", + async_test_pressure, + 1, + None, + None, ), - (measurement.PressureMeasurement.cluster_id, async_test_pressure, 1, None), ( measurement.IlluminanceMeasurement.cluster_id, + "illuminance", async_test_illuminance, 1, None, + None, ), ( smartenergy.Metering.cluster_id, + "smartenergy_metering", async_test_metering, 1, { "demand_formatting": 0xF9, "divisor": 1, + "metering_device_type": 0x00, "multiplier": 1, + "status": 0x00, }, + {"current_summ_delivered"}, + ), + ( + smartenergy.Metering.cluster_id, + "smartenergy_metering_summation_delivered", + async_test_smart_energy_summation, + 1, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summa_formatting": 0b1_0111_010, + "unit_of_measure": 0x01, + }, + {"instaneneous_demand"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement", async_test_electrical_measurement, 1, None, + None, ), ( general.PowerConfiguration.cluster_id, + "power", async_test_powerconfiguration, 2, { @@ -147,6 +225,7 @@ async def async_test_powerconfiguration(hass, cluster, entity_id): "battery_voltage": 29, "battery_quantity": 3, }, + None, ), ), ) @@ -155,9 +234,11 @@ async def test_sensor( zigpy_device_mock, zha_device_joined_restored, cluster_id, + entity_suffix, test_func, report_count, read_plug, + unsupported_attrs, ): """Test zha sensor platform.""" @@ -171,12 +252,15 @@ async def test_sensor( } ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + if unsupported_attrs: + for attr in unsupported_attrs: + cluster.add_unsupported_attribute(attr) if cluster_id == smartenergy.Metering.cluster_id: # this one is mains powered zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 cluster.PLUGGED_ATTR_READS = read_plug zha_device = await zha_device_joined_restored(zigpy_device) - entity_id = await find_entity_id(DOMAIN, zha_device, hass) + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) await async_enable_traffic(hass, [zha_device], enabled=False) await hass.async_block_till_done() @@ -372,3 +456,197 @@ async def test_electrical_measurement_init( assert channel.divisor == 10 assert channel.multiplier == 20 assert hass.states.get(entity_id).state == "60.0" + + +@pytest.mark.parametrize( + "cluster_id, unsupported_attributes, entity_ids, missing_entity_ids", + ( + ( + smartenergy.Metering.cluster_id, + { + "instantaneous_demand", + }, + { + "smartenergy_metering_summation_delivered", + }, + { + "smartenergy_metering", + }, + ), + ( + smartenergy.Metering.cluster_id, + {"instantaneous_demand", "current_summ_delivered"}, + {}, + { + "smartenergy_metering_summation_delivered", + "smartenergy_metering", + }, + ), + ( + smartenergy.Metering.cluster_id, + {}, + { + "smartenergy_metering_summation_delivered", + "smartenergy_metering", + }, + {}, + ), + ), +) +async def test_unsupported_attributes_sensor( + hass, + zigpy_device_mock, + zha_device_joined_restored, + cluster_id, + unsupported_attributes, + entity_ids, + missing_entity_ids, +): + """Test zha sensor platform.""" + + entity_ids = {ENTITY_ID_PREFIX.format(e) for e in entity_ids} + missing_entity_ids = {ENTITY_ID_PREFIX.format(e) for e in missing_entity_ids} + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + } + } + ) + cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + if cluster_id == smartenergy.Metering.cluster_id: + # this one is mains powered + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + for attr in unsupported_attributes: + cluster.add_unsupported_attribute(attr) + zha_device = await zha_device_joined_restored(zigpy_device) + + await async_enable_traffic(hass, [zha_device], enabled=False) + await hass.async_block_till_done() + present_entity_ids = set(await find_entity_ids(DOMAIN, zha_device, hass)) + assert present_entity_ids == entity_ids + assert missing_entity_ids not in present_entity_ids + + +@pytest.mark.parametrize( + "raw_uom, raw_value, expected_state, expected_uom", + ( + ( + 1, + 12320, + "1.23", + VOLUME_CUBIC_METERS, + ), + ( + 1, + 1232000, + "123.20", + VOLUME_CUBIC_METERS, + ), + ( + 3, + 2340, + "0.23", + f"100 {VOLUME_CUBIC_FEET}", + ), + ( + 3, + 2360, + "0.24", + f"100 {VOLUME_CUBIC_FEET}", + ), + ( + 8, + 23660, + "2.37", + "kPa", + ), + ( + 0, + 9366, + "0.937", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 999, + "0.1", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 10091, + "1.009", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 10099, + "1.01", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 100999, + "10.1", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 100023, + "10.002", + ENERGY_KILO_WATT_HOUR, + ), + ( + 0, + 102456, + "10.246", + ENERGY_KILO_WATT_HOUR, + ), + ), +) +async def test_se_summation_uom( + hass, + zigpy_device_mock, + zha_device_joined, + raw_uom, + raw_value, + expected_state, + expected_uom, +): + """Test zha smart energy summation.""" + + entity_id = ENTITY_ID_PREFIX.format("smartenergy_metering_summation_delivered") + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + smartenergy.Metering.cluster_id, + general.Basic.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SIMPLE_SENSOR, + } + } + ) + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + + cluster = zigpy_device.endpoints[1].in_clusters[smartenergy.Metering.cluster_id] + for attr in ("instanteneous_demand",): + cluster.add_unsupported_attribute(attr) + cluster.PLUGGED_ATTR_READS = { + "current_summ_delivered": raw_value, + "demand_formatting": 0xF9, + "divisor": 10000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summa_formatting": 0b1_0111_010, + "unit_of_measure": raw_uom, + } + await zha_device_joined(zigpy_device) + + assert_state(hass, entity_id, expected_state, expected_uom) diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 30780bcaa86..531e9649ec3 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -118,6 +118,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "sensor.centralite_3210_l_77665544_electrical_measurement", "sensor.centralite_3210_l_77665544_smartenergy_metering", + "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", "switch.centralite_3210_l_77665544_on_off", ], DEV_SIG_ENT_MAP: { @@ -131,6 +132,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_77665544_smartenergy_metering_summation_delivered", + }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -391,6 +397,7 @@ DEVICES = [ }, DEV_SIG_ENTITIES: [ "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", + "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", "switch.climaxtechnology_psmp5_00_00_02_02tc_77665544_on_off", ], DEV_SIG_ENT_MAP: { @@ -404,6 +411,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["4:0x0019"], SIG_MANUFACTURER: "ClimaxTechnology", @@ -943,6 +955,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.jasco_products_45852_77665544_level_on_off", "sensor.jasco_products_45852_77665544_smartenergy_metering", + "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -955,6 +968,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], SIG_MANUFACTURER: "Jasco Products", @@ -982,6 +1000,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.jasco_products_45856_77665544_on_off", "sensor.jasco_products_45856_77665544_smartenergy_metering", + "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -994,6 +1013,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006"], SIG_MANUFACTURER: "Jasco Products", @@ -1021,6 +1045,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.jasco_products_45857_77665544_level_on_off", "sensor.jasco_products_45857_77665544_smartenergy_metering", + "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -1033,6 +1058,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019", "2:0x0006", "2:0x0008"], SIG_MANUFACTURER: "Jasco Products", @@ -2782,12 +2812,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_ias_zone", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - DEV_SIG_CHANNELS: ["manufacturer_specific"], - DEV_SIG_ENT_MAP_CLASS: "BinaryInput", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_77665544_manufacturer_specific", - "default_match": True, - }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "Samjin", @@ -2925,6 +2949,7 @@ DEVICES = [ "light.sercomm_corp_sz_esw01_77665544_on_off", "sensor.sercomm_corp_sz_esw01_77665544_electrical_measurement", "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", + "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -2937,6 +2962,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_77665544_smartenergy_metering_summation_delivered", + }, ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CHANNELS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", @@ -3423,6 +3453,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sengled_e11_g13_77665544_level_on_off", "sensor.sengled_e11_g13_77665544_smartenergy_metering", + "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3435,6 +3466,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "sengled", @@ -3455,6 +3491,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sengled_e12_n14_77665544_level_on_off", "sensor.sengled_e12_n14_77665544_smartenergy_metering", + "sensor.sengled_e12_n14_77665544_smartenergy_metering_sumaiton_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3467,6 +3504,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "sengled", @@ -3487,6 +3529,7 @@ DEVICES = [ DEV_SIG_ENTITIES: [ "light.sengled_z01_a19nae26_77665544_level_light_color_on_off", "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", + "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", ], DEV_SIG_ENT_MAP: { ("light", "00:11:22:33:44:55:66:77-1"): { @@ -3499,6 +3542,11 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering", }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CHANNELS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_77665544_smartenergy_metering_summation_delivered", + }, }, DEV_SIG_EVT_CHANNELS: ["1:0x0019"], SIG_MANUFACTURER: "sengled",