diff --git a/pyproject.toml b/pyproject.toml index a72984bcb..2b1f30dbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "zigpy-deconz==0.25.5", "zigpy-xbee==0.21.1", "zigpy-zigate==0.14.0", - "zha-quirks==1.2.0", + "zha-quirks>=1.2.0", ] [tool.setuptools.packages.find] @@ -30,7 +30,8 @@ exclude = ["tests", "tests.*"] testing = [ "pytest", "coloredlogs", - "python-slugify" + "python-slugify", + "homeassistant", ] [tool.setuptools-git-versioning] @@ -229,6 +230,7 @@ split-on-trailing-comma = false # Allow for main entry & scripts to write to stdout "script/*" = ["T20"] +"tools/*" = ["T20"] [tool.ruff.lint.mccabe] max-complexity = 25 diff --git a/tests/common.py b/tests/common.py index 3f45f52cd..9dadbce60 100644 --- a/tests/common.py +++ b/tests/common.py @@ -13,7 +13,6 @@ from zigpy.application import ControllerApplication from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from zigpy.quirks import get_device as quirks_get_device import zigpy.types as t import zigpy.zcl import zigpy.zcl.foundation as zcl_f @@ -423,7 +422,7 @@ def zigpy_device_from_device_data( if quirk: device = quirk(app, device.ieee, device.nwk, device) else: - device = quirks_get_device(device) + device = app._device_resolver(device) for epid, ep in device_data["endpoints"].items(): try: @@ -601,7 +600,7 @@ def create_mock_zigpy_device( if quirk: device = quirk(zigpy_app_controller, device.ieee, device.nwk, device) else: - device = quirks_get_device(device) + device = zigpy_app_controller._device_resolver(device) if patch_cluster: for endpoint in (ep for epid, ep in device.endpoints.items() if epid): diff --git a/tests/data/devices/ikea-of-sweden-starkvind-air-purifier-table.json b/tests/data/devices/ikea-of-sweden-starkvind-air-purifier-table.json index 1ec2cf34f..17911d435 100644 --- a/tests/data/devices/ikea-of-sweden-starkvind-air-purifier-table.json +++ b/tests/data/devices/ikea-of-sweden-starkvind-air-purifier-table.json @@ -634,7 +634,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", diff --git a/tests/data/devices/ikea-of-sweden-starkvind-air-purifier.json b/tests/data/devices/ikea-of-sweden-starkvind-air-purifier.json index 070f1aaa6..44d1ae22c 100644 --- a/tests/data/devices/ikea-of-sweden-starkvind-air-purifier.json +++ b/tests/data/devices/ikea-of-sweden-starkvind-air-purifier.json @@ -562,7 +562,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", diff --git a/tests/data/devices/tze200-7bztmfm1-ts0601.json b/tests/data/devices/tze200-7bztmfm1-ts0601.json index 546a27e3c..cd9b93698 100644 --- a/tests/data/devices/tze200-7bztmfm1-ts0601.json +++ b/tests/data/devices/tze200-7bztmfm1-ts0601.json @@ -454,7 +454,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", @@ -542,7 +542,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze200-dwcarsat-ts0601.json b/tests/data/devices/tze200-dwcarsat-ts0601.json index e8399460a..309b31486 100644 --- a/tests/data/devices/tze200-dwcarsat-ts0601.json +++ b/tests/data/devices/tze200-dwcarsat-ts0601.json @@ -554,7 +554,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", @@ -642,7 +642,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze200-mja3fuja-ts0601.json b/tests/data/devices/tze200-mja3fuja-ts0601.json index 151005a0f..35db2c2b9 100644 --- a/tests/data/devices/tze200-mja3fuja-ts0601.json +++ b/tests/data/devices/tze200-mja3fuja-ts0601.json @@ -485,7 +485,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze200-yvx5lh6k-ts0601.json b/tests/data/devices/tze200-yvx5lh6k-ts0601.json index e29b12837..28cb371ad 100644 --- a/tests/data/devices/tze200-yvx5lh6k-ts0601.json +++ b/tests/data/devices/tze200-yvx5lh6k-ts0601.json @@ -337,7 +337,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", @@ -381,7 +381,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze204-c2fmom5z-ts0601.json b/tests/data/devices/tze204-c2fmom5z-ts0601.json index 4241fc729..3fa6bf2d0 100644 --- a/tests/data/devices/tze204-c2fmom5z-ts0601.json +++ b/tests/data/devices/tze204-c2fmom5z-ts0601.json @@ -617,7 +617,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", @@ -705,7 +705,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze204-dwcarsat-ts0601.json b/tests/data/devices/tze204-dwcarsat-ts0601.json index a0fd28419..ab745d8ab 100644 --- a/tests/data/devices/tze204-dwcarsat-ts0601.json +++ b/tests/data/devices/tze204-dwcarsat-ts0601.json @@ -496,7 +496,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", @@ -584,7 +584,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze204-ltwbm23f-ts0601.json b/tests/data/devices/tze204-ltwbm23f-ts0601.json index 36bfeedcc..6d74c9138 100644 --- a/tests/data/devices/tze204-ltwbm23f-ts0601.json +++ b/tests/data/devices/tze204-ltwbm23f-ts0601.json @@ -571,13 +571,13 @@ "mode": "auto", "native_max_value": 6, "native_min_value": -6, - "native_step": 0.1, + "native_step": 1, "native_unit_of_measurement": "\u00b0C" }, "state": { "class_name": "NumberConfigurationEntity", "available": true, - "state": 0.0 + "state": 0 } }, { diff --git a/tests/data/devices/tze204-qyr2m29i-ts0601.json b/tests/data/devices/tze204-qyr2m29i-ts0601.json index 83bbdede5..da47a6967 100644 --- a/tests/data/devices/tze204-qyr2m29i-ts0601.json +++ b/tests/data/devices/tze204-qyr2m29i-ts0601.json @@ -528,7 +528,7 @@ "mode": "auto", "native_max_value": 6, "native_min_value": -6, - "native_step": 0.1, + "native_step": 1, "native_unit_of_measurement": "\u00b0C" }, "state": { diff --git a/tests/data/devices/tze204-yvx5lh6k-ts0601.json b/tests/data/devices/tze204-yvx5lh6k-ts0601.json index 2b408c456..b431a583a 100644 --- a/tests/data/devices/tze204-yvx5lh6k-ts0601.json +++ b/tests/data/devices/tze204-yvx5lh6k-ts0601.json @@ -454,7 +454,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "PM25", @@ -542,7 +542,7 @@ "available": true, "group_id": null, "suggested_display_precision": 0, - "unit": "\u00b5g/m\u00b3" + "unit": "\u03bcg/m\u00b3" }, "state": { "class_name": "VOCLevel", diff --git a/tests/data/devices/tze284-rqcuwlsa-ts0601.json b/tests/data/devices/tze284-rqcuwlsa-ts0601.json index 9bba0a8a8..690dc2666 100644 --- a/tests/data/devices/tze284-rqcuwlsa-ts0601.json +++ b/tests/data/devices/tze284-rqcuwlsa-ts0601.json @@ -452,7 +452,7 @@ "available": true, "group_id": null, "suggested_display_precision": null, - "unit": "\u00b5S/cm" + "unit": "\u03bcS/cm" }, "state": { "class_name": "ElectricalConductivity", diff --git a/tests/test_discover.py b/tests/test_discover.py index 30e3c7cbe..ca3035b48 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -55,7 +55,6 @@ zigpy_device_from_json, ) from zha.application import Platform -from zha.application.discovery import discover_device_entities from zha.application.gateway import Gateway from zha.application.helpers import DeviceOverridesConfiguration from zha.application.platforms import PlatformEntity, binary_sensor, sensor @@ -171,7 +170,7 @@ async def test_device_override_picks_highest_priority( zha_device = await join_zigpy_device(zha_gateway, zigpy_device) # Only one light entity will be discovered - entities = list(discover_device_entities(zha_device)) + entities = list(zha_device.discover_entities()) light_entities = [e for e in entities if e.PLATFORM == Platform.LIGHT] assert len(light_entities) == 1 assert isinstance(light_entities[0], HueLight) @@ -181,7 +180,7 @@ async def test_device_override_picks_highest_priority( f"{zigpy_device.ieee}-11": DeviceOverridesConfiguration(type=Platform.SWITCH) } - entities = list(discover_device_entities(zha_device)) + entities = list(zha_device.discover_entities()) switch_entities = [e for e in entities if e.PLATFORM == Platform.SWITCH] assert len(switch_entities) == 1 diff --git a/tools/compare_constants.py b/tools/compare_constants.py new file mode 100644 index 000000000..831faf839 --- /dev/null +++ b/tools/compare_constants.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Compare ZHA's local copies of HA constants against the canonical `homeassistant` package. + +Run this whenever `homeassistant` is bumped to surface drift in unit enums, +device-class enums, and module-level constants that ZHA mirrors. + +Exits 1 on drift, 0 if fully in sync. +""" + +from __future__ import annotations + +from enum import Enum +import importlib +import sys +from typing import Any + +import homeassistant.const as ha_const + +import zha.units as zha_units + +# Explicit (zha, ha) pairs for enums that don't live in `zha.units`. +ENUM_PAIRS: list[tuple[str, str]] = [ + ( + "zha.application.platforms.binary_sensor.device_class.BinarySensorDeviceClass", + "homeassistant.components.binary_sensor.BinarySensorDeviceClass", + ), + ( + "zha.application.platforms.sensor.device_class.SensorDeviceClass", + "homeassistant.components.sensor.SensorDeviceClass", + ), + ( + "zha.application.platforms.sensor.device_class.SensorStateClass", + "homeassistant.components.sensor.SensorStateClass", + ), + ( + "zha.application.platforms.number.device_class.NumberDeviceClass", + "homeassistant.components.number.NumberDeviceClass", + ), + ( + "zha.application.platforms.number.device_class.NumberMode", + "homeassistant.components.number.NumberMode", + ), + # zha.application.Platform is intentionally a Zigbee-only subset of + # homeassistant.const.Platform — comparison would produce noise. +] + +# Constants intentionally defined in ZHA without an HA counterpart. +ZHA_ONLY_CONSTANTS: frozenset[str] = frozenset({"COUNT", "KILOJOULES_PER_KG"}) + + +def import_qualified(qualified_name: str) -> Any: + """Import a dotted name like 'zha.units.UnitOfTemperature'.""" + module_name, attr = qualified_name.rsplit(".", 1) + return getattr(importlib.import_module(module_name), attr) + + +def is_enum_class(obj: Any) -> bool: + """Return True if obj is an Enum subclass.""" + return isinstance(obj, type) and issubclass(obj, Enum) + + +def compare_enums(zha_enum: type[Enum], ha_enum: type[Enum]) -> list[str]: + """Return a list of difference lines between two enums (empty if identical).""" + zha_members = {m.name: m.value for m in zha_enum} + ha_members = {m.name: m.value for m in ha_enum} + + diffs: list[str] = [] + for name in sorted(set(ha_members) - set(zha_members)): + diffs.append(f" missing in ZHA: {name} = {ha_members[name]!r}") + for name in sorted(set(zha_members) - set(ha_members)): + diffs.append(f" extra in ZHA: {name} = {zha_members[name]!r}") + for name in sorted(set(zha_members) & set(ha_members)): + if zha_members[name] != ha_members[name]: + diffs.append( + f" value mismatch: {name}: ZHA={zha_members[name]!r} HA={ha_members[name]!r}" + ) + return diffs + + +def compare_units_module() -> list[tuple[str, list[str]]]: + """Compare zha.units against homeassistant.const in both directions. + + Forward: walk zha.units and compare each against the same-named symbol in + homeassistant.const. Reverse: surface UnitOf* enums in homeassistant.const + that don't exist in zha.units at all. + """ + results: list[tuple[str, list[str]]] = [] + for name in sorted(vars(zha_units)): + if name.startswith("_"): + continue + zha_obj = getattr(zha_units, name) + + # Only compare classes/strings defined in zha.units itself + # (skip re-imports like StrEnum, Final). + if is_enum_class(zha_obj): + if zha_obj.__module__ != zha_units.__name__: + continue + elif not isinstance(zha_obj, str): + continue + + if name in ZHA_ONLY_CONSTANTS: + continue + + if not hasattr(ha_const, name): + results.append((name, [" not present in homeassistant.const"])) + continue + + ha_obj = getattr(ha_const, name) + + if is_enum_class(zha_obj) and is_enum_class(ha_obj): + results.append((name, compare_enums(zha_obj, ha_obj))) + elif isinstance(zha_obj, str) and isinstance(ha_obj, str): + if zha_obj != ha_obj: + results.append( + (name, [f" value mismatch: ZHA={zha_obj!r} HA={ha_obj!r}"]) + ) + else: + results.append((name, [])) + else: + results.append( + ( + name, + [ + f" type mismatch: ZHA={type(zha_obj).__name__} HA={type(ha_obj).__name__}" + ], + ) + ) + + # Reverse scan: UnitOf* enums in homeassistant.const that ZHA doesn't have. + zha_names = {n for n in vars(zha_units) if not n.startswith("_")} + for name in sorted(vars(ha_const)): + if not name.startswith("UnitOf"): + continue + ha_obj = getattr(ha_const, name) + if not is_enum_class(ha_obj): + continue + if ha_obj.__module__ != ha_const.__name__: + continue + if name in zha_names: + continue + members = ", ".join(f"{m.name}={m.value!r}" for m in ha_obj) + results.append((name, [f" not present in zha.units (HA has: {members})"])) + + return results + + +def print_block(title: str, results: list[tuple[str, list[str]]]) -> bool: + """Print one section. Return True if any drift was found.""" + print(title) + print("=" * len(title)) + drift = False + in_sync: list[str] = [] + for name, diffs in results: + if not diffs: + in_sync.append(name) + continue + drift = True + print(f"\n{name}:") + for line in diffs: + print(line) + if in_sync: + print(f"\nin sync ({len(in_sync)}): {', '.join(in_sync)}") + print() + return drift + + +def main() -> int: + """Run the comparison and return an exit code (0 = in sync, 1 = drift).""" + units_results = compare_units_module() + + enum_results: list[tuple[str, list[str]]] = [] + for zha_name, ha_name in ENUM_PAIRS: + label = f"{zha_name.split('.')[-1]} ({zha_name} ↔ {ha_name})" + try: + zha_enum = import_qualified(zha_name) + ha_enum = import_qualified(ha_name) + except (ImportError, AttributeError) as exc: + enum_results.append((label, [f" failed to import: {exc}"])) + continue + enum_results.append((label, compare_enums(zha_enum, ha_enum))) + + drift = False + drift |= print_block("zha.units vs homeassistant.const", units_results) + drift |= print_block("Device classes & Platform enums", enum_results) + + if drift: + print("DRIFT detected — ZHA constants are out of sync with homeassistant.") + return 1 + print("All compared constants match homeassistant.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/zha/application/__init__.py b/zha/application/__init__.py index 385cc9697..d169e19eb 100644 --- a/zha/application/__init__.py +++ b/zha/application/__init__.py @@ -24,3 +24,22 @@ class Platform(StrEnum): VALVE = "valve" UNKNOWN = "unknown" UPDATE = "update" + + +class EntityType(StrEnum): + """Entity type.""" + + CONFIG = "config" + DIAGNOSTIC = "diagnostic" + STANDARD = "standard" + + +class EntityPlatform(StrEnum): + """Entity platform exposed by quirks v2 metadata.""" + + BINARY_SENSOR = "binary_sensor" + BUTTON = "button" + NUMBER = "number" + SENSOR = "sensor" + SELECT = "select" + SWITCH = "switch" diff --git a/zha/application/discovery.py b/zha/application/discovery.py index aec9a259c..a312f17e9 100644 --- a/zha/application/discovery.py +++ b/zha/application/discovery.py @@ -20,7 +20,6 @@ ZCLEnumMetadata, ZCLSensorMetadata, ) -from zigpy.state import State from zigpy.zcl import ClusterType from zha.application import Platform, const as zha_const @@ -130,67 +129,6 @@ def inner(*args: P.args, **kwargs: P.kwargs) -> Iterator[T]: return inner -@ignore_exceptions_during_iteration -def discover_device_entities(device: Device) -> Iterator[BaseEntity]: - """Discover entities for a ZHA device.""" - _LOGGER.debug( - "Discovering entities for device: %s-%s", - str(device.ieee), - device.name, - ) - - assert not device.is_active_coordinator - - for ep_id, endpoint in device.endpoints.items(): - if ep_id == 0: - continue - - _LOGGER.debug( - "Discovering entities for endpoint: %s-%s", - str(endpoint.device.ieee), - endpoint.id, - ) - - yield from discover_entities_for_endpoint(endpoint) - - yield from discover_quirks_v2_entities(device) - - -@ignore_exceptions_during_iteration -def discover_coordinator_device_entities( - device: Device, -) -> Iterator[sensor.DeviceCounterSensor]: - """Discover entities for the coordinator device.""" - _LOGGER.debug( - "Discovering entities for coordinator device: %s-%s", - str(device.ieee), - device.name, - ) - state: State = device.gateway.application_controller.state - - for counter_groups in ( - "counters", - "broadcast_counters", - "device_counters", - "group_counters", - ): - for counter_group, counters in getattr(state, counter_groups).items(): - for counter in counters: - yield sensor.DeviceCounterSensor( - zha_device=device, - counter_groups=counter_groups, - counter_group=counter_group, - counter=counter, - ) - - _LOGGER.debug( - "'%s' platform -> '%s' using %s", - Platform.SENSOR, - sensor.DeviceCounterSensor.__name__, - f"counter groups[{counter_groups}] counter group[{counter_group}] counter[{counter}]", - ) - - @ignore_exceptions_during_iteration def discover_group_entities(group: Group) -> Iterator[GroupEntity]: """Process a group and create any entities that are needed.""" diff --git a/zha/application/gateway.py b/zha/application/gateway.py index e73478477..c23a7fb73 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -55,7 +55,13 @@ gather_with_limited_concurrency, ) from zha.event import EventBase -from zha.zigbee.device import Device, DeviceInfo, DeviceStatus, ExtendedDeviceInfo +from zha.zigbee.device import ( + Device, + DeviceInfo, + DeviceStatus, + ExtendedDeviceInfo, + resolve_device, +) from zha.zigbee.group import Group, GroupInfo, GroupMemberReference BLOCK_LOG_TIMEOUT: Final[int] = 60 @@ -251,6 +257,7 @@ async def _async_initialize(self) -> None: config=app_config, auto_form=False, start_radio=False, + device_resolver=resolve_device, ) await self.application_controller.startup(auto_form=True) diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index 9da3fc16c..d04749562 100644 --- a/zha/application/platforms/__init__.py +++ b/zha/application/platforms/__init__.py @@ -14,11 +14,11 @@ from typing import TYPE_CHECKING, Any, Final, Literal, final from zigpy.profiles import zha, zll -from zigpy.quirks.v2 import EntityMetadata, EntityType +from zigpy.quirks.v2 import EntityMetadata from zigpy.types import ClusterId from zigpy.types.named import EUI64 -from zha.application import Platform +from zha.application import EntityType, Platform from zha.application.const import UniqueIdMigration from zha.const import STATE_CHANGED from zha.debounce import Debouncer @@ -519,9 +519,9 @@ def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: elif has_command_name: self._unique_id_suffix = entity_metadata.command_name - if entity_metadata.entity_type is EntityType.CONFIG: + if entity_metadata.entity_type == EntityType.CONFIG: self._attr_entity_category = EntityCategory.CONFIG - elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: + elif entity_metadata.entity_type == EntityType.DIAGNOSTIC: self._attr_entity_category = EntityCategory.DIAGNOSTIC else: self._attr_entity_category = None diff --git a/zha/application/platforms/binary_sensor/const.py b/zha/application/platforms/binary_sensor/const.py index ece9a1fbd..56a0b31d9 100644 --- a/zha/application/platforms/binary_sensor/const.py +++ b/zha/application/platforms/binary_sensor/const.py @@ -1,9 +1,9 @@ """Constants for the binary_sensor platform.""" -from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass from zigpy.zcl.clusters.security import IasZone -# Re-exported from zigpy for use throughout ZHA +from zha.application.platforms.binary_sensor.device_class import BinarySensorDeviceClass + __all__ = ["BinarySensorDeviceClass"] diff --git a/zha/application/platforms/binary_sensor/device_class.py b/zha/application/platforms/binary_sensor/device_class.py new file mode 100644 index 000000000..0eea82253 --- /dev/null +++ b/zha/application/platforms/binary_sensor/device_class.py @@ -0,0 +1,91 @@ +"""Device classes for the binary_sensor platform.""" + +from enum import StrEnum + + +class BinarySensorDeviceClass(StrEnum): + """Device class for binary sensors.""" + + # On means low, Off means normal + BATTERY = "battery" + + # On means charging, Off means not charging + BATTERY_CHARGING = "battery_charging" + + # On means carbon monoxide detected, Off means no carbon monoxide (clear) + CO = "carbon_monoxide" + + # On means cold, Off means normal + COLD = "cold" + + # On means connected, Off means disconnected + CONNECTIVITY = "connectivity" + + # On means open, Off means closed + DOOR = "door" + + # On means open, Off means closed + GARAGE_DOOR = "garage_door" + + # On means gas detected, Off means no gas (clear) + GAS = "gas" + + # On means hot, Off means normal + HEAT = "heat" + + # On means light detected, Off means no light + LIGHT = "light" + + # On means open (unlocked), Off means closed (locked) + LOCK = "lock" + + # On means wet, Off means dry + MOISTURE = "moisture" + + # On means motion detected, Off means no motion (clear) + MOTION = "motion" + + # On means moving, Off means not moving (stopped) + MOVING = "moving" + + # On means occupied, Off means not occupied (clear) + OCCUPANCY = "occupancy" + + # On means open, Off means closed + OPENING = "opening" + + # On means plugged in, Off means unplugged + PLUG = "plug" + + # On means power detected, Off means no power + POWER = "power" + + # On means home, Off means away + PRESENCE = "presence" + + # On means problem detected, Off means no problem (OK) + PROBLEM = "problem" + + # On means running, Off means not running + RUNNING = "running" + + # On means unsafe, Off means safe + SAFETY = "safety" + + # On means smoke detected, Off means no smoke (clear) + SMOKE = "smoke" + + # On means sound detected, Off means no sound (clear) + SOUND = "sound" + + # On means tampering detected, Off means no tampering (clear) + TAMPER = "tamper" + + # On means update available, Off means up-to-date + UPDATE = "update" + + # On means vibration detected, Off means no vibration + VIBRATION = "vibration" + + # On means open, Off means closed + WINDOW = "window" diff --git a/zha/application/platforms/number/const.py b/zha/application/platforms/number/const.py index 36870772c..6f52df930 100644 --- a/zha/application/platforms/number/const.py +++ b/zha/application/platforms/number/const.py @@ -1,8 +1,7 @@ """Constants for the Number platform.""" -from zigpy.quirks.v2.homeassistant.number import NumberDeviceClass, NumberMode +from zha.application.platforms.number.device_class import NumberDeviceClass, NumberMode -# Re-exported from zigpy for use throughout ZHA __all__ = ["NumberDeviceClass", "NumberMode"] ICONS = { diff --git a/zha/application/platforms/number/device_class.py b/zha/application/platforms/number/device_class.py new file mode 100644 index 000000000..592148235 --- /dev/null +++ b/zha/application/platforms/number/device_class.py @@ -0,0 +1,405 @@ +"""Device classes for the Number platform.""" + +from enum import StrEnum + + +class NumberMode(StrEnum): + """Modes for number entities.""" + + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + +class NumberDeviceClass(StrEnum): + """Device class for numbers.""" + + # NumberDeviceClass should be aligned with SensorDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ + + APPARENT_POWER = "apparent_power" + """Apparent power. + + Unit of measurement: `mVA`, `VA`, `kVA` + """ + + AQI = "aqi" + """Air Quality Index. + + Unit of measurement: `None` + """ + + AREA = "area" + """Area + + Unit of measurement: `UnitOfArea` units + """ + + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" + """Atmospheric pressure. + + Unit of measurement: `UnitOfPressure` units + """ + + BATTERY = "battery" + """Percentage of battery that is left. + + Unit of measurement: `%` + """ + + BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" + """Blood glucose concentration. + + Unit of measurement: `mg/dL`, `mmol/L` + """ + + CO = "carbon_monoxide" + """Carbon Monoxide gas concentration. + + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³` + """ + + CO2 = "carbon_dioxide" + """Carbon Dioxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ + + CONDUCTIVITY = "conductivity" + """Conductivity. + + Unit of measurement: `S/cm`, `mS/cm`, `μS/cm` + """ + + CURRENT = "current" + """Current. + + Unit of measurement: `A`, `mA` + """ + + DATA_RATE = "data_rate" + """Data rate. + + Unit of measurement: UnitOfDataRate + """ + + DATA_SIZE = "data_size" + """Data size. + + Unit of measurement: UnitOfInformation + """ + + DISTANCE = "distance" + """Generic distance. + + Unit of measurement: `LENGTH_*` units + - SI /metric: `mm`, `cm`, `m`, `km` + - USCS / imperial: `in`, `ft`, `yd`, `mi` + """ + + DURATION = "duration" + """Fixed duration. + + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `μs` + """ + + ENERGY = "energy" + """Energy. + + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + """ + + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `Wh/km`, `mi/kWh`, `km/kWh` + """ + + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + """ + + FREQUENCY = "frequency" + """Frequency. + + Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + """ + + GAS = "gas" + """Gas. + + Unit of measurement: + - SI / metric: `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `MCF` + """ + + HUMIDITY = "humidity" + """Relative humidity. + + Unit of measurement: `%` + """ + + ILLUMINANCE = "illuminance" + """Illuminance. + + Unit of measurement: `lx` + """ + + IRRADIANCE = "irradiance" + """Irradiance. + + Unit of measurement: + - SI / metric: `W/m²` + - USCS / imperial: `BTU/(h⋅ft²)` + """ + + MOISTURE = "moisture" + """Moisture. + + Unit of measurement: `%` + """ + + MONETARY = "monetary" + """Amount of money. + + Unit of measurement: ISO4217 currency code + + See https://en.wikipedia.org/wiki/ISO_4217#Active_codes for active codes + """ + + NITROGEN_DIOXIDE = "nitrogen_dioxide" + """Amount of NO2. + + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` + """ + + NITROGEN_MONOXIDE = "nitrogen_monoxide" + """Amount of NO. + + Unit of measurement: `ppb` (parts per billion), `μg/m³` + """ + + NITROUS_OXIDE = "nitrous_oxide" + """Amount of N2O. + + Unit of measurement: `μg/m³` + """ + + OZONE = "ozone" + """Amount of O3. + + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` + """ + + PH = "ph" + """Potential hydrogen (acidity/alkalinity). + + Unit of measurement: Unitless + """ + + PM1 = "pm1" + """Particulate matter <= 1 μm. + + Unit of measurement: `μg/m³` + """ + + PM10 = "pm10" + """Particulate matter <= 10 μm. + + Unit of measurement: `μg/m³` + """ + + PM25 = "pm25" + """Particulate matter <= 2.5 μm. + + Unit of measurement: `μg/m³` + """ + + PM4 = "pm4" + """Particulate matter <= 4 μm. + + Unit of measurement: `μg/m³` + """ + + POWER_FACTOR = "power_factor" + """Power factor. + + Unit of measurement: `%`, `None` + """ + + POWER = "power" + """Power. + + Unit of measurement: `mW`, `W`, `kW`, `MW`, `GW`, `TW`, `BTU/h` + """ + + PRECIPITATION = "precipitation" + """Accumulated precipitation. + + Unit of measurement: UnitOfPrecipitationDepth + - SI / metric: `cm`, `mm` + - USCS / imperial: `in` + """ + + PRECIPITATION_INTENSITY = "precipitation_intensity" + """Precipitation intensity. + + Unit of measurement: UnitOfVolumetricFlux + - SI /metric: `mm/d`, `mm/h` + - USCS / imperial: `in/d`, `in/h` + """ + + PRESSURE = "pressure" + """Pressure. + + Unit of measurement: + - `mbar`, `cbar`, `bar` + - `mPa`, `Pa`, `hPa`, `kPa` + - `inHg` + - `psi` + - `inH₂O` + """ + + REACTIVE_ENERGY = "reactive_energy" + """Reactive energy. + + Unit of measurement: `varh`, `kvarh` + """ + + REACTIVE_POWER = "reactive_power" + """Reactive power. + + Unit of measurement: `mvar`, `var`, `kvar` + """ + + SIGNAL_STRENGTH = "signal_strength" + """Signal strength. + + Unit of measurement: `dB`, `dBm` + """ + + SOUND_PRESSURE = "sound_pressure" + """Sound pressure. + + Unit of measurement: `dB`, `dBA` + """ + + SPEED = "speed" + """Generic speed. + + Unit of measurement: `SPEED_*` units or `UnitOfVolumetricFlux` + - SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h` + - USCS / imperial: `in/d`, `in/h`, `ft/s`, `mph` + - Nautical: `kn` + """ + + SULPHUR_DIOXIDE = "sulphur_dioxide" + """Amount of SO2. + + Unit of measurement: `ppb` (parts per billion), `μg/m³` + """ + + TEMPERATURE = "temperature" + """Temperature. + + Unit of measurement: `°C`, `°F`, `K` + """ + + TEMPERATURE_DELTA = "temperature_delta" + """Difference of temperatures - Temperature range. + + Unit of measurement: `°C`, `°F`, `K` + """ + + VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" + """Amount of VOC. + + Unit of measurement: `μg/m³`, `mg/m³` + """ + + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + + VOLTAGE = "voltage" + """Voltage. + + Unit of measurement: `V`, `mV`, `μV`, `kV`, `MV` + """ + + VOLUME = "volume" + """Generic volume. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + VOLUME_FLOW_RATE = "volume_flow_rate" + """Generic flow rate + + Unit of measurement: UnitOfVolumeFlowRate + - SI / metric: `m³/h`, `m³/min`, `m³/s`, `L/h`, `L/min`, `L/s`, `mL/s` + - USCS / imperial: `ft³/min`, `gal/min`, `gal/d` + """ + + WATER = "water" + """Water. + + Unit of measurement: + - SI / metric: `m³`, `L` + - USCS / imperial: `ft³`, `CCF`, `MCF`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + WEIGHT = "weight" + """Generic weight, represents a measurement of an object's mass. + + Weight is used instead of mass to fit with every day language. + + Unit of measurement: `MASS_*` units + - SI / metric: `μg`, `mg`, `g`, `kg` + - USCS / imperial: `oz`, `lb` + """ + + WIND_DIRECTION = "wind_direction" + """Wind direction. + + Unit of measurement: `°` + """ + + WIND_SPEED = "wind_speed" + """Wind speed. + + Unit of measurement: `SPEED_*` units + - SI /metric: `m/s`, `km/h` + - USCS / imperial: `ft/s`, `mph` + - Nautical: `kn` + """ diff --git a/zha/application/platforms/sensor/const.py b/zha/application/platforms/sensor/const.py index 093aaced9..8ad6c8654 100644 --- a/zha/application/platforms/sensor/const.py +++ b/zha/application/platforms/sensor/const.py @@ -2,9 +2,12 @@ from datetime import UTC, datetime -from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass from zigpy.zcl.clusters.general_const import AnalogInputType +from zha.application.platforms.sensor.device_class import ( + SensorDeviceClass, + SensorStateClass, +) from zha.units import ( CONCENTRATION_PARTS_PER_MILLION, COUNT, @@ -20,7 +23,6 @@ UnitOfTime, ) -# Re-exported from zigpy for use throughout ZHA __all__ = [ "SensorDeviceClass", "SensorStateClass", diff --git a/zha/application/platforms/sensor/device_class.py b/zha/application/platforms/sensor/device_class.py new file mode 100644 index 000000000..24fc70eee --- /dev/null +++ b/zha/application/platforms/sensor/device_class.py @@ -0,0 +1,460 @@ +"""Device classes for the sensor platform.""" + +from enum import StrEnum + + +class SensorDeviceClass(StrEnum): + """Device class for sensors.""" + + # Non-numerical device classes + DATE = "date" + """Date. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ + + ENUM = "enum" + """Enumeration. + + Provides a fixed list of options the state of the sensor can be in. + + Unit of measurement: `None` + """ + + TIMESTAMP = "timestamp" + """Timestamp. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ + + UPTIME = "uptime" + """Uptime. + + Represents the point in time when a device or service last restarted. + + Small drift between updates is automatically suppressed in + `SensorEntity.state` to avoid unnecessary state changes caused by clock + jitter. + + Unit of measurement: `None` + + ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 + """ + + # Numerical device classes, these should be aligned with NumberDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ + + APPARENT_POWER = "apparent_power" + """Apparent power. + + Unit of measurement: `mVA`, `VA`, `kVA` + """ + + AQI = "aqi" + """Air Quality Index. + + Unit of measurement: `None` + """ + + AREA = "area" + """Area + + Unit of measurement: `UnitOfArea` units + """ + + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" + """Atmospheric pressure. + + Unit of measurement: `UnitOfPressure` units + """ + + BATTERY = "battery" + """Percentage of battery that is left. + + Unit of measurement: `%` + """ + + BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" + """Blood glucose concentration. + + Unit of measurement: `mg/dL`, `mmol/L` + """ + + CO = "carbon_monoxide" + """Carbon Monoxide gas concentration. + + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `mg/m³`, `μg/m³` + """ + + CO2 = "carbon_dioxide" + """Carbon Dioxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ + + CONDUCTIVITY = "conductivity" + """Conductivity. + + Unit of measurement: `S/cm`, `mS/cm`, `μS/cm` + """ + + CURRENT = "current" + """Current. + + Unit of measurement: `A`, `mA` + """ + + DATA_RATE = "data_rate" + """Data rate. + + Unit of measurement: UnitOfDataRate + """ + + DATA_SIZE = "data_size" + """Data size. + + Unit of measurement: UnitOfInformation + """ + + DISTANCE = "distance" + """Generic distance. + + Unit of measurement: `LENGTH_*` units + - SI /metric: `mm`, `cm`, `m`, `km` + - USCS / imperial: `in`, `ft`, `yd`, `mi` + """ + + DURATION = "duration" + """Fixed duration. + + Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `μs` + """ + + ENERGY = "energy" + """Energy. + + Use this device class for sensors measuring energy consumption, for example + electric energy consumption. + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + """ + + ENERGY_DISTANCE = "energy_distance" + """Energy distance. + + Use this device class for sensors measuring energy by distance, for example the amount + of electric energy consumed by an electric car. + + Unit of measurement: `kWh/100km`, `Wh/km`, `mi/kWh`, `km/kWh` + """ + + ENERGY_STORAGE = "energy_storage" + """Stored energy. + + Use this device class for sensors measuring stored energy, for example the amount + of electric energy currently stored in a battery or the capacity of a battery. + + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `mWh`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` + """ + + FREQUENCY = "frequency" + """Frequency. + + Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + """ + + GAS = "gas" + """Gas. + + Unit of measurement: + - SI / metric: `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `MCF` + """ + + HUMIDITY = "humidity" + """Relative humidity. + + Unit of measurement: `%` + """ + + ILLUMINANCE = "illuminance" + """Illuminance. + + Unit of measurement: `lx` + """ + + IRRADIANCE = "irradiance" + """Irradiance. + + Unit of measurement: + - SI / metric: `W/m²` + - USCS / imperial: `BTU/(h⋅ft²)` + """ + + MOISTURE = "moisture" + """Moisture. + + Unit of measurement: `%` + """ + + MONETARY = "monetary" + """Amount of money. + + Unit of measurement: ISO4217 currency code + + See https://en.wikipedia.org/wiki/ISO_4217#Active_codes for active codes + """ + + NITROGEN_DIOXIDE = "nitrogen_dioxide" + """Amount of NO2. + + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` + """ + + NITROGEN_MONOXIDE = "nitrogen_monoxide" + """Amount of NO. + + Unit of measurement: `ppb` (parts per billion), `μg/m³` + """ + + NITROUS_OXIDE = "nitrous_oxide" + """Amount of N2O. + + Unit of measurement: `μg/m³` + """ + + OZONE = "ozone" + """Amount of O3. + + Unit of measurement: `ppb` (parts per billion), `ppm` (parts per million), `μg/m³` + """ + + PH = "ph" + """Potential hydrogen (acidity/alkalinity). + + Unit of measurement: Unitless + """ + + PM1 = "pm1" + """Particulate matter <= 1 μm. + + Unit of measurement: `μg/m³` + """ + + PM10 = "pm10" + """Particulate matter <= 10 μm. + + Unit of measurement: `μg/m³` + """ + + PM25 = "pm25" + """Particulate matter <= 2.5 μm. + + Unit of measurement: `μg/m³` + """ + + PM4 = "pm4" + """Particulate matter <= 4 μm. + + Unit of measurement: `μg/m³` + """ + + POWER_FACTOR = "power_factor" + """Power factor. + + Unit of measurement: `%`, `None` + """ + + POWER = "power" + """Power. + + Unit of measurement: `mW`, `W`, `kW`, `MW`, `GW`, `TW`, `BTU/h` + """ + + PRECIPITATION = "precipitation" + """Accumulated precipitation. + + Unit of measurement: UnitOfPrecipitationDepth + - SI / metric: `cm`, `mm` + - USCS / imperial: `in` + """ + + PRECIPITATION_INTENSITY = "precipitation_intensity" + """Precipitation intensity. + + Unit of measurement: UnitOfVolumetricFlux + - SI /metric: `mm/d`, `mm/h` + - USCS / imperial: `in/d`, `in/h` + """ + + PRESSURE = "pressure" + """Pressure. + + Unit of measurement: + - `mbar`, `cbar`, `bar` + - `mPa`, `Pa`, `hPa`, `kPa` + - `inHg` + - `psi` + - `inH₂O` + """ + + REACTIVE_ENERGY = "reactive_energy" + """Reactive energy. + + Unit of measurement: `varh`, `kvarh` + """ + + REACTIVE_POWER = "reactive_power" + """Reactive power. + + Unit of measurement: `mvar`, `var`, `kvar` + """ + + SIGNAL_STRENGTH = "signal_strength" + """Signal strength. + + Unit of measurement: `dB`, `dBm` + """ + + SOUND_PRESSURE = "sound_pressure" + """Sound pressure. + + Unit of measurement: `dB`, `dBA` + """ + + SPEED = "speed" + """Generic speed. + + Unit of measurement: `SPEED_*` units or `UnitOfVolumetricFlux` + - SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h`, `mm/s` + - USCS / imperial: `in/d`, `in/h`, `in/s`, `ft/s`, `mph` + - Nautical: `kn` + - Beaufort: `Beaufort` + """ + + SULPHUR_DIOXIDE = "sulphur_dioxide" + """Amount of SO2. + + Unit of measurement: `ppb` (parts per billion), `μg/m³` + """ + + TEMPERATURE = "temperature" + """Temperature. + + Unit of measurement: `°C`, `°F`, `K` + """ + + TEMPERATURE_DELTA = "temperature_delta" + """Difference of temperatures - Temperature range. + + Unit of measurement: `°C`, `°F`, `K` + """ + + VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" + """Amount of VOC. + + Unit of measurement: `μg/m³`, `mg/m³` + """ + + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + + VOLTAGE = "voltage" + """Voltage. + + Unit of measurement: `V`, `mV`, `μV`, `kV`, `MV` + """ + + VOLUME = "volume" + """Generic volume. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + VOLUME_STORAGE = "volume_storage" + """Generic stored volume. + + Use this device class for sensors measuring stored volume, for example the amount + of fuel in a fuel tank. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `MCF`, `fl. oz.`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + VOLUME_FLOW_RATE = "volume_flow_rate" + """Generic flow rate + + Unit of measurement: UnitOfVolumeFlowRate + - SI / metric: `m³/h`, `m³/min`, `m³/s`, `L/h`, `L/min`, `L/s`, `mL/s` + - USCS / imperial: `ft³/min`, `gal/min`, `gal/d` + """ + + WATER = "water" + """Water. + + Unit of measurement: + - SI / metric: `m³`, `L` + - USCS / imperial: `ft³`, `CCF`, `MCF`, `gal` (warning: volumes expressed in + USCS/imperial units are currently assumed to be US volumes) + """ + + WEIGHT = "weight" + """Generic weight, represents a measurement of an object's mass. + + Weight is used instead of mass to fit with every day language. + + Unit of measurement: `MASS_*` units + - SI / metric: `μg`, `mg`, `g`, `kg` + - USCS / imperial: `oz`, `lb` + """ + + WIND_DIRECTION = "wind_direction" + """Wind direction. + + Unit of measurement: `°` + """ + + WIND_SPEED = "wind_speed" + """Wind speed. + + Unit of measurement: `SPEED_*` units + - SI /metric: `m/s`, `km/h` + - USCS / imperial: `ft/s`, `mph` + - Nautical: `kn` + - Beaufort: `Beaufort` + """ + + +class SensorStateClass(StrEnum): + """State class for sensors.""" + + MEASUREMENT = "measurement" + """The state represents a measurement in present time.""" + + MEASUREMENT_ANGLE = "measurement_angle" + """The state represents a angle measurement in present time. Currently only degrees are supported.""" + + TOTAL = "total" + """The state represents a total amount. + + For example: net energy consumption""" + + TOTAL_INCREASING = "total_increasing" + """The state represents a monotonically increasing total. + + For example: an amount of consumed gas""" diff --git a/zha/units.py b/zha/units.py index 3501c7f10..540b2ab6c 100644 --- a/zha/units.py +++ b/zha/units.py @@ -18,7 +18,7 @@ class UnitOfMass(StrEnum): GRAMS = "g" KILOGRAMS = "kg" MILLIGRAMS = "mg" - MICROGRAMS = "µg" + MICROGRAMS = "μg" OUNCES = "oz" POUNDS = "lb" STONES = "st" @@ -27,6 +27,7 @@ class UnitOfMass(StrEnum): class UnitOfPressure(StrEnum): """Pressure units.""" + MILLIPASCAL = "mPa" PA = "Pa" HPA = "hPa" KPA = "kPa" @@ -35,6 +36,7 @@ class UnitOfPressure(StrEnum): MBAR = "mbar" MMHG = "mmHg" INHG = "inHg" + INH2O = "inH₂O" PSI = "psi" @@ -60,19 +62,30 @@ class UnitOfPower(StrEnum): class UnitOfReactivePower(StrEnum): """Reactive power units.""" + MILLIVOLT_AMPERE_REACTIVE = "mvar" VOLT_AMPERE_REACTIVE = "var" KILO_VOLT_AMPERE_REACTIVE = "kvar" +class UnitOfReactiveEnergy(StrEnum): + """Reactive energy units.""" + + VOLT_AMPERE_REACTIVE_HOUR = "varh" + KILO_VOLT_AMPERE_REACTIVE_HOUR = "kvarh" + + class UnitOfApparentPower(StrEnum): """Apparent power units.""" + MILLIVOLT_AMPERE = "mVA" VOLT_AMPERE = "VA" + KILO_VOLT_AMPERE = "kVA" class UnitOfElectricCurrent(StrEnum): """Electric current units.""" + MICROAMPERE = "μA" MILLIAMPERE = "mA" AMPERE = "A" @@ -81,7 +94,7 @@ class UnitOfElectricCurrent(StrEnum): class UnitOfElectricPotential(StrEnum): """Electric potential units.""" - MICROVOLT = "µV" + MICROVOLT = "μV" MILLIVOLT = "mV" VOLT = "V" KILOVOLT = "kV" @@ -91,6 +104,7 @@ class UnitOfElectricPotential(StrEnum): class UnitOfFrequency(StrEnum): """Frequency units.""" + MILLIHERTZ = "mHz" HERTZ = "Hz" KILOHERTZ = "kHz" MEGAHERTZ = "MHz" @@ -101,12 +115,15 @@ class UnitOfVolumeFlowRate(StrEnum): """Volume flow rate units.""" CUBIC_METERS_PER_HOUR = "m³/h" + CUBIC_METERS_PER_MINUTE = "m³/min" CUBIC_METERS_PER_SECOND = "m³/s" CUBIC_FEET_PER_MINUTE = "ft³/min" LITERS_PER_HOUR = "L/h" LITERS_PER_MINUTE = "L/min" LITERS_PER_SECOND = "L/s" + GALLONS_PER_HOUR = "gal/h" GALLONS_PER_MINUTE = "gal/min" + GALLONS_PER_DAY = "gal/d" MILLILITERS_PER_SECOND = "mL/s" @@ -130,6 +147,7 @@ class UnitOfVolume(StrEnum): CUBIC_FEET = "ft³" CENTUM_CUBIC_FEET = "CCF" + MILLE_CUBIC_FEET = "MCF" CUBIC_METERS = "m³" LITERS = "L" MILLILITERS = "mL" @@ -194,6 +212,7 @@ class UnitOfEnergyDistance(StrEnum): """Energy Distance units.""" KILO_WATT_HOUR_PER_100_KM = "kWh/100km" + WATT_HOUR_PER_KM = "Wh/km" MILES_PER_KILO_WATT_HOUR = "mi/kWh" KM_PER_KILO_WATT_HOUR = "km/kWh" @@ -202,7 +221,7 @@ class UnitOfConductivity(StrEnum): """Conductivity units.""" SIEMENS_PER_CM = "S/cm" - MICROSIEMENS_PER_CM = "µS/cm" + MICROSIEMENS_PER_CM = "μS/cm" MILLISIEMENS_PER_CM = "mS/cm" @@ -212,6 +231,7 @@ class UnitOfSpeed(StrEnum): BEAUFORT = "Beaufort" FEET_PER_SECOND = "ft/s" INCHES_PER_SECOND = "in/s" + METERS_PER_MINUTE = "m/min" METERS_PER_SECOND = "m/s" KILOMETERS_PER_HOUR = "km/h" KNOTS = "kn" @@ -220,13 +240,22 @@ class UnitOfSpeed(StrEnum): # Concentration units -CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" +CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" +CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "μg/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" CONCENTRATION_PARTS_PER_CUBIC_METER: Final = "p/m³" CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" + +class UnitOfBloodGlucoseConcentration(StrEnum): + """Blood glucose concentration units.""" + + MILLIGRAMS_PER_DECILITER = "mg/dL" + MILLIMOLE_PER_LITER = "mmol/L" + + # Signal_strength units SIGNAL_STRENGTH_DECIBELS: Final = "dB" SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm" @@ -234,12 +263,20 @@ class UnitOfSpeed(StrEnum): # Light units LIGHT_LUX: Final = "lx" +# UV Index units +UV_INDEX: Final = "UV index" + # Percentage units PERCENTAGE: Final[str] = "%" # Rotational speed units REVOLUTIONS_PER_MINUTE: Final = "rpm" +# Currency units +CURRENCY_EURO: Final = "€" +CURRENCY_DOLLAR: Final = "$" +CURRENCY_CENT: Final = "¢" + # Irradiance units class UnitOfIrradiance(StrEnum): @@ -249,6 +286,86 @@ class UnitOfIrradiance(StrEnum): BTUS_PER_HOUR_SQUARE_FOOT = "BTU/(h⋅ft²)" +class UnitOfVolumetricFlux(StrEnum): + """Volumetric flux, commonly used for precipitation intensity. + + The derivation of these units is a volume of rain amassing in a container + with constant cross section in a given time + """ + + INCHES_PER_DAY = "in/d" + """Derived from in³/(in²⋅d)""" + + INCHES_PER_HOUR = "in/h" + """Derived from in³/(in²⋅h)""" + + MILLIMETERS_PER_DAY = "mm/d" + """Derived from mm³/(mm²⋅d)""" + + MILLIMETERS_PER_HOUR = "mm/h" + """Derived from mm³/(mm²⋅h)""" + + +class UnitOfPrecipitationDepth(StrEnum): + """Precipitation depth. + + The derivation of these units is a volume of rain amassing in a container + with constant cross section + """ + + INCHES = "in" + """Derived from in³/in²""" + + MILLIMETERS = "mm" + """Derived from mm³/mm²""" + + CENTIMETERS = "cm" + """Derived from cm³/cm²""" + + +# Data units +class UnitOfInformation(StrEnum): + """Information units.""" + + BITS = "bit" + KILOBITS = "kbit" + MEGABITS = "Mbit" + GIGABITS = "Gbit" + BYTES = "B" + KILOBYTES = "kB" + MEGABYTES = "MB" + GIGABYTES = "GB" + TERABYTES = "TB" + PETABYTES = "PB" + EXABYTES = "EB" + ZETTABYTES = "ZB" + YOTTABYTES = "YB" + KIBIBYTES = "KiB" + MEBIBYTES = "MiB" + GIBIBYTES = "GiB" + TEBIBYTES = "TiB" + PEBIBYTES = "PiB" + EXBIBYTES = "EiB" + ZEBIBYTES = "ZiB" + YOBIBYTES = "YiB" + + +class UnitOfDataRate(StrEnum): + """Data rate units.""" + + BITS_PER_SECOND = "bit/s" + KILOBITS_PER_SECOND = "kbit/s" + MEGABITS_PER_SECOND = "Mbit/s" + GIGABITS_PER_SECOND = "Gbit/s" + BYTES_PER_SECOND = "B/s" + KILOBYTES_PER_SECOND = "kB/s" + MEGABYTES_PER_SECOND = "MB/s" + GIGABYTES_PER_SECOND = "GB/s" + KIBIBYTES_PER_SECOND = "KiB/s" + MEBIBYTES_PER_SECOND = "MiB/s" + GIBIBYTES_PER_SECOND = "GiB/s" + + # Kinetic energy KILOJOULES_PER_KG: Final = "KJ/kg" diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index 14884e1da..fff897dbb 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -6,7 +6,7 @@ import asyncio from collections import defaultdict -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable, Iterable, Iterator, Sequence import contextlib import copy import dataclasses @@ -15,7 +15,7 @@ from functools import cached_property import logging import time -from typing import TYPE_CHECKING, Any, Final, Self +from typing import TYPE_CHECKING, Any, Final from zigpy.device import Device as ZigpyDevice import zigpy.exceptions @@ -25,6 +25,7 @@ from zigpy.types import uint1_t, uint8_t, uint16_t from zigpy.types.named import EUI64, NWK, ExtendedPanId from zigpy.typing import UNDEFINED, UndefinedType +from zigpy.zcl import ClusterType from zigpy.zcl.clusters import Cluster from zigpy.zcl.clusters.general import Groups, Identify, Ota from zigpy.zcl.foundation import ( @@ -80,6 +81,7 @@ BaseEntityInfo, EntityStateChangedEvent, PlatformEntity, + sensor, ) from zha.application.platforms.update import BaseFirmwareUpdateEntity from zha.const import STATE_CHANGED @@ -137,6 +139,82 @@ def get_device_automation_triggers( } +def _read_current_firmware_version( + zigpy_device: zigpy.device.Device, +) -> int | None: + """Read `current_file_version` from the device's OTA cluster, or None.""" + try: + ota = zigpy_device.find_cluster( + cluster_id=Ota.cluster_id, cluster_type=ClusterType.Client + ) + except ValueError: + return None + return ota.get(Ota.AttributeDefs.current_file_version.id) + + +@dataclass(frozen=True) +class DeviceMatch: + """Fingerprint criteria for matching a `Device` subclass to a zigpy device.""" + + manufacturers: frozenset[str] | None = None + models: frozenset[str] | None = None + firmware_versions: tuple[int | None, int | None] = (None, None) + firmware_version_allow_missing: bool = True + + +DEVICE_QUIRKS: list[type[Device]] = [] + + +def register_device(cls: type[Device]) -> type[Device]: + """Register a `Device` subclass for fingerprint-based dispatch.""" + DEVICE_QUIRKS.append(cls) + return cls + + +@dataclass(frozen=True) +class Replace: + """Replace a cluster on a device's endpoint with a CustomCluster subclass.""" + + endpoint_id: int + cluster_id: int + cluster_type: ClusterType + replacement: type[zigpy.quirks.CustomCluster] + + def apply(self, device: zigpy.device.Device) -> None: + endpoint = device.endpoints[self.endpoint_id] + if self.cluster_type is ClusterType.Server: + endpoint.in_clusters.pop(self.cluster_id, None) + endpoint.add_input_cluster( + self.cluster_id, self.replacement(endpoint, is_server=True) + ) + else: + endpoint.out_clusters.pop(self.cluster_id, None) + endpoint.add_output_cluster( + self.cluster_id, self.replacement(endpoint, is_server=False) + ) + + +def resolve_device(zigpy_device: zigpy.device.Device) -> zigpy.device.Device: + """Resolver for `ControllerApplication.register_device_resolver`. + + Applies v3 cluster operations for the first matching `Device` subclass, + or falls back to the legacy v1/v2 quirks registry for backward compat. + """ + for cls in DEVICE_QUIRKS: + if cls.matches(zigpy_device): + _LOGGER.warning( + "v3 resolver matched %s for %s/%s, applying %d ops", + cls.__name__, + zigpy_device.manufacturer, + zigpy_device.model, + len(cls._operations), + ) + for op in cls._operations: + op.apply(zigpy_device) + return zigpy_device + return zigpy.quirks.get_device(zigpy_device) + + @dataclass(frozen=True, kw_only=True) class ClusterBinding: """Describes a cluster binding.""" @@ -282,6 +360,11 @@ class Device(LogMixin, EventBase): unique_id: str + # The base `Device` is the universal fallback (matches anything) and is never + # iterated through `DEVICE_QUIRKS`. + _device_match: DeviceMatch = DeviceMatch() + _operations: tuple[Replace, ...] = () + # Cached properties that depend on the zigpy device and must be invalidated # when the underlying device is swapped (e.g. after a re-interview). _ZIGPY_CACHED_PROPERTIES: Final = ( @@ -689,13 +772,46 @@ def get_platform_entity(self, platform: Platform, unique_id: str) -> PlatformEnt raise KeyError(f"Entity {unique_id} not found") return entity + @classmethod + def matches(cls, zigpy_device: zigpy.device.Device) -> bool: + """Return True if this Device subclass should wrap `zigpy_device`.""" + m = cls._device_match + if ( + m.manufacturers is not None + and zigpy_device.manufacturer not in m.manufacturers + ): + return False + if m.models is not None and zigpy_device.model not in m.models: + return False + + min_fw, max_fw = m.firmware_versions + if min_fw is not None or max_fw is not None: + current = _read_current_firmware_version(zigpy_device) + if current is None: + if not m.firmware_version_allow_missing: + return False + else: + if min_fw is not None and current < min_fw: + return False + if max_fw is not None and current >= max_fw: + return False + + return True + @classmethod def new( cls, zigpy_dev: zigpy.device.Device, gateway: Gateway, - ) -> Self: - """Create new device.""" + ) -> Device: + """Create new device, dispatching to a registered subclass when applicable.""" + if zigpy_dev.ieee == gateway.state.node_info.ieee: + return CoordinatorDevice(zigpy_dev, gateway) + + for quirk_cls in DEVICE_QUIRKS: + if quirk_cls.matches(zigpy_dev): + return quirk_cls(zigpy_dev, gateway) + return cls(zigpy_dev, gateway) def async_update_firmware_version(self, firmware_version: str) -> None: @@ -1047,19 +1163,38 @@ def _apply_entity_metadata_changes(self, entity: PlatformEntity) -> None: if meta.new_fallback_name is not None: entity._attr_fallback_name = meta.new_fallback_name - def _discover_new_entities(self) -> None: - new_entities: Iterable[BaseEntity] + def discover_entities(self) -> Iterator[BaseEntity]: + """Yield entities for this device.""" + # TODO: purge old coordinator entities + if self.is_coordinator: + return - if self.is_active_coordinator: - new_entities = discovery.discover_coordinator_device_entities(self) - elif self.is_coordinator: - # TODO: purge old coordinator entities - new_entities = [] - else: - new_entities = discovery.discover_device_entities(self) + for ep_id, endpoint in self.endpoints.items(): + if ep_id == 0: + continue + + _LOGGER.debug( + "Discovering entities for endpoint: %s-%s", + str(endpoint.device.ieee), + endpoint.id, + ) + yield from discovery.discover_entities_for_endpoint(endpoint) + + yield from discovery.discover_quirks_v2_entities(self) + + def _discover_new_entities(self) -> None: + # Iterate defensively so a failure in any single entity construction + # does not abort discovery for the rest of the device. + iterator = iter(self.discover_entities()) + while True: + try: + entity = next(iterator) + except StopIteration: + break + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to create entity during discovery") + continue - # Discover all applicable entities - for entity in new_entities: if self._is_entity_removed_by_quirk(entity): continue @@ -1802,3 +1937,32 @@ def get_diagnostics_json(self): ] return info + + +class CoordinatorDevice(Device): + """ZHA wrapper for the active coordinator device.""" + + def discover_entities(self) -> Iterator[BaseEntity]: + """Yield counter sensors for the active coordinator.""" + state = self.gateway.application_controller.state + for counter_groups in ( + "counters", + "broadcast_counters", + "device_counters", + "group_counters", + ): + for counter_group, counters in getattr(state, counter_groups).items(): + for counter in counters: + yield sensor.DeviceCounterSensor( + zha_device=self, + counter_groups=counter_groups, + counter_group=counter_group, + counter=counter, + ) + + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + Platform.SENSOR, + sensor.DeviceCounterSensor.__name__, + f"counter groups[{counter_groups}] counter group[{counter_group}] counter[{counter}]", + )