mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 17:27:10 +00:00
Add "Summation Delivered" Sensor for SmartEnergy metering ZHA channel (#56666)
This commit is contained in:
parent
60eb426451
commit
dbba2c4afe
@ -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."""
|
||||
|
@ -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):
|
||||
|
@ -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, {})
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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__}
|
||||
|
@ -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"] == "<bitmap8.32: 32>"
|
||||
|
||||
|
||||
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)
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user