From 822924287ad8c769cf5e72d38cb104b2dd639ba7 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 27 Mar 2024 19:18:16 -0400 Subject: [PATCH] Initial standalone version (#6) * update pyproject.toml * add periodic decorator from zhawss * add deps * update cluster handlers * update group * update endpoint * update device * cleanup gateway * more gateway cleanup * cleanup discovery * cleanup registries * fix slugify dep * clean up helpers * clean up * rename * start working on entities * update for quirks stuff * async stuff * start updating platform entities * update lock * update number * update select * clean up sensor * update siren * update switch * clean up * remove temp * tests from zhaws * button test * task cleanup * binary sensor test cleanup * cluster handlers tests and cleanup * discovery fixes * clean up * more cleanup * cleanup * test alarm control panel * remove import * climate tests * cover tests * cleanup * fan tests * lock tests * number tests * more tests * more work on tests * restructure and cleanup * more select tests * switch tests * counter sensor temp fixes * don't load 2x * more testing work * more tests * make all tests green * clean up * more cleanup * absolutify the imports * rough conversion on update * clean up constant duplication * fix stupid git ignore * add coverage * don't count TYPE_CHECKING blocks in coverage * unwind the sensordeviceclass changes from HA * more test coverage * button coverage * coverage * fix event handling * coverage * coverage * coverage * coverage * device tracker tests * more fan coverage * light coverage * coverage * coverage * coverage * coverage * event relays * set gateway ref in config * add global listeners * put baud rates back * rejigger * native_value * unify interface * add _attr_native_unit_of_measurement * tweak * unify interface * convert some events to dataclasses * fix name * debug * name handling needs cleanup * debug * add color modes * rename to emit prefix * emit zha event * clean up * rename * emit * emit * remove ZHA prefix from class names * remove ZHA prefix * remove ZHA prefix * no eventing for light group stuff * direct device availability * compat layer * some guards * add context * cleanup * lock async_update cleanup * clean up switch async_update * clean up * clean up handle_cluster_handler_attribute_updated * more efficient event processing * fix emits * coverage * cleanup * clean up * tests * more coverage * initial conversion of OTA * bust the cache and reduce cov to 92% * try explicit * oops --- .coveragerc | 4 + .github/workflows/ci.yml | 4 +- .gitignore | 1 - pyproject.toml | 325 +- requirements_test.txt | 2 + tests/common.py | 257 + tests/conftest.py | 378 ++ tests/test_alarm_control_panel.py | 239 + tests/test_async_.py | 655 ++ tests/test_binary_sensor.py | 146 + tests/test_button.py | 303 + tests/test_climate.py | 1506 +++++ tests/test_cluster_handlers.py | 1401 ++++ tests/test_color.py | 579 ++ tests/test_cover.py | 813 +++ tests/test_debouncer.py | 539 ++ tests/test_device.py | 267 + tests/test_device_tracker.py | 103 + tests/test_discover.py | 1087 +++ tests/test_fan.py | 821 +++ tests/test_gateway.py | 452 ++ tests/test_light.py | 1930 ++++++ tests/test_lock.py | 227 + tests/test_number.py | 387 ++ tests/test_registries.py | 587 ++ tests/test_select.py | 269 + tests/test_sensor.py | 1187 ++++ tests/test_siren.py | 168 + tests/test_switch.py | 898 +++ tests/test_units.py | 30 + tests/test_update.py | 577 ++ tests/zha_devices_list.py | 5922 +++++++++++++++++ zha/__init__.py | 293 +- zha/application/__init__.py | 50 + zha/application/const.py | 210 +- zha/application/decorators.py | 54 - zha/application/discovery.py | 328 +- zha/application/entity.py | 361 - zha/application/gateway.py | 592 +- zha/application/helpers.py | 196 +- zha/application/platforms/__init__.py | 313 + .../platforms/alarm_control_panel.py | 157 - .../platforms/alarm_control_panel/__init__.py | 160 + .../platforms/alarm_control_panel/const.py | 59 + .../__init__.py} | 216 +- .../platforms/binary_sensor/const.py | 104 + .../{button.py => button/__init__.py} | 134 +- zha/application/platforms/button/const.py | 13 + zha/application/platforms/climate.py | 824 --- zha/application/platforms/climate/__init__.py | 846 +++ zha/application/platforms/climate/const.py | 189 + .../platforms/{cover.py => cover/__init__.py} | 297 +- zha/application/platforms/cover/const.py | 63 + zha/application/platforms/device_tracker.py | 165 +- zha/application/platforms/fan.py | 315 - zha/application/platforms/fan/__init__.py | 422 ++ zha/application/platforms/fan/const.py | 51 + zha/application/platforms/fan/helpers.py | 100 + zha/application/platforms/helpers.py | 99 + .../platforms/{light.py => light/__init__.py} | 1021 ++- zha/application/platforms/light/const.py | 148 + zha/application/platforms/light/helpers.py | 779 +++ zha/application/platforms/lock.py | 197 - zha/application/platforms/lock/__init__.py | 125 + zha/application/platforms/lock/const.py | 12 + .../{number.py => number/__init__.py} | 624 +- zha/application/platforms/number/const.py | 560 ++ zha/application/platforms/select.py | 158 +- .../{sensor.py => sensor/__init__.py} | 567 +- zha/application/platforms/sensor/const.py | 392 ++ zha/application/platforms/siren.py | 115 +- zha/application/platforms/switch.py | 355 +- zha/application/platforms/update.py | 313 +- zha/application/registries.py | 98 +- zha/async_.py | 572 ++ zha/const.py | 19 + zha/debounce.py | 183 + zha/decorators.py | 108 + zha/event.py | 93 + zha/exceptions.py | 5 + zha/mixins.py | 28 + zha/units.py | 167 + zha/zigbee/__init__.py | 7 +- zha/zigbee/cluster_handlers/__init__.py | 295 +- zha/zigbee/cluster_handlers/closures.py | 60 +- zha/zigbee/cluster_handlers/const.py | 110 + zha/zigbee/cluster_handlers/general.py | 152 +- zha/zigbee/cluster_handlers/helpers.py | 2 +- zha/zigbee/cluster_handlers/homeautomation.py | 21 +- zha/zigbee/cluster_handlers/hvac.py | 52 +- zha/zigbee/cluster_handlers/lighting.py | 17 +- zha/zigbee/cluster_handlers/lightlink.py | 5 +- .../cluster_handlers/manufacturerspecific.py | 117 +- zha/zigbee/cluster_handlers/measurement.py | 44 +- zha/zigbee/cluster_handlers/protocol.py | 45 +- zha/zigbee/cluster_handlers/registries.py | 13 + zha/zigbee/cluster_handlers/security.py | 122 +- zha/zigbee/cluster_handlers/smartenergy.py | 43 +- zha/zigbee/device.py | 272 +- zha/zigbee/endpoint.py | 68 +- zha/zigbee/group.py | 315 +- 101 files changed, 30904 insertions(+), 6170 deletions(-) create mode 100644 .coveragerc create mode 100644 tests/common.py create mode 100644 tests/conftest.py create mode 100644 tests/test_alarm_control_panel.py create mode 100644 tests/test_async_.py create mode 100644 tests/test_binary_sensor.py create mode 100644 tests/test_button.py create mode 100644 tests/test_climate.py create mode 100644 tests/test_cluster_handlers.py create mode 100644 tests/test_color.py create mode 100644 tests/test_cover.py create mode 100644 tests/test_debouncer.py create mode 100644 tests/test_device.py create mode 100644 tests/test_device_tracker.py create mode 100644 tests/test_discover.py create mode 100644 tests/test_fan.py create mode 100644 tests/test_gateway.py create mode 100644 tests/test_light.py create mode 100644 tests/test_lock.py create mode 100644 tests/test_number.py create mode 100644 tests/test_registries.py create mode 100644 tests/test_select.py create mode 100644 tests/test_sensor.py create mode 100644 tests/test_siren.py create mode 100644 tests/test_switch.py create mode 100644 tests/test_units.py create mode 100644 tests/test_update.py create mode 100644 tests/zha_devices_list.py delete mode 100644 zha/application/decorators.py delete mode 100644 zha/application/entity.py delete mode 100644 zha/application/platforms/alarm_control_panel.py create mode 100644 zha/application/platforms/alarm_control_panel/__init__.py create mode 100644 zha/application/platforms/alarm_control_panel/const.py rename zha/application/platforms/{binary_sensor.py => binary_sensor/__init__.py} (65%) create mode 100644 zha/application/platforms/binary_sensor/const.py rename zha/application/platforms/{button.py => button/__init__.py} (55%) create mode 100644 zha/application/platforms/button/const.py delete mode 100644 zha/application/platforms/climate.py create mode 100644 zha/application/platforms/climate/__init__.py create mode 100644 zha/application/platforms/climate/const.py rename zha/application/platforms/{cover.py => cover/__init__.py} (64%) create mode 100644 zha/application/platforms/cover/const.py delete mode 100644 zha/application/platforms/fan.py create mode 100644 zha/application/platforms/fan/__init__.py create mode 100644 zha/application/platforms/fan/const.py create mode 100644 zha/application/platforms/fan/helpers.py create mode 100644 zha/application/platforms/helpers.py rename zha/application/platforms/{light.py => light/__init__.py} (60%) create mode 100644 zha/application/platforms/light/const.py create mode 100644 zha/application/platforms/light/helpers.py delete mode 100644 zha/application/platforms/lock.py create mode 100644 zha/application/platforms/lock/__init__.py create mode 100644 zha/application/platforms/lock/const.py rename zha/application/platforms/{number.py => number/__init__.py} (61%) create mode 100644 zha/application/platforms/number/const.py rename zha/application/platforms/{sensor.py => sensor/__init__.py} (77%) create mode 100644 zha/application/platforms/sensor/const.py create mode 100644 zha/async_.py create mode 100644 zha/const.py create mode 100644 zha/debounce.py create mode 100644 zha/decorators.py create mode 100644 zha/event.py create mode 100644 zha/exceptions.py create mode 100644 zha/mixins.py create mode 100644 zha/units.py create mode 100644 zha/zigbee/cluster_handlers/const.py create mode 100644 zha/zigbee/cluster_handlers/registries.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..0571d364 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[report] +show_missing = True +exclude_also = + if TYPE_CHECKING: \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34ab949b..1c9994bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,8 +12,8 @@ jobs: uses: zigpy/workflows/.github/workflows/ci.yml@main with: CODE_FOLDER: zha - CACHE_VERSION: 1 + CACHE_VERSION: 2 PYTHON_VERSION_DEFAULT: 3.12 PRE_COMMIT_CACHE_PATH: ~/.cache/pre-commit - MINIMUM_COVERAGE_PERCENTAGE: 100 + MINIMUM_COVERAGE_PERCENTAGE: 92 PYTHON_MATRIX: "3.12" diff --git a/.gitignore b/.gitignore index e05e2e7d..9c99b41d 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,6 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ -cover/ # Translations *.mo diff --git a/pyproject.toml b/pyproject.toml index 73234a7e..4fd88a41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,19 @@ readme = "README.md" license = {text = "GPL-3.0"} requires-python = ">=3.12" dependencies = [ + "bellows==0.38.1", + "pyserial==3.5", + "pyserial-asyncio==0.6", + "zha-quirks==0.0.112", + "zigpy-deconz==0.23.1", "zigpy>=0.63.5", + "zigpy-xbee==0.20.1", + "zigpy-zigate==0.12.0", + "zigpy-znp==0.12.1", + "universal-silabs-flasher==0.0.18", + "pyserial-asyncio-fast==0.11", + "python-slugify==8.0.4", + "awesomeversion==24.2.0", ] [tool.setuptools.packages.find] @@ -28,6 +40,9 @@ testing = [ [tool.setuptools-git-versioning] enabled = true +[tool.codespell] +ignore-words-list = "hass" + [tool.mypy] python_version = "3.12" check_untyped_defs = true @@ -68,307 +83,17 @@ disable_error_code = [ "var-annotated", ] -[tool.pylint.BASIC] -class-const-naming-style = "any" - -[tool.pylint."MESSAGES CONTROL"] -# Reasons disabled: -# format - handled by ruff -# locally-disabled - it spams too much -# duplicate-code - unavoidable -# cyclic-import - doesn't test if both import on load -# abstract-class-little-used - prevents from setting right foundation -# unused-argument - generic callbacks and setup methods create a lot of warnings -# too-many-* - are not enforced for the sake of readability -# too-few-* - same as too-many-* -# abstract-method - with intro of async there are always methods missing -# inconsistent-return-statements - doesn't handle raise -# too-many-ancestors - it's too strict. -# wrong-import-order - isort guards this -# consider-using-f-string - str.format sometimes more readable -# --- -# Pylint CodeStyle plugin -# consider-using-namedtuple-or-dataclass - too opinionated -# consider-using-assignment-expr - decision to use := better left to devs -disable = [ - "format", - "abstract-method", - "cyclic-import", - "duplicate-code", - "inconsistent-return-statements", - "locally-disabled", - "not-context-manager", - "too-few-public-methods", - "too-many-ancestors", - "too-many-arguments", - "too-many-instance-attributes", - "too-many-lines", - "too-many-locals", - "too-many-public-methods", - "too-many-boolean-expressions", - "wrong-import-order", - "consider-using-f-string", - "consider-using-namedtuple-or-dataclass", - "consider-using-assignment-expr", - - # Handled by ruff - # Ref: - "await-outside-async", # PLE1142 - "bad-str-strip-call", # PLE1310 - "bad-string-format-type", # PLE1307 - "bidirectional-unicode", # PLE2502 - "continue-in-finally", # PLE0116 - "duplicate-bases", # PLE0241 - "format-needs-mapping", # F502 - "function-redefined", # F811 - # Needed because ruff does not understand type of __all__ generated by a function - # "invalid-all-format", # PLE0605 - "invalid-all-object", # PLE0604 - "invalid-character-backspace", # PLE2510 - "invalid-character-esc", # PLE2513 - "invalid-character-nul", # PLE2514 - "invalid-character-sub", # PLE2512 - "invalid-character-zero-width-space", # PLE2515 - "logging-too-few-args", # PLE1206 - "logging-too-many-args", # PLE1205 - "missing-format-string-key", # F524 - "mixed-format-string", # F506 - "no-method-argument", # N805 - "no-self-argument", # N805 - "nonexistent-operator", # B002 - "nonlocal-without-binding", # PLE0117 - "not-in-loop", # F701, F702 - "notimplemented-raised", # F901 - "return-in-init", # PLE0101 - "return-outside-function", # F706 - "syntax-error", # E999 - "too-few-format-args", # F524 - "too-many-format-args", # F522 - "too-many-star-expressions", # F622 - "truncated-format-string", # F501 - "undefined-all-variable", # F822 - "undefined-variable", # F821 - "used-prior-global-declaration", # PLE0118 - "yield-inside-async-function", # PLE1700 - "yield-outside-function", # F704 - "anomalous-backslash-in-string", # W605 - "assert-on-string-literal", # PLW0129 - "assert-on-tuple", # F631 - "bad-format-string", # W1302, F - "bad-format-string-key", # W1300, F - "bare-except", # E722 - "binary-op-exception", # PLW0711 - "cell-var-from-loop", # B023 - # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work - "duplicate-except", # B014 - "duplicate-key", # F601 - "duplicate-string-formatting-argument", # F - "duplicate-value", # F - "eval-used", # S307 - "exec-used", # S102 - # "expression-not-assigned", # B018, ruff catches new occurrences, needs more work - "f-string-without-interpolation", # F541 - "forgotten-debug-statement", # T100 - "format-string-without-interpolation", # F - # "global-statement", # PLW0603, ruff catches new occurrences, needs more work - "global-variable-not-assigned", # PLW0602 - "implicit-str-concat", # ISC001 - "import-self", # PLW0406 - "inconsistent-quotes", # Q000 - "invalid-envvar-default", # PLW1508 - "keyword-arg-before-vararg", # B026 - "logging-format-interpolation", # G - "logging-fstring-interpolation", # G - "logging-not-lazy", # G - "misplaced-future", # F404 - "named-expr-without-context", # PLW0131 - "nested-min-max", # PLW3301 - # "pointless-statement", # B018, ruff catches new occurrences, needs more work - "raise-missing-from", # B904 - # "redefined-builtin", # A001, ruff is way more stricter, needs work - "try-except-raise", # TRY302 - "unused-argument", # ARG001, we don't use it - "unused-format-string-argument", #F507 - "unused-format-string-key", # F504 - "unused-import", # F401 - "unused-variable", # F841 - "useless-else-on-loop", # PLW0120 - "wildcard-import", # F403 - "bad-classmethod-argument", # N804 - "consider-iterating-dictionary", # SIM118 - "empty-docstring", # D419 - "invalid-name", # N815 - "line-too-long", # E501, disabled globally - "missing-class-docstring", # D101 - "missing-final-newline", # W292 - "missing-function-docstring", # D103 - "missing-module-docstring", # D100 - "multiple-imports", #E401 - "singleton-comparison", # E711, E712 - "subprocess-run-check", # PLW1510 - "superfluous-parens", # UP034 - "ungrouped-imports", # I001 - "unidiomatic-typecheck", # E721 - "unnecessary-direct-lambda-call", # PLC3002 - "unnecessary-lambda-assignment", # PLC3001 - "unneeded-not", # SIM208 - "useless-import-alias", # PLC0414 - "wrong-import-order", # I001 - "wrong-import-position", # E402 - "comparison-of-constants", # PLR0133 - "comparison-with-itself", # PLR0124 - "consider-alternative-union-syntax", # UP007 - "consider-merging-isinstance", # PLR1701 - "consider-using-alias", # UP006 - "consider-using-dict-comprehension", # C402 - "consider-using-generator", # C417 - "consider-using-get", # SIM401 - "consider-using-set-comprehension", # C401 - "consider-using-sys-exit", # PLR1722 - "consider-using-ternary", # SIM108 - "literal-comparison", # F632 - "property-with-parameters", # PLR0206 - "super-with-arguments", # UP008 - "too-many-branches", # PLR0912 - "too-many-return-statements", # PLR0911 - "too-many-statements", # PLR0915 - "trailing-comma-tuple", # COM818 - "unnecessary-comprehension", # C416 - "use-a-generator", # C417 - "use-dict-literal", # C406 - "use-list-literal", # C405 - "useless-object-inheritance", # UP004 - "useless-return", # PLR1711 - # "no-self-use", # PLR6301 # Optional plugin, not enabled - - # Handled by mypy - # Ref: - "abstract-class-instantiated", - "arguments-differ", - "assigning-non-slot", - "assignment-from-no-return", - "assignment-from-none", - "bad-exception-cause", - "bad-format-character", - "bad-reversed-sequence", - "bad-super-call", - "bad-thread-instantiation", - "catching-non-exception", - "comparison-with-callable", - "deprecated-class", - "dict-iter-missing-items", - "format-combined-specification", - "global-variable-undefined", - "import-error", - "inconsistent-mro", - "inherit-non-class", - "init-is-generator", - "invalid-class-object", - "invalid-enum-extension", - "invalid-envvar-value", - "invalid-format-returned", - "invalid-hash-returned", - "invalid-metaclass", - "invalid-overridden-method", - "invalid-repr-returned", - "invalid-sequence-index", - "invalid-slice-index", - "invalid-slots-object", - "invalid-slots", - "invalid-star-assignment-target", - "invalid-str-returned", - "invalid-unary-operand-type", - "invalid-unicode-codec", - "isinstance-second-argument-not-valid-type", - "method-hidden", - "misplaced-format-function", - "missing-format-argument-key", - "missing-format-attribute", - "missing-kwoa", - "no-member", - "no-value-for-parameter", - "non-iterator-returned", - "non-str-assignment-to-dunder-name", - "nonlocal-and-global", - "not-a-mapping", - "not-an-iterable", - "not-async-context-manager", - "not-callable", - "not-context-manager", - "overridden-final-method", - "raising-bad-type", - "raising-non-exception", - "redundant-keyword-arg", - "relative-beyond-top-level", - "self-cls-assignment", - "signature-differs", - "star-needs-assignment-target", - "subclassed-final-class", - "super-without-brackets", - "too-many-function-args", - "typevar-double-variance", - "typevar-name-mismatch", - "unbalanced-dict-unpacking", - "unbalanced-tuple-unpacking", - "unexpected-keyword-arg", - "unhashable-member", - "unpacking-non-sequence", - "unsubscriptable-object", - "unsupported-assignment-operation", - "unsupported-binary-operation", - "unsupported-delete-operation", - "unsupported-membership-test", - "used-before-assignment", - "using-final-decorator-in-unsupported-version", - "wrong-exception-operation", -] -enable = [ - #"useless-suppression", # temporarily every now and then to clean them up - "use-symbolic-message-instead", -] -per-file-ignores = [ - # hass-component-root-import: Tests test non-public APIs - # protected-access: Tests do often test internals a lot - # redefined-outer-name: Tests reference fixtures in the test function - "/tests/:hass-component-root-import,protected-access,redefined-outer-name", -] - -[tool.pylint.REPORTS] -score = false - -[tool.pylint.TYPECHECK] -ignored-classes = [ - "_CountingAttr", # for attrs -] -mixin-class-rgx = ".*[Mm]ix[Ii]n" - -[tool.pylint.FORMAT] -expected-line-ending-format = "LF" +[tool.pylint] max-line-length = 120 - -[tool.pylint.EXCEPTIONS] -overgeneral-exceptions = [ - "builtins.BaseException", - "builtins.Exception", -] - -[tool.pylint.TYPING] -runtime-typing = false - -[tool.pylint.CODE_STYLE] -max-line-length-suggestions = 72 +disable = ["C0103", "W0212"] [tool.pytest.ini_options] -testpaths = [ - "tests", -] -norecursedirs = [ - ".git", - "testing_config", -] -log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" -log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" +testpaths = "tests" +norecursedirs = ".git" + +[tool.ruff] +target-version = "py312" [tool.ruff.lint] select = [ @@ -488,9 +213,9 @@ ignore = [ [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = false -[tool.ruff.lint.flake8-tidy-imports.banned-api] -"async_timeout".msg = "use asyncio.timeout instead" -"pytz".msg = "use zoneinfo instead" +[tool.ruff.lint.flake8-tidy-imports] +# Disallow all relative imports. +ban-relative-imports = "all" [tool.ruff.lint.isort] force-sort-within-sections = true diff --git a/requirements_test.txt b/requirements_test.txt index 8b3e562f..8b6f2955 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,6 +7,8 @@ pytest-cov pytest-sugar pytest-timeout pytest-asyncio +pytest-xdist pytest zigpy>=0.63.5 ruff +looptime diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 00000000..e966cc86 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,257 @@ +"""Common test objects.""" + +import asyncio +from collections.abc import Awaitable +import logging +from typing import Any, Optional +from unittest.mock import AsyncMock, Mock + +from slugify import slugify +import zigpy.types as t +import zigpy.zcl +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.zigbee.device import Device +from zha.zigbee.group import Group + +_LOGGER = logging.getLogger(__name__) + + +def patch_cluster(cluster: zigpy.zcl.Cluster) -> None: + """Patch a cluster for testing.""" + cluster.PLUGGED_ATTR_READS = {} + + async def _read_attribute_raw(attributes: Any, *args: Any, **kwargs: Any) -> Any: + result = [] + for attr_id in attributes: + value = cluster.PLUGGED_ATTR_READS.get(attr_id) + if value is None: + # try converting attr_id to attr_name and lookup the plugs again + attr = cluster.attributes.get(attr_id) + if attr is not None: + value = cluster.PLUGGED_ATTR_READS.get(attr.name) + if value is not None: + result.append( + zcl_f.ReadAttributeRecord( + attr_id, + zcl_f.Status.SUCCESS, + zcl_f.TypeValue(type=None, value=value), + ) + ) + else: + result.append(zcl_f.ReadAttributeRecord(attr_id, zcl_f.Status.FAILURE)) + return (result,) + + cluster.bind = AsyncMock(return_value=[0]) + cluster.configure_reporting = AsyncMock( + return_value=[ + [zcl_f.ConfigureReportingResponseRecord(zcl_f.Status.SUCCESS, 0x00, 0xAABB)] + ] + ) + cluster.configure_reporting_multiple = AsyncMock( + return_value=zcl_f.ConfigureReportingResponse.deserialize(b"\x00")[0] + ) + cluster.handle_cluster_request = Mock() + cluster.read_attributes = AsyncMock(wraps=cluster.read_attributes) + cluster.read_attributes_raw = AsyncMock(side_effect=_read_attribute_raw) + cluster.unbind = AsyncMock(return_value=[0]) + cluster.write_attributes = AsyncMock(wraps=cluster.write_attributes) + cluster._write_attributes = AsyncMock( + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]] + ) + if cluster.cluster_id == 4: + cluster.add = AsyncMock(return_value=[0]) + if cluster.cluster_id == 0x1000: + get_group_identifiers_rsp = ( + zigpy.zcl.clusters.lightlink.LightLink.commands_by_name[ + "get_group_identifiers_rsp" + ].schema + ) + cluster.get_group_identifiers = AsyncMock( + return_value=get_group_identifiers_rsp( + total=0, start_index=0, group_info_records=[] + ) + ) + if cluster.cluster_id == 0xFC45: + cluster.attributes = { + # Relative Humidity Measurement Information + 0x0000: zcl_f.ZCLAttributeDef( + id=0x0000, name="measured_value", type=t.uint16_t + ) + } + cluster.attributes_by_name = { + "measured_value": zcl_f.ZCLAttributeDef( + id=0x0000, name="measured_value", type=t.uint16_t + ) + } + + +def update_attribute_cache(cluster: zigpy.zcl.Cluster) -> None: + """Update attribute cache based on plugged attributes.""" + if not cluster.PLUGGED_ATTR_READS: + return + + attrs = [] + for attrid, value in cluster.PLUGGED_ATTR_READS.items(): + if isinstance(attrid, str): + attrid = cluster.attributes_by_name[attrid].id + else: + attrid = zigpy.types.uint16_t(attrid) + attrs.append(make_attribute(attrid, value)) + + hdr = make_zcl_header(zcl_f.GeneralCommand.Report_Attributes) + hdr.frame_control.disable_default_response = True + msg = zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Report_Attributes].schema( + attribute_reports=attrs + ) + cluster.handle_message(hdr, msg) + + +def make_attribute(attrid: int, value: Any, status: int = 0) -> zcl_f.Attribute: + """Make an attribute.""" + attr = zcl_f.Attribute() + attr.attrid = attrid + attr.value = zcl_f.TypeValue() + attr.value.value = value + return attr + + +async def send_attributes_report( + zha_gateway: Gateway, cluster: zigpy.zcl.Cluster, attributes: dict +) -> None: + """Cause the sensor to receive an attribute report from the network. + + This is to simulate the normal device communication that happens when a + device is paired to the zigbee network. + """ + attrs = [] + + for attrid, value in attributes.items(): + if isinstance(attrid, str): + attrid = cluster.attributes_by_name[attrid].id + else: + attrid = zigpy.types.uint16_t(attrid) + + attrs.append(make_attribute(attrid, value)) + + msg = zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Report_Attributes].schema( + attribute_reports=attrs + ) + + hdr = make_zcl_header(zcl_f.GeneralCommand.Report_Attributes) + hdr.frame_control.disable_default_response = True + cluster.handle_message(hdr, msg) + await zha_gateway.async_block_till_done() + + +def make_zcl_header( + command_id: int, global_command: bool = True, tsn: int = 1 +) -> zcl_f.ZCLHeader: + """Cluster.handle_message() ZCL Header helper.""" + if global_command: + frc = zcl_f.FrameControl(zcl_f.FrameType.GLOBAL_COMMAND) + else: + frc = zcl_f.FrameControl(zcl_f.FrameType.CLUSTER_COMMAND) + return zcl_f.ZCLHeader(frc, tsn=tsn, command_id=command_id) + + +def reset_clusters(clusters: list[zigpy.zcl.Cluster]) -> None: + """Reset mocks on cluster.""" + for cluster in clusters: + cluster.bind.reset_mock() + cluster.configure_reporting.reset_mock() + cluster.configure_reporting_multiple.reset_mock() + cluster.write_attributes.reset_mock() + + +def find_entity(device: Device, platform: Platform) -> Optional[PlatformEntity]: + """Find an entity for the specified platform on the given device.""" + for entity in device.platform_entities.values(): + if platform == entity.PLATFORM: + return entity + return None + + +def mock_coro( + return_value: Any = None, exception: Optional[Exception] = None +) -> Awaitable: + """Return a coro that returns a value or raise an exception.""" + fut: asyncio.Future = asyncio.Future() + if exception is not None: + fut.set_exception(exception) + else: + fut.set_result(return_value) + return fut + + +def find_entity_id( + domain: str, zha_device: Device, qualifier: Optional[str] = None +) -> Optional[str]: + """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 = find_entity_ids(domain, zha_device) + if not entities: + return None + if qualifier: + for entity_id in entities: + if qualifier in entity_id: + return entity_id + return None + else: + return entities[0] + + +def find_entity_ids( + domain: str, zha_device: Device, omit: Optional[list[str]] = None +) -> list[str]: + """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. + """ + ieeetail = "".join([f"{o:02x}" for o in zha_device.ieee[:4]]) + head = f"{domain}.{slugify(f'{zha_device.name} {ieeetail}', separator='_')}" + + entity_ids = [ + f"{entity.PLATFORM}.{slugify(entity.name, separator='_')}" + for entity in zha_device.platform_entities.values() + ] + + matches = [] + res = [] + for entity_id in entity_ids: + if entity_id.startswith(head): + matches.append(entity_id) + + if omit: + for entity_id in matches: + skip = False + for o in omit: + if o in entity_id: + skip = True + break + if not skip: + res.append(entity_id) + else: + res = matches + return res + + +def async_find_group_entity_id(domain: str, group: Group) -> Optional[str]: + """Find the group entity id under test.""" + entity_id = f"{domain}.{group.name.lower().replace(' ','_')}_0x{group.group_id:04x}" + + entity_ids = [ + f"{entity.PLATFORM}.{slugify(entity.name, separator='_')}" + for entity in group.group_entities.values() + ] + + if entity_id in entity_ids: + return entity_id + return None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..00d1e88e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,378 @@ +"""Test configuration for the ZHA component.""" + +import asyncio +from collections.abc import Callable +import itertools +import logging +import time +from types import TracebackType +from typing import Any, Optional +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +import warnings + +import pytest +import zigpy +from zigpy.application import ControllerApplication +import zigpy.config +from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +import zigpy.device +import zigpy.group +import zigpy.profiles +from zigpy.quirks import get_device +import zigpy.types +from zigpy.zcl.clusters.general import Basic, Groups +from zigpy.zcl.foundation import Status +import zigpy.zdo.types as zdo_t + +from tests import common +from zha.application.const import ( + CONF_ALARM_ARM_REQUIRES_CODE, + CONF_ALARM_FAILED_TRIES, + CONF_ALARM_MASTER_CODE, + CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, + CONF_GROUP_MEMBERS_ASSUME_STATE, + CONF_RADIO_TYPE, + CUSTOM_CONFIGURATION, + ZHA_ALARM_OPTIONS, + ZHA_OPTIONS, +) +from zha.application.gateway import Gateway +from zha.application.helpers import ZHAData +from zha.zigbee.device import Device + +FIXTURE_GRP_ID = 0x1001 +FIXTURE_GRP_NAME = "fixture group" +COUNTER_NAMES = ["counter_1", "counter_2", "counter_3"] +_LOGGER = logging.getLogger(__name__) + + +class _FakeApp(ControllerApplication): + async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor): + pass + + async def connect(self): + pass + + async def disconnect(self): + pass + + async def force_remove(self, dev: zigpy.device.Device): + pass + + async def load_network_info(self, *, load_devices: bool = False): + pass + + async def permit_ncp(self, time_s: int = 60): + pass + + async def permit_with_link_key( + self, node: zigpy.types.EUI64, link_key: zigpy.types.KeyData, time_s: int = 60 + ): + pass + + async def reset_network_info(self): + pass + + async def send_packet(self, packet: zigpy.types.ZigbeePacket): + pass + + async def start_network(self): + pass + + async def write_network_info( + self, *, network_info: zigpy.state.NetworkInfo, node_info: zigpy.state.NodeInfo + ) -> None: + pass + + async def request( + self, + device: zigpy.device.Device, + profile: zigpy.types.uint16_t, + cluster: zigpy.types.uint16_t, + src_ep: zigpy.types.uint8_t, + dst_ep: zigpy.types.uint8_t, + sequence: zigpy.types.uint8_t, + data: bytes, + *, + expect_reply: bool = True, + use_ieee: bool = False, + extended_timeout: bool = False, + ): + pass + + async def move_network_to_channel( + self, new_channel: int, *, num_broadcasts: int = 5 + ) -> None: + pass + + +def _wrap_mock_instance(obj: Any) -> MagicMock: + """Auto-mock every attribute and method in an object.""" + mock = create_autospec(obj, spec_set=True, instance=True) + + for attr_name in dir(obj): + if attr_name.startswith("__") and attr_name not in {"__getitem__"}: + continue + + real_attr = getattr(obj, attr_name) + mock_attr = getattr(mock, attr_name) + + if callable(real_attr) and not hasattr(real_attr, "__aenter__"): + mock_attr.side_effect = real_attr + else: + setattr(mock, attr_name, real_attr) + + return mock + + +@pytest.fixture +async def zigpy_app_controller(): + """Zigpy ApplicationController fixture.""" + app = _FakeApp( + { + zigpy.config.CONF_DATABASE: None, + zigpy.config.CONF_DEVICE: {zigpy.config.CONF_DEVICE_PATH: "/dev/null"}, + zigpy.config.CONF_STARTUP_ENERGY_SCAN: False, + zigpy.config.CONF_NWK_BACKUP_ENABLED: False, + zigpy.config.CONF_TOPO_SCAN_ENABLED: False, + zigpy.config.CONF_OTA: { + zigpy.config.CONF_OTA_ENABLED: False, + }, + } + ) + + app.groups.add_group(FIXTURE_GRP_ID, FIXTURE_GRP_NAME, suppress_event=True) + + app.state.node_info.nwk = 0x0000 + app.state.node_info.ieee = zigpy.types.EUI64.convert("00:15:8d:00:02:32:4f:32") + app.state.network_info.pan_id = 0x1234 + app.state.network_info.extended_pan_id = app.state.node_info.ieee + app.state.network_info.channel = 15 + app.state.network_info.network_key.key = zigpy.types.KeyData(range(16)) + app.state.counters = zigpy.state.CounterGroups() + app.state.counters["ezsp_counters"] = zigpy.state.CounterGroup("ezsp_counters") + for name in COUNTER_NAMES: + app.state.counters["ezsp_counters"][name].increment() + + # Create a fake coordinator device + dev = app.add_device(nwk=app.state.node_info.nwk, ieee=app.state.node_info.ieee) + dev.node_desc = zdo_t.NodeDescriptor() + dev.node_desc.logical_type = zdo_t.LogicalType.Coordinator + dev.manufacturer = "Coordinator Manufacturer" + dev.model = "Coordinator Model" + + ep = dev.add_endpoint(1) + ep.add_input_cluster(Basic.cluster_id) + ep.add_input_cluster(Groups.cluster_id) + + with patch("zigpy.device.Device.request", return_value=[Status.SUCCESS]): + # The mock wrapping accesses deprecated attributes, so we suppress the warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_app = _wrap_mock_instance(app) + mock_app.backups = _wrap_mock_instance(app.backups) + + yield mock_app + + +@pytest.fixture(name="caplog") +def caplog_fixture(caplog: pytest.LogCaptureFixture) -> pytest.LogCaptureFixture: + """Set log level to debug for tests using the caplog fixture.""" + caplog.set_level(logging.DEBUG) + return caplog + + +@pytest.fixture +def zha_data() -> ZHAData: + """Fixture representing zha configuration data.""" + return ZHAData( + yaml_config={}, + config_entry_data={ + "data": { + zigpy.config.CONF_DEVICE: { + zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB0" + }, + CONF_RADIO_TYPE: "ezsp", + }, + "options": { + CUSTOM_CONFIGURATION: { + ZHA_OPTIONS: { + CONF_ENABLE_ENHANCED_LIGHT_TRANSITION: True, + CONF_GROUP_MEMBERS_ASSUME_STATE: False, + }, + ZHA_ALARM_OPTIONS: { + CONF_ALARM_ARM_REQUIRES_CODE: False, + CONF_ALARM_MASTER_CODE: "4321", + CONF_ALARM_FAILED_TRIES: 2, + }, + } + }, + }, + ) + + +class TestGateway: + """Test ZHA gateway context manager.""" + + def __init__(self, data: ZHAData): + """Initialize the ZHA gateway.""" + self.zha_data: ZHAData = data + self.zha_gateway: Gateway + + async def __aenter__(self) -> Gateway: + """Start the ZHA gateway.""" + self.zha_gateway = await Gateway.async_from_config(self.zha_data) + await self.zha_gateway.async_block_till_done() + await self.zha_gateway.async_initialize_devices_and_entities() + return self.zha_gateway + + async def __aexit__( + self, exc_type: Exception, exc_value: str, traceback: TracebackType + ) -> None: + """Shutdown the ZHA gateway.""" + await self.zha_gateway.shutdown() + await asyncio.sleep(0) + + +@pytest.fixture +async def zha_gateway( + zha_data: ZHAData, # pylint: disable=redefined-outer-name + zigpy_app_controller, # pylint: disable=redefined-outer-name + caplog, # pylint: disable=unused-argument +): + """Set up ZHA component.""" + + with ( + patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ), + patch( + "bellows.zigbee.application.ControllerApplication", + return_value=zigpy_app_controller, + ), + ): + async with TestGateway(zha_data) as gateway: + yield gateway + + +@pytest.fixture(scope="session", autouse=True) +def disable_request_retry_delay(): + """Disable ZHA request retrying delay to speed up failures.""" + + with patch( + "zha.zigbee.cluster_handlers.RETRYABLE_REQUEST_DECORATOR", + zigpy.util.retryable_request(tries=3, delay=0), + ): + yield + + +@pytest.fixture(scope="session", autouse=True) +def globally_load_quirks(): + """Load quirks automatically so that ZHA tests run deterministically in isolation. + + If portions of the ZHA test suite that do not happen to load quirks are run + independently, bugs can emerge that will show up only when more of the test suite is + run. + """ + + import zhaquirks # pylint: disable=import-outside-toplevel + + zhaquirks.setup() + + +@pytest.fixture +def device_joined( + zha_gateway: Gateway, # pylint: disable=redefined-outer-name +) -> Callable[[zigpy.device.Device], Device]: + """Return a newly joined ZHAWS device.""" + + async def _zha_device(zigpy_dev: zigpy.device.Device) -> Device: + await zha_gateway.async_device_initialized(zigpy_dev) + await zha_gateway.async_block_till_done() + return zha_gateway.get_device(zigpy_dev.ieee) + + return _zha_device + + +@pytest.fixture +def cluster_handler() -> Callable: + """Clueter handler mock factory fixture.""" + + def cluster_handler_factory( + name: str, cluster_id: int, endpoint_id: int = 1 + ) -> MagicMock: + ch = MagicMock() + ch.name = name + ch.generic_id = f"cluster_handler_0x{cluster_id:04x}" + ch.id = f"{endpoint_id}:0x{cluster_id:04x}" + ch.async_configure = AsyncMock() + ch.async_initialize = AsyncMock() + return ch + + return cluster_handler_factory + + +@pytest.fixture +def zigpy_device_mock( + zigpy_app_controller: ControllerApplication, # pylint: disable=redefined-outer-name +) -> Callable[..., zigpy.device.Device]: + """Make a fake device using the specified cluster classes.""" + + def _mock_dev( + endpoints: dict[int, dict[str, Any]], + ieee: str = "00:0d:6f:00:0a:90:69:e7", + manufacturer: str = "FakeManufacturer", + model: str = "FakeModel", + node_descriptor: bytes = b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + nwk: int = 0xB79C, + patch_cluster: bool = True, + quirk: Optional[Callable] = None, + attributes: dict[int, dict[str, dict[str, Any]]] = None, + ) -> zigpy.device.Device: + """Make a fake device using the specified cluster classes.""" + device = zigpy.device.Device( + zigpy_app_controller, zigpy.types.EUI64.convert(ieee), nwk + ) + device.manufacturer = manufacturer + device.model = model + device.node_desc = zdo_t.NodeDescriptor.deserialize(node_descriptor)[0] + device.last_seen = time.time() + + for epid, ep in endpoints.items(): + endpoint = device.add_endpoint(epid) + endpoint.device_type = ep[SIG_EP_TYPE] + endpoint.profile_id = ep.get(SIG_EP_PROFILE) + endpoint.request = AsyncMock(return_value=[0]) + + for cluster_id in ep.get(SIG_EP_INPUT, []): + endpoint.add_input_cluster(cluster_id) + + for cluster_id in ep.get(SIG_EP_OUTPUT, []): + endpoint.add_output_cluster(cluster_id) + + if quirk: + device = quirk(zigpy_app_controller, device.ieee, device.nwk, device) + else: + device = get_device(device) + + if patch_cluster: + for endpoint in (ep for epid, ep in device.endpoints.items() if epid): + endpoint.request = AsyncMock(return_value=[0]) + for cluster in itertools.chain( + endpoint.in_clusters.values(), endpoint.out_clusters.values() + ): + common.patch_cluster(cluster) + + if attributes is not None: + for ep_id, clusters in attributes.items(): + for cluster_name, attrs in clusters.items(): + cluster = getattr(device.endpoints[ep_id], cluster_name) + + for name, value in attrs.items(): + attr_id = cluster.find_attribute(name).id + cluster._attr_cache[attr_id] = value + + return device + + return _mock_dev diff --git a/tests/test_alarm_control_panel.py b/tests/test_alarm_control_panel.py new file mode 100644 index 00000000..9e733f60 --- /dev/null +++ b/tests/test_alarm_control_panel.py @@ -0,0 +1,239 @@ +"""Test zha alarm control panel.""" + +from collections.abc import Awaitable, Callable +import logging +from unittest.mock import AsyncMock, call, patch, sentinel + +import pytest +from zigpy.device import Device as ZigpyDevice +from zigpy.profiles import zha +from zigpy.zcl.clusters import security +import zigpy.zcl.foundation as zcl_f + +from zha.application.gateway import Gateway +from zha.application.platforms.alarm_control_panel import AlarmControlPanel +from zha.zigbee.device import Device + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +def zigpy_device(zigpy_device_mock: Callable[..., ZigpyDevice]) -> ZigpyDevice: + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00" + ) + + +@patch( + "zigpy.zcl.clusters.security.IasAce.client_command", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_alarm_control_panel( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zhaws alarm control panel platform.""" + zha_device: Device = await device_joined(zigpy_device) + cluster: security.IasAce = zigpy_device.endpoints.get(1).ias_ace + alarm_entity: AlarmControlPanel = zha_device.platform_entities.get( + "00:0d:6f:00:0a:90:69:e7-1" + ) + assert alarm_entity is not None + assert isinstance(alarm_entity, AlarmControlPanel) + + # test that the state is STATE_ALARM_DISARMED + assert alarm_entity.state == "disarmed" + + # arm_away + cluster.client_command.reset_mock() + await alarm_entity.async_alarm_arm_away("4321") + await zha_gateway.async_block_till_done() + assert cluster.client_command.call_count == 2 + assert cluster.client_command.await_count == 2 + assert cluster.client_command.call_args == call( + 4, + security.IasAce.PanelStatus.Armed_Away, + 0, + security.IasAce.AudibleNotification.Default_Sound, + security.IasAce.AlarmStatus.No_Alarm, + ) + assert alarm_entity.state == "armed_away" + + # disarm + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # trip alarm from faulty code entry. First we need to arm away + cluster.client_command.reset_mock() + await alarm_entity.async_alarm_arm_away("4321") + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_away" + cluster.client_command.reset_mock() + + # now simulate a faulty code entry sequence + await alarm_entity.async_alarm_disarm("0000") + await alarm_entity.async_alarm_disarm("0000") + await alarm_entity.async_alarm_disarm("0000") + await zha_gateway.async_block_till_done() + + assert alarm_entity.state == "triggered" + assert cluster.client_command.call_count == 6 + assert cluster.client_command.await_count == 6 + assert cluster.client_command.call_args == call( + 4, + security.IasAce.PanelStatus.In_Alarm, + 0, + security.IasAce.AudibleNotification.Default_Sound, + security.IasAce.AlarmStatus.Emergency, + ) + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # arm_home + await alarm_entity.async_alarm_arm_home("4321") + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_home" + assert cluster.client_command.call_count == 2 + assert cluster.client_command.await_count == 2 + assert cluster.client_command.call_args == call( + 4, + security.IasAce.PanelStatus.Armed_Stay, + 0, + security.IasAce.AudibleNotification.Default_Sound, + security.IasAce.AlarmStatus.No_Alarm, + ) + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # arm_night + await alarm_entity.async_alarm_arm_night("4321") + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_night" + assert cluster.client_command.call_count == 2 + assert cluster.client_command.await_count == 2 + assert cluster.client_command.call_args == call( + 4, + security.IasAce.PanelStatus.Armed_Night, + 0, + security.IasAce.AudibleNotification.Default_Sound, + security.IasAce.AlarmStatus.No_Alarm, + ) + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # arm from panel + cluster.listener_event( + "cluster_command", 1, 0, [security.IasAce.ArmMode.Arm_All_Zones, "", 0] + ) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_away" + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # arm day home only from panel + cluster.listener_event( + "cluster_command", 1, 0, [security.IasAce.ArmMode.Arm_Day_Home_Only, "", 0] + ) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_home" + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # arm night sleep only from panel + cluster.listener_event( + "cluster_command", 1, 0, [security.IasAce.ArmMode.Arm_Night_Sleep_Only, "", 0] + ) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_night" + + # disarm from panel with bad code + cluster.listener_event( + "cluster_command", 1, 0, [security.IasAce.ArmMode.Disarm, "", 0] + ) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "armed_night" + + # disarm from panel with bad code for 2nd time still armed + cluster.listener_event( + "cluster_command", 1, 0, [security.IasAce.ArmMode.Disarm, "", 0] + ) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "triggered" + + # disarm from panel with good code + cluster.listener_event( + "cluster_command", 1, 0, [security.IasAce.ArmMode.Disarm, "4321", 0] + ) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "disarmed" + + # panic from panel + cluster.listener_event("cluster_command", 1, 4, []) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "triggered" + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # fire from panel + cluster.listener_event("cluster_command", 1, 3, []) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "triggered" + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + + # emergency from panel + cluster.listener_event("cluster_command", 1, 2, []) + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "triggered" + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + assert alarm_entity.state == "disarmed" + + await alarm_entity.async_alarm_trigger() + await zha_gateway.async_block_till_done() + assert alarm_entity.state == "triggered" + + # reset the panel + await reset_alarm_panel(zha_gateway, cluster, alarm_entity) + assert alarm_entity.state == "disarmed" + + +async def reset_alarm_panel( + zha_gateway: Gateway, + cluster: security.IasAce, + entity: AlarmControlPanel, +) -> None: + """Reset the state of the alarm panel.""" + cluster.client_command.reset_mock() + await entity.async_alarm_disarm("4321") + await zha_gateway.async_block_till_done() + assert entity.state == "disarmed" + assert cluster.client_command.call_count == 2 + assert cluster.client_command.await_count == 2 + assert cluster.client_command.call_args == call( + 4, + security.IasAce.PanelStatus.Panel_Disarmed, + 0, + security.IasAce.AudibleNotification.Default_Sound, + security.IasAce.AlarmStatus.No_Alarm, + ) + cluster.client_command.reset_mock() diff --git a/tests/test_async_.py b/tests/test_async_.py new file mode 100644 index 00000000..264856b8 --- /dev/null +++ b/tests/test_async_.py @@ -0,0 +1,655 @@ +"""Tests for the gateway module.""" + +import asyncio +import functools +import time +from unittest.mock import MagicMock, patch + +import pytest + +from zha.application.gateway import Gateway +from zha.async_ import AsyncUtilMixin, ZHAJob, ZHAJobType, create_eager_task +from zha.decorators import callback + + +async def test_zhajob_forbid_coroutine() -> None: + """Test zhajob forbids coroutines.""" + + async def bla(): + pass + + coro = bla() + + with pytest.raises(ValueError): + _ = ZHAJob(coro).job_type + + # To avoid warning about unawaited coro + await coro + + +@pytest.mark.parametrize("eager_start", [True, False]) +async def test_cancellable_zhajob(zha_gateway: Gateway, eager_start: bool) -> None: + """Simulate a shutdown, ensure cancellable jobs are cancelled.""" + job = MagicMock() + + @callback + def run_job(job: ZHAJob) -> None: + """Call the action.""" + zha_gateway.async_run_zha_job(job, eager_start=eager_start) + + timer1 = zha_gateway.loop.call_later( + 60, run_job, ZHAJob(callback(job), cancel_on_shutdown=True) + ) + timer2 = zha_gateway.loop.call_later(60, run_job, ZHAJob(callback(job))) + + await zha_gateway.shutdown() + + assert timer1.cancelled() + assert not timer2.cancelled() + + # Cleanup + timer2.cancel() + + +async def test_async_add_zha_job_schedule_callback() -> None: + """Test that we schedule callbacks and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + job = MagicMock() + + AsyncUtilMixin.async_add_zha_job(zha_gateway, ZHAJob(callback(job))) + assert len(zha_gateway.loop.call_soon.mock_calls) == 1 + assert len(zha_gateway.loop.create_task.mock_calls) == 0 + assert len(zha_gateway.add_job.mock_calls) == 0 + + +async def test_async_add_zha_job_eager_start_coro_suspends( + zha_gateway: Gateway, +) -> None: + """Test scheduling a coro as a task that will suspend with eager_start.""" + + async def job_that_suspends(): + await asyncio.sleep(0) + + task = zha_gateway.async_add_zha_job( + ZHAJob(callback(job_that_suspends)), eager_start=True + ) + assert not task.done() + assert task in zha_gateway._tracked_completable_tasks + await task + assert task not in zha_gateway._tracked_completable_tasks + + +async def test_async_run_zha_job_eager_start_coro_suspends( + zha_gateway: Gateway, +) -> None: + """Test scheduling a coro as a task that will suspend with eager_start.""" + + async def job_that_suspends(): + await asyncio.sleep(0) + + task = zha_gateway.async_run_zha_job(ZHAJob(callback(job_that_suspends))) + assert not task.done() + assert task in zha_gateway._tracked_completable_tasks + await task + assert task not in zha_gateway._tracked_completable_tasks + + +async def test_async_add_zha_job_background(zha_gateway: Gateway) -> None: + """Test scheduling a coro as a background task with async_add_zha_job.""" + + async def job_that_suspends(): + await asyncio.sleep(0) + + task = zha_gateway.async_add_zha_job( + ZHAJob(callback(job_that_suspends)), background=True + ) + assert not task.done() + assert task in zha_gateway._background_tasks + await task + assert task not in zha_gateway._background_tasks + + +async def test_async_run_zha_job_background(zha_gateway: Gateway) -> None: + """Test scheduling a coro as a background task with async_run_zha_job.""" + + async def job_that_suspends(): + await asyncio.sleep(0) + + task = zha_gateway.async_run_zha_job( + ZHAJob(callback(job_that_suspends)), background=True + ) + assert not task.done() + assert task in zha_gateway._background_tasks + await task + assert task not in zha_gateway._background_tasks + + +async def test_async_add_zha_job_eager_background(zha_gateway: Gateway) -> None: + """Test scheduling a coro as an eager background task with async_add_zha_job.""" + + async def job_that_suspends(): + await asyncio.sleep(0) + + task = zha_gateway.async_add_zha_job( + ZHAJob(callback(job_that_suspends)), background=True + ) + assert not task.done() + assert task in zha_gateway._background_tasks + await task + assert task not in zha_gateway._background_tasks + + +async def test_async_run_zha_job_eager_background(zha_gateway: Gateway) -> None: + """Test scheduling a coro as an eager background task with async_run_zha_job.""" + + async def job_that_suspends(): + await asyncio.sleep(0) + + task = zha_gateway.async_run_zha_job( + ZHAJob(callback(job_that_suspends)), background=True + ) + assert not task.done() + assert task in zha_gateway._background_tasks + await task + assert task not in zha_gateway._background_tasks + + +async def test_async_run_zha_job_background_synchronous( + zha_gateway: Gateway, +) -> None: + """Test scheduling a coro as an eager background task with async_run_zha_job.""" + + async def job_that_does_not_suspends(): + pass + + task = zha_gateway.async_run_zha_job( + ZHAJob(callback(job_that_does_not_suspends)), + background=True, + ) + assert task.done() + assert task not in zha_gateway._background_tasks + assert task not in zha_gateway._tracked_completable_tasks + await task + + +async def test_async_run_zha_job_synchronous(zha_gateway: Gateway) -> None: + """Test scheduling a coro as an eager task with async_run_zha_job.""" + + async def job_that_does_not_suspends(): + pass + + task = zha_gateway.async_run_zha_job( + ZHAJob(callback(job_that_does_not_suspends)), + background=False, + ) + assert task.done() + assert task not in zha_gateway._background_tasks + assert task not in zha_gateway._tracked_completable_tasks + await task + + +async def test_async_add_zha_job_coro_named(zha_gateway: Gateway) -> None: + """Test that we schedule coroutines and add jobs to the job pool with a name.""" + + async def mycoro(): + pass + + job = ZHAJob(mycoro, "named coro") + assert "named coro" in str(job) + assert job.name == "named coro" + task = AsyncUtilMixin.async_add_zha_job(zha_gateway, job) + assert "named coro" in str(task) + + +async def test_async_add_zha_job_eager_start(zha_gateway: Gateway) -> None: + """Test eager_start with async_add_zha_job.""" + + async def mycoro(): + pass + + job = ZHAJob(mycoro, "named coro") + assert "named coro" in str(job) + assert job.name == "named coro" + task = AsyncUtilMixin.async_add_zha_job(zha_gateway, job, eager_start=True) + assert "named coro" in str(task) + + +async def test_async_add_zha_job_schedule_partial_callback() -> None: + """Test that we schedule partial coros and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + job = MagicMock() + partial = functools.partial(callback(job)) + + AsyncUtilMixin.async_add_zha_job(zha_gateway, ZHAJob(partial)) + assert len(zha_gateway.loop.call_soon.mock_calls) == 1 + assert len(zha_gateway.loop.create_task.mock_calls) == 0 + assert len(zha_gateway.add_job.mock_calls) == 0 + + +async def test_async_add_zha_job_schedule_coroutinefunction() -> None: + """Test that we schedule coroutines and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + + async def job(): + pass + + AsyncUtilMixin.async_add_zha_job(zha_gateway, ZHAJob(job)) + assert len(zha_gateway.loop.call_soon.mock_calls) == 0 + assert len(zha_gateway.loop.create_task.mock_calls) == 1 + assert len(zha_gateway.add_job.mock_calls) == 0 + + +async def test_async_add_zha_job_schedule_corofunction_eager_start() -> None: + """Test that we schedule coroutines and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + + async def job(): + pass + + with patch( + "zha.async_.create_eager_task", wraps=create_eager_task + ) as mock_create_eager_task: + zha_job = ZHAJob(job) + task = AsyncUtilMixin.async_add_zha_job(zha_gateway, zha_job, eager_start=True) + assert len(zha_gateway.loop.call_soon.mock_calls) == 0 + assert len(zha_gateway.add_job.mock_calls) == 0 + assert mock_create_eager_task.mock_calls + await task + + +async def test_async_add_zha_job_schedule_partial_coroutinefunction( + zha_gateway: Gateway, +) -> None: + """Test that we schedule partial coros and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + + async def job(): + pass + + partial = functools.partial(job) + + AsyncUtilMixin.async_add_zha_job(zha_gateway, ZHAJob(partial)) + assert len(zha_gateway.loop.call_soon.mock_calls) == 0 + assert len(zha_gateway.loop.create_task.mock_calls) == 1 + assert len(zha_gateway.add_job.mock_calls) == 0 + + +async def test_async_add_job_add_zha_threaded_job_to_pool() -> None: + """Test that we schedule coroutines and add jobs to the job pool.""" + zha_gateway = MagicMock() + + def job(): + pass + + AsyncUtilMixin.async_add_zha_job(zha_gateway, ZHAJob(job)) + assert len(zha_gateway.loop.call_soon.mock_calls) == 0 + assert len(zha_gateway.loop.create_task.mock_calls) == 0 + assert len(zha_gateway.loop.run_in_executor.mock_calls) == 2 + + +async def test_async_create_task_schedule_coroutine() -> None: + """Test that we schedule coroutines and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + + async def job(): + pass + + AsyncUtilMixin.async_create_task(zha_gateway, job()) + assert len(zha_gateway.loop.call_soon.mock_calls) == 0 + assert len(zha_gateway.loop.create_task.mock_calls) == 1 + assert len(zha_gateway.add_job.mock_calls) == 0 + + +async def test_async_create_task_eager_start_schedule_coroutine() -> None: + """Test that we schedule coroutines and add jobs to the job pool.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + + async def job(): + pass + + AsyncUtilMixin.async_create_task(zha_gateway, job(), eager_start=True) + # Should create the task directly since 3.12 supports eager_start + assert len(zha_gateway.loop.create_task.mock_calls) == 0 + assert len(zha_gateway.add_job.mock_calls) == 0 + + +async def test_async_create_task_schedule_coroutine_with_name() -> None: + """Test that we schedule coroutines and add jobs to the job pool with a name.""" + zha_gateway = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + + async def job(): + pass + + task = AsyncUtilMixin.async_create_task(zha_gateway, job(), "named task") + assert len(zha_gateway.loop.call_soon.mock_calls) == 0 + assert len(zha_gateway.loop.create_task.mock_calls) == 1 + assert len(zha_gateway.add_job.mock_calls) == 0 + assert "named task" in str(task) + + +async def test_async_run_eager_zha_job_calls_callback() -> None: + """Test that the callback annotation is respected.""" + zha_gateway = MagicMock() + calls = [] + + def job(): + asyncio.get_running_loop() # ensure we are in the event loop + calls.append(1) + + AsyncUtilMixin.async_run_zha_job(zha_gateway, ZHAJob(callback(job))) + assert len(calls) == 1 + + +async def test_async_run_eager_zha_job_calls_coro_function() -> None: + """Test running coros from async_run_zha_job with eager_start.""" + zha_gateway = MagicMock() + + async def job(): + pass + + AsyncUtilMixin.async_run_zha_job(zha_gateway, ZHAJob(job)) + assert len(zha_gateway.async_add_zha_job.mock_calls) == 1 + + +async def test_async_run_zha_job_calls_callback() -> None: + """Test that the callback annotation is respected.""" + zha_gateway = MagicMock() + calls = [] + + def job(): + calls.append(1) + + AsyncUtilMixin.async_run_zha_job(zha_gateway, ZHAJob(callback(job))) + assert len(calls) == 1 + assert len(zha_gateway.async_add_job.mock_calls) == 0 + + +async def test_async_run_zha_job_delegates_non_async() -> None: + """Test that the callback annotation is respected.""" + zha_gateway = MagicMock() + calls = [] + + def job(): + calls.append(1) + + AsyncUtilMixin.async_run_zha_job(zha_gateway, ZHAJob(job)) + assert len(calls) == 0 + assert len(zha_gateway.async_add_zha_job.mock_calls) == 1 + + +async def test_pending_scheduler(zha_gateway: Gateway) -> None: + """Add a coro to pending tasks.""" + call_count = [] + + async def test_coro(): + """Test Coro.""" + call_count.append("call") + + for _ in range(3): + zha_gateway.async_add_job(test_coro()) + + await asyncio.wait(zha_gateway._tracked_completable_tasks) + + assert len(zha_gateway._tracked_completable_tasks) == 0 + assert len(call_count) == 3 + + +def test_add_job_pending_tasks_coro(zha_gateway: Gateway) -> None: + """Add a coro to pending tasks.""" + + async def test_coro(): + """Test Coro.""" + + for _ in range(2): + zha_gateway.add_job(test_coro()) + + # Ensure add_job does not run immediately + assert len(zha_gateway._tracked_completable_tasks) == 0 + + +async def test_async_add_job_pending_tasks_coro(zha_gateway: Gateway) -> None: + """Add a coro to pending tasks.""" + call_count = [] + + async def test_coro(): + """Test Coro.""" + call_count.append("call") + + for _ in range(2): + zha_gateway.async_add_job(test_coro()) + + assert len(zha_gateway._tracked_completable_tasks) == 2 + await zha_gateway.async_block_till_done() + assert len(call_count) == 2 + assert len(zha_gateway._tracked_completable_tasks) == 0 + + +async def test_async_create_task_pending_tasks_coro(zha_gateway: Gateway) -> None: + """Add a coro to pending tasks.""" + call_count = [] + + async def test_coro(): + """Test Coro.""" + call_count.append("call") + + for _ in range(2): + zha_gateway.async_create_task(test_coro()) + + assert len(zha_gateway._tracked_completable_tasks) == 2 + await zha_gateway.async_block_till_done() + assert len(call_count) == 2 + assert len(zha_gateway._tracked_completable_tasks) == 0 + + +async def test_async_add_job_pending_tasks_executor(zha_gateway: Gateway) -> None: + """Run an executor in pending tasks.""" + call_count = [] + + def test_executor(): + """Test executor.""" + call_count.append("call") + + async def wait_finish_callback(): + """Wait until all stuff is scheduled.""" + await asyncio.sleep(0) + await asyncio.sleep(0) + + for _ in range(2): + zha_gateway.async_add_job(test_executor) + + await wait_finish_callback() + + await zha_gateway.async_block_till_done() + assert len(call_count) == 2 + + +async def test_async_add_job_pending_tasks_callback(zha_gateway: Gateway) -> None: + """Run a callback in pending tasks.""" + call_count = [] + + @callback + def test_callback(): + """Test callback.""" + call_count.append("call") + + async def wait_finish_callback(): + """Wait until all stuff is scheduled.""" + await asyncio.sleep(0) + await asyncio.sleep(0) + + for _ in range(2): + zha_gateway.async_add_job(test_callback) + + await wait_finish_callback() + + await zha_gateway.async_block_till_done() + + assert len(zha_gateway._tracked_completable_tasks) == 0 + assert len(call_count) == 2 + + +async def test_add_job_with_none(zha_gateway: Gateway) -> None: + """Try to add a job with None as function.""" + with pytest.raises(ValueError): + zha_gateway.async_add_job(None, "test_arg") + + +async def test_async_functions_with_callback(zha_gateway: Gateway) -> None: + """Test we deal with async functions accidentally marked as callback.""" + runs = [] + + @callback + async def test(): + runs.append(True) + + await zha_gateway.async_add_job(test) + assert len(runs) == 1 + + zha_gateway.async_run_job(test) + await zha_gateway.async_block_till_done() + assert len(runs) == 2 + + +async def test_async_run_job_starts_tasks_eagerly(zha_gateway: Gateway) -> None: + """Test async_run_job starts tasks eagerly.""" + runs = [] + + async def _test(): + runs.append(True) + + task = zha_gateway.async_run_job(_test) + # No call to zha_gateway.async_block_till_done to ensure the task is run eagerly + assert len(runs) == 1 + assert task.done() + await task + + +async def test_async_run_job_starts_coro_eagerly(zha_gateway: Gateway) -> None: + """Test async_run_job starts coros eagerly.""" + runs = [] + + async def _test(): + runs.append(True) + + task = zha_gateway.async_run_job(_test()) + # No call to zha_gateway.async_block_till_done to ensure the task is run eagerly + assert len(runs) == 1 + assert task.done() + await task + + +@pytest.mark.parametrize("eager_start", [True, False]) +async def test_background_task(zha_gateway: Gateway, eager_start: bool) -> None: + """Test background tasks being quit.""" + result = asyncio.Future() + + async def test_task(): + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + result.set_result("Foo") + raise + + task = zha_gateway.async_create_background_task( + test_task(), "happy task", eager_start=eager_start + ) + assert "happy task" in str(task) + await asyncio.sleep(0) + await zha_gateway.shutdown() + assert result.result() == "Foo" + + +async def test_shutdown_does_not_block_on_normal_tasks( + zha_gateway: Gateway, +) -> None: + """Ensure shutdown does not block on normal tasks.""" + result = asyncio.Future() + unshielded_task = asyncio.sleep(10) + + async def test_task(): + try: + await unshielded_task + except asyncio.CancelledError: + result.set_result("Foo") + + start = time.monotonic() + task = zha_gateway.async_create_task(test_task()) + await asyncio.sleep(0) + await zha_gateway.shutdown() + await asyncio.sleep(0) + assert result.done() + assert task.done() + assert time.monotonic() - start < 0.5 + + +async def test_shutdown_does_not_block_on_shielded_tasks( + zha_gateway: Gateway, +) -> None: + """Ensure shutdown does not block on shielded tasks.""" + result = asyncio.Future() + sleep_task = asyncio.ensure_future(asyncio.sleep(10)) + shielded_task = asyncio.shield(sleep_task) + + async def test_task(): + try: + await shielded_task + except asyncio.CancelledError: + result.set_result("Foo") + + start = time.monotonic() + task = zha_gateway.async_create_task(test_task()) + await asyncio.sleep(0) + await zha_gateway.shutdown() + await asyncio.sleep(0) + assert result.done() + assert task.done() + assert time.monotonic() - start < 0.5 + + # Cleanup lingering task after test is done + sleep_task.cancel() + + +@pytest.mark.parametrize("eager_start", [True, False]) +async def test_cancellable_ZHAJob(zha_gateway: Gateway, eager_start: bool) -> None: + """Simulate a shutdown, ensure cancellable jobs are cancelled.""" + job = MagicMock() + + @callback + def run_job(job: ZHAJob) -> None: + """Call the action.""" + zha_gateway.async_run_hass_job(job, eager_start=eager_start) + + timer1 = zha_gateway.loop.call_later( + 60, run_job, ZHAJob(callback(job), cancel_on_shutdown=True) + ) + timer2 = zha_gateway.loop.call_later(60, run_job, ZHAJob(callback(job))) + + await zha_gateway.shutdown() + + assert timer1.cancelled() + assert not timer2.cancelled() + + # Cleanup + timer2.cancel() + + +def test_ZHAJob_passing_job_type(): + """Test passing the job type to ZHAJob when we already know it.""" + + @callback + def callback_func(): + pass + + def not_callback_func(): + pass + + assert ( + ZHAJob(callback_func, job_type=ZHAJobType.Callback).job_type + == ZHAJobType.Callback + ) + + # We should trust the job_type passed in + assert ( + ZHAJob(not_callback_func, job_type=ZHAJobType.Callback).job_type + == ZHAJobType.Callback + ) diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py new file mode 100644 index 00000000..d2dc2a41 --- /dev/null +++ b/tests/test_binary_sensor.py @@ -0,0 +1,146 @@ +"""Test zhaws binary sensor.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import call + +import pytest +from zigpy.device import Device as ZigpyDevice +import zigpy.profiles.zha +from zigpy.zcl.clusters import general, measurement, security + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.platforms.binary_sensor import IASZone, Occupancy +from zha.zigbee.device import Device + +from .common import find_entity, send_attributes_report, update_attribute_cache +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +DEVICE_IAS = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_INPUT: [security.IasZone.cluster_id], + SIG_EP_OUTPUT: [], + } +} + + +DEVICE_OCCUPANCY = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, + SIG_EP_INPUT: [measurement.OccupancySensing.cluster_id], + SIG_EP_OUTPUT: [], + } +} + + +async def async_test_binary_sensor_occupancy( + zha_gateway: Gateway, + cluster: general.OnOff, + entity: Occupancy, + plugs: dict[str, int], +) -> None: + """Test getting on and off messages for binary sensors.""" + # binary sensor on + await send_attributes_report(zha_gateway, cluster, {1: 0, 0: 1, 2: 2}) + assert entity.is_on + + # binary sensor off + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 2}) + assert entity.is_on is False + + # test refresh + cluster.read_attributes.reset_mock() # type: ignore[unreachable] + assert entity.is_on is False + cluster.PLUGGED_ATTR_READS = plugs + update_attribute_cache(cluster) + await entity.async_update() + await zha_gateway.async_block_till_done() + assert cluster.read_attributes.await_count == 1 + assert cluster.read_attributes.await_args == call( + ["occupancy"], allow_cache=True, only_cache=True, manufacturer=None + ) + assert entity.is_on + + +async def async_test_iaszone_on_off( + zha_gateway: Gateway, + cluster: security.IasZone, + entity: IASZone, + plugs: dict[str, int], +) -> None: + """Test getting on and off messages for iaszone binary sensors.""" + # binary sensor on + cluster.listener_event("cluster_command", 1, 0, [1]) + await zha_gateway.async_block_till_done() + assert entity.is_on + + # binary sensor off + cluster.listener_event("cluster_command", 1, 0, [0]) + await zha_gateway.async_block_till_done() + assert entity.is_on is False + + # check that binary sensor remains off when non-alarm bits change + cluster.listener_event("cluster_command", 1, 0, [0b1111111100]) # type: ignore[unreachable] + await zha_gateway.async_block_till_done() + assert entity.is_on is False + + # test refresh + cluster.read_attributes.reset_mock() + assert entity.is_on is False + cluster.PLUGGED_ATTR_READS = plugs + update_attribute_cache(cluster) + await entity.async_update() + await zha_gateway.async_block_till_done() + assert cluster.read_attributes.await_count == 1 + assert cluster.read_attributes.await_args == call( + ["zone_status"], allow_cache=False, only_cache=False, manufacturer=None + ) + assert entity.is_on + + +@pytest.mark.parametrize( + "device, on_off_test, cluster_name, entity_type, plugs", + [ + ( + DEVICE_IAS, + async_test_iaszone_on_off, + "ias_zone", + IASZone, + {"zone_status": 1}, + ), + ( + DEVICE_OCCUPANCY, + async_test_binary_sensor_occupancy, + "occupancy", + Occupancy, + {"occupancy": 1}, + ), + ], +) +async def test_binary_sensor( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, + device: dict, + on_off_test: Callable[..., Awaitable[None]], + cluster_name: str, + entity_type: type, + plugs: dict[str, int], +) -> None: + """Test ZHA binary_sensor platform.""" + zigpy_device = zigpy_device_mock(device) + zha_device = await device_joined(zigpy_device) + + entity: PlatformEntity = find_entity(zha_device, Platform.BINARY_SENSOR) + assert entity is not None + assert isinstance(entity, entity_type) + assert entity.PLATFORM == Platform.BINARY_SENSOR + assert entity.is_on is False + + # test getting messages that trigger and reset the sensors + cluster = getattr(zigpy_device.endpoints[1], cluster_name) + await on_off_test(zha_gateway, cluster, entity, plugs) diff --git a/tests/test_button.py b/tests/test_button.py new file mode 100644 index 00000000..7a1dfa8c --- /dev/null +++ b/tests/test_button.py @@ -0,0 +1,303 @@ +"""Test ZHA button.""" + +from collections.abc import Awaitable, Callable +from typing import Final +from unittest.mock import call, patch + +import pytest +from slugify import slugify +from zhaquirks.const import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + OUTPUT_CLUSTERS, + PROFILE_ID, +) +from zhaquirks.tuya.ts0601_valve import ParksideTuyaValveManufCluster +from zigpy.device import Device as ZigpyDevice +from zigpy.exceptions import ZigbeeException +from zigpy.profiles import zha +from zigpy.quirks import CustomCluster, CustomDevice +from zigpy.quirks.v2 import add_to_registry_v2 +import zigpy.types as t +from zigpy.zcl.clusters import general, security +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster +import zigpy.zcl.foundation as zcl_f + +from tests.common import find_entity, find_entity_id, mock_coro, update_attribute_cache +from tests.conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.platforms.button import Button, WriteAttributeButton +from zha.application.platforms.button.const import ButtonDeviceClass +from zha.exceptions import ZHAException +from zha.zigbee.device import Device + + +@pytest.fixture +async def contact_sensor( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> tuple[Device, general.Identify]: + """Contact sensor fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + security.IasZone.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_ZONE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device: Device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].identify + + +class FrostLockQuirk(CustomDevice): + """Quirk with frost lock attribute.""" + + class TuyaManufCluster(CustomCluster, ManufacturerSpecificCluster): + """Tuya manufacturer specific cluster.""" + + cluster_id = 0xEF00 + ep_attribute = "tuya_manufacturer" + + attributes = {0xEF01: ("frost_lock_reset", t.Bool)} + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, + INPUT_CLUSTERS: [general.Basic.cluster_id, TuyaManufCluster], + OUTPUT_CLUSTERS: [], + }, + } + } + + +@pytest.fixture +async def tuya_water_valve(zigpy_device_mock, device_joined): + """Tuya Water Valve fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, + INPUT_CLUSTERS: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + general.OnOff.cluster_id, + ParksideTuyaValveManufCluster.cluster_id, + ], + OUTPUT_CLUSTERS: [general.Time.cluster_id, general.Ota.cluster_id], + }, + }, + manufacturer="_TZE200_htnnfasr", + model="TS0601", + ) + + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].tuya_manufacturer + + +async def test_button( + contact_sensor: tuple[Device, general.Identify], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zha button platform.""" + + zha_device, cluster = contact_sensor + assert cluster is not None + entity: PlatformEntity = find_entity(zha_device, Platform.BUTTON) # type: ignore + assert entity is not None + assert isinstance(entity, Button) + assert entity.PLATFORM == Platform.BUTTON + + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await entity.async_press() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 5 # duration in seconds + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +async def test_frost_unlock( + zha_gateway: Gateway, + tuya_water_valve: tuple[Device, general.Identify], # pylint: disable=redefined-outer-name +) -> None: + """Test custom frost unlock ZHA button.""" + + zha_device, cluster = tuya_water_valve + assert cluster is not None + entity_id = find_entity_id( + Platform.BUTTON, zha_device, qualifier="reset_frost_lock" + ) + entity: PlatformEntity = get_entity(zha_device, entity_id) + assert entity is not None + assert isinstance(entity, WriteAttributeButton) + + assert entity._attr_device_class == ButtonDeviceClass.RESTART + assert entity._attr_entity_category == EntityCategory.CONFIG + + await entity.async_press() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"frost_lock_reset": 0}, manufacturer=None) + ] + + cluster.write_attributes.reset_mock() + cluster.write_attributes.side_effect = ZigbeeException + + with pytest.raises(ZHAException): + await entity.async_press() + await zha_gateway.async_block_till_done() + + # There are three retries + assert cluster.write_attributes.mock_calls == [ + call({"frost_lock_reset": 0}, manufacturer=None), + call({"frost_lock_reset": 0}, manufacturer=None), + call({"frost_lock_reset": 0}, manufacturer=None), + ] + + +class FakeManufacturerCluster(CustomCluster, ManufacturerSpecificCluster): + """Fake manufacturer cluster.""" + + cluster_id: Final = 0xFFF3 + ep_attribute: Final = "mfg_identify" + + class AttributeDefs(zcl_f.BaseAttributeDefs): + """Attribute definitions.""" + + feed: Final = zcl_f.ZCLAttributeDef( + id=0x0000, type=t.uint8_t, access="rw", is_manufacturer_specific=True + ) + + class ServerCommandDefs(zcl_f.BaseCommandDefs): + """Server command definitions.""" + + self_test: Final = zcl_f.ZCLCommandDef( + id=0x00, schema={"identify_time": t.uint16_t}, direction=False + ) + + +( + add_to_registry_v2("Fake_Model", "Fake_Manufacturer") + .replaces(FakeManufacturerCluster) + .command_button( + FakeManufacturerCluster.ServerCommandDefs.self_test.name, + FakeManufacturerCluster.cluster_id, + command_args=(5,), + ) + .write_attr_button( + FakeManufacturerCluster.AttributeDefs.feed.name, + 2, + FakeManufacturerCluster.cluster_id, + ) +) + + +@pytest.fixture +async def custom_button_device(zigpy_device_mock, device_joined): + """Button device fixture for quirks button tests.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + FakeManufacturerCluster.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.REMOTE_CONTROL, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + manufacturer="Fake_Model", + model="Fake_Manufacturer", + ) + + zigpy_device.endpoints[1].mfg_identify.PLUGGED_ATTR_READS = { + FakeManufacturerCluster.AttributeDefs.feed.name: 0, + } + update_attribute_cache(zigpy_device.endpoints[1].mfg_identify) + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].mfg_identify + + +async def test_quirks_command_button( + zha_gateway: Gateway, + custom_button_device: tuple[Device, general.Identify], # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA button platform.""" + + zha_device, cluster = custom_button_device + assert cluster is not None + entity_id = find_entity_id(Platform.BUTTON, zha_device) + entity: PlatformEntity = get_entity(zha_device, entity_id) + assert isinstance(entity, Button) + assert entity is not None + + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await entity.async_press() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 5 # duration in seconds + + +async def test_quirks_write_attr_button( + zha_gateway: Gateway, + custom_button_device: tuple[Device, general.Identify], # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA button platform.""" + + zha_device, cluster = custom_button_device + assert cluster is not None + entity_id = find_entity_id(Platform.BUTTON, zha_device, qualifier="feed") + entity: PlatformEntity = get_entity(zha_device, entity_id) + assert isinstance(entity, WriteAttributeButton) + assert entity is not None + + assert cluster.get(cluster.AttributeDefs.feed.name) == 0 + + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await entity.async_press() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({cluster.AttributeDefs.feed.name: 2}, manufacturer=None) + ] + + assert cluster.get(cluster.AttributeDefs.feed.name) == 2 diff --git a/tests/test_climate.py b/tests/test_climate.py new file mode 100644 index 00000000..6fcfdc4c --- /dev/null +++ b/tests/test_climate.py @@ -0,0 +1,1506 @@ +"""Test zha climate.""" + +# pylint: disable=redefined-outer-name,too-many-lines + +import asyncio +from collections.abc import Awaitable, Callable +import logging +from unittest.mock import AsyncMock, call, patch + +import pytest +from slugify import slugify +import zhaquirks.sinope.thermostat +from zhaquirks.sinope.thermostat import SinopeTechnologiesThermostatCluster +import zhaquirks.tuya.ts0601_trv +from zigpy.device import Device as ZigpyDevice +import zigpy.profiles +import zigpy.zcl.clusters +from zigpy.zcl.clusters.hvac import Thermostat +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.const import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_NONE, + PRESET_SCHEDULE, + PRESET_TEMP_MANUAL, +) +from zha.application.gateway import Gateway +from zha.application.platforms.climate import ( + HVAC_MODE_2_SYSTEM, + SEQ_OF_OPERATION, + Thermostat as ThermostatEntity, +) +from zha.application.platforms.climate.const import FanState +from zha.application.platforms.sensor import ( + Sensor, + SinopeHVACAction, + ThermostatHVACAction, +) +from zha.exceptions import ZHAException +from zha.zigbee.device import Device + +from .common import find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +_LOGGER = logging.getLogger(__name__) + +CLIMATE = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +CLIMATE_FAN = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Fan.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +CLIMATE_SINOPE = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + 65281, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id, 65281], + }, + 196: { + SIG_EP_PROFILE: 0xC25D, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [zigpy.zcl.clusters.general.PowerConfiguration.cluster_id], + SIG_EP_OUTPUT: [], + }, +} + +CLIMATE_ZEN = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Fan.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +CLIMATE_MOES = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + 61148, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +CLIMATE_BECA = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SMART_PLUG, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.Groups.cluster_id, + zigpy.zcl.clusters.general.Scenes.cluster_id, + 61148, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Time.cluster_id, + zigpy.zcl.clusters.general.Ota.cluster_id, + ], + } +} + +CLIMATE_ZONNSMART = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.THERMOSTAT, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + zigpy.zcl.clusters.hvac.UserInterface.cluster_id, + 61148, + ], + SIG_EP_OUTPUT: [zigpy.zcl.clusters.general.Ota.cluster_id], + } +} + +MANUF_SINOPE = "Sinope Technologies" +MANUF_ZEN = "Zen Within" +MANUF_MOES = "_TZE200_ckud7u2l" +MANUF_BECA = "_TZE200_b6wax7g0" +MANUF_ZONNSMART = "_TZE200_hue3yfsn" + +ZCL_ATTR_PLUG = { + "abs_min_heat_setpoint_limit": 800, + "abs_max_heat_setpoint_limit": 3000, + "abs_min_cool_setpoint_limit": 2000, + "abs_max_cool_setpoint_limit": 4000, + "ctrl_sequence_of_oper": Thermostat.ControlSequenceOfOperation.Cooling_and_Heating, + "local_temperature": None, + "max_cool_setpoint_limit": 3900, + "max_heat_setpoint_limit": 2900, + "min_cool_setpoint_limit": 2100, + "min_heat_setpoint_limit": 700, + "occupancy": 1, + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2200, + "pi_cooling_demand": None, + "pi_heating_demand": None, + "running_mode": Thermostat.RunningMode.Off, + "running_state": None, + "system_mode": Thermostat.SystemMode.Off, + "unoccupied_heating_setpoint": 2200, + "unoccupied_cooling_setpoint": 2300, +} + +ATTR_PRESET_MODE = "preset_mode" + + +@pytest.fixture +def device_climate_mock( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Callable[..., Device]: + """Test regular thermostat device.""" + + async def _dev(clusters, plug=None, manuf=None, quirk=None): + if plug is None: + plugged_attrs = ZCL_ATTR_PLUG + else: + plugged_attrs = {**ZCL_ATTR_PLUG, **plug} + + zigpy_device = zigpy_device_mock(clusters, manufacturer=manuf, quirk=quirk) + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = plugged_attrs + zha_device = await device_joined(zigpy_device) + return zha_device + + return _dev + + +@pytest.fixture +async def device_climate(device_climate_mock): + """Plain Climate device.""" + + return await device_climate_mock(CLIMATE) + + +@pytest.fixture +async def device_climate_fan(device_climate_mock): + """Test thermostat with fan device.""" + + return await device_climate_mock(CLIMATE_FAN) + + +@pytest.fixture +@patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", +) +async def device_climate_sinope(device_climate_mock): + """Sinope thermostat.""" + + return await device_climate_mock( + CLIMATE_SINOPE, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + + +@pytest.fixture +async def device_climate_zen(device_climate_mock): + """Zen Within thermostat.""" + + return await device_climate_mock(CLIMATE_ZEN, manuf=MANUF_ZEN) + + +@pytest.fixture +async def device_climate_moes(device_climate_mock): + """MOES thermostat.""" + + return await device_climate_mock( + CLIMATE_MOES, manuf=MANUF_MOES, quirk=zhaquirks.tuya.ts0601_trv.MoesHY368_Type1 + ) + + +@pytest.fixture +async def device_climate_beca(device_climate_mock) -> Device: + """Beca thermostat.""" + + return await device_climate_mock( + CLIMATE_BECA, + manuf=MANUF_BECA, + quirk=zhaquirks.tuya.ts0601_trv.MoesHY368_Type1new, + ) + + +@pytest.fixture +async def device_climate_zonnsmart(device_climate_mock): + """ZONNSMART thermostat.""" + + return await device_climate_mock( + CLIMATE_ZONNSMART, + manuf=MANUF_ZONNSMART, + quirk=zhaquirks.tuya.ts0601_trv.ZonnsmartTV01_ZG, + ) + + +def get_entity(zha_dev: Device, entity_id: str) -> ThermostatEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def get_sensor_entity(zha_dev: Device, entity_id: str) -> Sensor: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def test_sequence_mappings(): + """Test correct mapping between control sequence -> HVAC Mode -> Sysmode.""" + + for hvac_modes in SEQ_OF_OPERATION.values(): + for hvac_mode in hvac_modes: + assert hvac_mode in HVAC_MODE_2_SYSTEM + assert Thermostat.SystemMode(HVAC_MODE_2_SYSTEM[hvac_mode]) is not None + + +async def test_climate_local_temperature( + device_climate: Device, + zha_gateway: Gateway, +) -> None: + """Test local temperature.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + + assert isinstance(entity, ThermostatEntity) + assert entity.get_state()["current_temperature"] is None + + await send_attributes_report(zha_gateway, thrm_cluster, {0: 2100}) + assert entity.get_state()["current_temperature"] == 21.0 + + +async def test_climate_hvac_action_running_state( + device_climate_sinope: Device, + zha_gateway: Gateway, +): + """Test hvac action via running state.""" + + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope) + sensor_entity_id = find_entity_id(Platform.SENSOR, device_climate_sinope, "hvac") + assert entity_id is not None + assert sensor_entity_id is not None + + entity: ThermostatEntity = get_entity(device_climate_sinope, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + sensor_entity: Sensor = get_entity(device_climate_sinope, sensor_entity_id) + assert sensor_entity is not None + assert isinstance(sensor_entity, SinopeHVACAction) + + assert entity.get_state()["hvac_action"] == "off" + assert sensor_entity.get_state()["state"] == "off" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001E: Thermostat.RunningMode.Off} + ) + assert entity.get_state()["hvac_action"] == "off" + assert sensor_entity.get_state()["state"] == "off" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001C: Thermostat.SystemMode.Auto} + ) + assert entity.get_state()["hvac_action"] == "idle" + assert sensor_entity.get_state()["state"] == "idle" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001E: Thermostat.RunningMode.Cool} + ) + assert entity.get_state()["hvac_action"] == "cooling" + assert sensor_entity.get_state()["state"] == "cooling" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001E: Thermostat.RunningMode.Heat} + ) + assert entity.get_state()["hvac_action"] == "heating" + assert sensor_entity.get_state()["state"] == "heating" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001E: Thermostat.RunningMode.Off} + ) + assert entity.get_state()["hvac_action"] == "idle" + assert sensor_entity.get_state()["state"] == "idle" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On} + ) + assert entity.get_state()["hvac_action"] == "fan" + assert sensor_entity.get_state()["state"] == "fan" + + +@pytest.mark.looptime +async def test_sinope_time( + device_climate_sinope: Device, + zha_gateway: Gateway, +): + """Test hvac action via running state.""" + + mfg_cluster = device_climate_sinope.device.endpoints[1].sinope_manufacturer_specific + assert mfg_cluster is not None + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope) + entity: ThermostatEntity = get_entity(device_climate_sinope, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + entity._async_update_time = AsyncMock(wraps=entity._async_update_time) + + await asyncio.sleep(4600) + + assert entity._async_update_time.await_count == 1 + assert mfg_cluster.write_attributes.await_count == 1 + assert "secs_since_2k" in mfg_cluster.write_attributes.await_args_list[0][0][0] + + +async def test_climate_hvac_action_running_state_zen( + device_climate_zen: Device, + zha_gateway: Gateway, +): + """Test Zen hvac action via running state.""" + + thrm_cluster = device_climate_zen.device.endpoints[1].thermostat + entity_id = find_entity_id(Platform.CLIMATE, device_climate_zen) + sensor_entity_id = find_entity_id(Platform.SENSOR, device_climate_zen, "hvac") + assert entity_id is not None + assert sensor_entity_id is not None + + entity: ThermostatEntity = get_entity(device_climate_zen, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + sensor_entity: Sensor = get_entity(device_climate_zen, sensor_entity_id) + assert sensor_entity is not None + assert isinstance(sensor_entity, ThermostatHVACAction) + + assert entity.get_state()["hvac_action"] is None + assert sensor_entity.get_state()["state"] is None + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_2nd_Stage_On} + ) + assert entity.get_state()["hvac_action"] == "cooling" + assert sensor_entity.get_state()["state"] == "cooling" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_State_On} + ) + assert entity.get_state()["hvac_action"] == "fan" + assert sensor_entity.get_state()["state"] == "fan" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_2nd_Stage_On} + ) + assert entity.get_state()["hvac_action"] == "heating" + assert sensor_entity.get_state()["state"] == "heating" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_2nd_Stage_On} + ) + assert entity.get_state()["hvac_action"] == "fan" + assert sensor_entity.get_state()["state"] == "fan" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Cool_State_On} + ) + assert entity.get_state()["hvac_action"] == "cooling" + assert sensor_entity.get_state()["state"] == "cooling" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Fan_3rd_Stage_On} + ) + assert entity.get_state()["hvac_action"] == "fan" + assert sensor_entity.get_state()["state"] == "fan" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Heat_State_On} + ) + assert entity.get_state()["hvac_action"] == "heating" + assert sensor_entity.get_state()["state"] == "heating" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x0029: Thermostat.RunningState.Idle} + ) + assert entity.get_state()["hvac_action"] == "off" + assert sensor_entity.get_state()["state"] == "off" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat} + ) + assert entity.get_state()["hvac_action"] == "idle" + assert sensor_entity.get_state()["state"] == "idle" + + +async def test_climate_hvac_action_pi_demand( + device_climate: Device, + zha_gateway: Gateway, +): + """Test hvac action based on pi_heating/cooling_demand attrs.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + assert entity_id is not None + + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_action"] is None + + await send_attributes_report(zha_gateway, thrm_cluster, {0x0007: 10}) + assert entity.get_state()["hvac_action"] == "cooling" + + await send_attributes_report(zha_gateway, thrm_cluster, {0x0008: 20}) + assert entity.get_state()["hvac_action"] == "heating" + + await send_attributes_report(zha_gateway, thrm_cluster, {0x0007: 0}) + await send_attributes_report(zha_gateway, thrm_cluster, {0x0008: 0}) + + assert entity.get_state()["hvac_action"] == "off" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001C: Thermostat.SystemMode.Heat} + ) + assert entity.get_state()["hvac_action"] == "idle" + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001C: Thermostat.SystemMode.Cool} + ) + assert entity.get_state()["hvac_action"] == "idle" + + +@pytest.mark.parametrize( + "sys_mode, hvac_mode", + ( + (Thermostat.SystemMode.Auto, "heat_cool"), + (Thermostat.SystemMode.Cool, "cool"), + (Thermostat.SystemMode.Heat, "heat"), + (Thermostat.SystemMode.Pre_cooling, "cool"), + (Thermostat.SystemMode.Fan_only, "fan_only"), + (Thermostat.SystemMode.Dry, "dry"), + ), +) +async def test_hvac_mode( + device_climate: Device, + zha_gateway: Gateway, + sys_mode, + hvac_mode, +): + """Test HVAC mode.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + assert entity_id is not None + + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "off" + + await send_attributes_report(zha_gateway, thrm_cluster, {0x001C: sys_mode}) + assert entity.get_state()["hvac_mode"] == hvac_mode + + await send_attributes_report( + zha_gateway, thrm_cluster, {0x001C: Thermostat.SystemMode.Off} + ) + assert entity.get_state()["hvac_mode"] == "off" + + await send_attributes_report(zha_gateway, thrm_cluster, {0x001C: 0xFF}) + assert entity.get_state()["hvac_mode"] is None + + +@pytest.mark.parametrize( + "seq_of_op, modes", + ( + (0xFF, {"off"}), + (0x00, {"off", "cool"}), + (0x01, {"off", "cool"}), + (0x02, {"off", "heat"}), + (0x03, {"off", "heat"}), + (0x04, {"off", "cool", "heat", "heat_cool"}), + (0x05, {"off", "cool", "heat", "heat_cool"}), + ), +) +async def test_hvac_modes( # pylint: disable=unused-argument + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, + seq_of_op, + modes, +): + """Test HVAC modes from sequence of operations.""" + + dev_climate = await device_climate_mock( + CLIMATE, {"ctrl_sequence_of_oper": seq_of_op} + ) + entity_id = find_entity_id(Platform.CLIMATE, dev_climate) + assert entity_id is not None + entity: ThermostatEntity = get_entity(dev_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + assert set(entity.hvac_modes) == modes + + +@pytest.mark.parametrize( + "sys_mode, preset, target_temp", + ( + (Thermostat.SystemMode.Heat, None, 22), + (Thermostat.SystemMode.Heat, "away", 16), + (Thermostat.SystemMode.Cool, None, 25), + (Thermostat.SystemMode.Cool, "away", 27), + ), +) +async def test_target_temperature( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, + sys_mode, + preset, + target_temp, +): + """Test target temperature property.""" + + dev_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2200, + "system_mode": sys_mode, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + entity_id = find_entity_id(Platform.CLIMATE, dev_climate) + assert entity_id is not None + entity: ThermostatEntity = get_entity(dev_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + if preset: + await entity.async_set_preset_mode(preset) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature"] == target_temp + + +@pytest.mark.parametrize( + "preset, unoccupied, target_temp", + ( + (None, 1800, 17), + ("away", 1800, 18), + ("away", None, None), + ), +) +async def test_target_temperature_high( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, + preset, + unoccupied, + target_temp, +): + """Test target temperature high property.""" + + dev_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 1700, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_cooling_setpoint": unoccupied, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + entity_id = find_entity_id(Platform.CLIMATE, dev_climate) + assert entity_id is not None + entity: ThermostatEntity = get_entity(dev_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + if preset: + await entity.async_set_preset_mode(preset) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_high"] == target_temp + + +@pytest.mark.parametrize( + "preset, unoccupied, target_temp", + ( + (None, 1600, 21), + ("away", 1600, 16), + ("away", None, None), + ), +) +async def test_target_temperature_low( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, + preset, + unoccupied, + target_temp, +): + """Test target temperature low property.""" + + dev_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_heating_setpoint": 2100, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": unoccupied, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + entity_id = find_entity_id(Platform.CLIMATE, dev_climate) + assert entity_id is not None + entity: ThermostatEntity = get_entity(dev_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + if preset: + await entity.async_set_preset_mode(preset) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] == target_temp + + +@pytest.mark.parametrize( + "hvac_mode, sys_mode", + ( + ("auto", None), + ("cool", Thermostat.SystemMode.Cool), + ("dry", None), + ("fan_only", None), + ("heat", Thermostat.SystemMode.Heat), + ("heat_cool", Thermostat.SystemMode.Auto), + ), +) +async def test_set_hvac_mode( + device_climate: Device, + zha_gateway: Gateway, + hvac_mode, + sys_mode, +): + """Test setting hvac mode.""" + + thrm_cluster = device_climate.device.endpoints[1].thermostat + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "off" + + await entity.async_set_hvac_mode(hvac_mode) + await zha_gateway.async_block_till_done() + + if sys_mode is not None: + assert entity.get_state()["hvac_mode"] == hvac_mode + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": sys_mode + } + else: + assert thrm_cluster.write_attributes.call_count == 0 + assert entity.get_state()["hvac_mode"] == "off" + + # turn off + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_hvac_mode("off") + await zha_gateway.async_block_till_done() + + assert entity.get_state()["hvac_mode"] == "off" + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": Thermostat.SystemMode.Off + } + + +async def test_preset_setting( + device_climate_sinope: Device, + zha_gateway: Gateway, +): + """Test preset setting.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_sinope, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["preset_mode"] == "none" + + # unsuccessful occupancy change + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse( + [ + zcl_f.WriteAttributesStatusRecord( + status=zcl_f.Status.FAILURE, + attrid=SinopeTechnologiesThermostatCluster.AttributeDefs.set_occupancy.id, # pylint: disable=no-member + ) + ] + ) + ] + + with pytest.raises(ZHAException): + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + + assert entity.get_state()["preset_mode"] == "none" + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 0} + + # successful occupancy change + thrm_cluster.write_attributes.reset_mock() + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] + ] + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + + assert entity.get_state()["preset_mode"] == "away" + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 0} + + # unsuccessful occupancy change + thrm_cluster.write_attributes.reset_mock() + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse( + [ + zcl_f.WriteAttributesStatusRecord( + status=zcl_f.Status.FAILURE, + attrid=SinopeTechnologiesThermostatCluster.AttributeDefs.set_occupancy.id, # pylint: disable=no-member + ) + ] + ) + ] + + with pytest.raises(ZHAException): + # unsuccessful occupancy change + await entity.async_set_preset_mode("none") + await zha_gateway.async_block_till_done() + + assert entity.get_state()["preset_mode"] == "away" + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 1} + + # successful occupancy change + thrm_cluster.write_attributes.reset_mock() + thrm_cluster.write_attributes.return_value = [ + zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0] + ] + + await entity.async_set_preset_mode("none") + await zha_gateway.async_block_till_done() + + assert entity.get_state()["preset_mode"] == "none" + assert thrm_cluster.write_attributes.call_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == {"set_occupancy": 1} + + +async def test_preset_setting_invalid( + device_climate_sinope: Device, + zha_gateway: Gateway, +): + """Test invalid preset setting.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + assert entity_id is not None + + entity: ThermostatEntity = get_entity(device_climate_sinope, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["preset_mode"] == "none" + await entity.async_set_preset_mode("invalid_preset") + await zha_gateway.async_block_till_done() + + assert entity.get_state()["preset_mode"] == "none" + assert thrm_cluster.write_attributes.call_count == 0 + + +async def test_set_temperature_hvac_mode( + device_climate: Device, + zha_gateway: Gateway, +): + """Test setting HVAC mode in temperature service call.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + thrm_cluster = device_climate.device.endpoints[1].thermostat + assert entity_id is not None + + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "off" + await entity.async_set_temperature(hvac_mode="heat_cool", temperature=20) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["hvac_mode"] == "heat_cool" + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args[0][0] == { + "system_mode": Thermostat.SystemMode.Auto + } + + +async def test_set_temperature_heat_cool( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, +): + """Test setting temperature service call in heating/cooling HVAC mode.""" + + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Auto, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + thrm_cluster = device_climate.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "heat_cool" + + await entity.async_set_temperature(temperature=20) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] == 20.0 + assert entity.get_state()["target_temperature_high"] == 25.0 + assert thrm_cluster.write_attributes.await_count == 0 + + await entity.async_set_temperature(target_temp_high=26, target_temp_low=19) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] == 19.0 + assert entity.get_state()["target_temperature_high"] == 26.0 + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "occupied_heating_setpoint": 1900 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "occupied_cooling_setpoint": 2600 + } + + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + thrm_cluster.write_attributes.reset_mock() + + await entity.async_set_temperature(target_temp_high=30, target_temp_low=15) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] == 15.0 + assert entity.get_state()["target_temperature_high"] == 30.0 + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "unoccupied_heating_setpoint": 1500 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "unoccupied_cooling_setpoint": 3000 + } + + +async def test_set_temperature_heat( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, +): + """Test setting temperature service call in heating HVAC mode.""" + + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Heat, + "unoccupied_heating_setpoint": 1600, + "unoccupied_cooling_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + thrm_cluster = device_climate.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "heat" + + await entity.async_set_temperature(target_temp_high=30, target_temp_low=15) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] == 20.0 + assert thrm_cluster.write_attributes.await_count == 0 + + await entity.async_set_temperature(temperature=21) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] == 21.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "occupied_heating_setpoint": 2100 + } + + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + thrm_cluster.write_attributes.reset_mock() + + await entity.async_set_temperature(temperature=22) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] == 22.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "unoccupied_heating_setpoint": 2200 + } + + +async def test_set_temperature_cool( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, +): + """Test setting temperature service call in cooling HVAC mode.""" + + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Cool, + "unoccupied_cooling_setpoint": 1600, + "unoccupied_heating_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + quirk=zhaquirks.sinope.thermostat.SinopeTechnologiesThermostat, + ) + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + thrm_cluster = device_climate.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "cool" + + await entity.async_set_temperature(target_temp_high=30, target_temp_low=15) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] == 25.0 + assert thrm_cluster.write_attributes.await_count == 0 + + await entity.async_set_temperature(temperature=21) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] == 21.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "occupied_cooling_setpoint": 2100 + } + + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + thrm_cluster.write_attributes.reset_mock() + + await entity.async_set_temperature(temperature=22) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] == 22.0 + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "unoccupied_cooling_setpoint": 2200 + } + + +async def test_set_temperature_wrong_mode( + device_climate_mock: Callable[..., Device], + zha_gateway: Gateway, +): + """Test setting temperature service call for wrong HVAC mode.""" + + with patch.object( + zigpy.zcl.clusters.manufacturer_specific.ManufacturerSpecificCluster, + "ep_attribute", + "sinope_manufacturer_specific", + ): + device_climate = await device_climate_mock( + CLIMATE_SINOPE, + { + "occupied_cooling_setpoint": 2500, + "occupied_heating_setpoint": 2000, + "system_mode": Thermostat.SystemMode.Dry, + "unoccupied_cooling_setpoint": 1600, + "unoccupied_heating_setpoint": 2700, + }, + manuf=MANUF_SINOPE, + ) + entity_id = find_entity_id(Platform.CLIMATE, device_climate) + thrm_cluster = device_climate.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["hvac_mode"] == "dry" + + await entity.async_set_temperature(temperature=24) + await zha_gateway.async_block_till_done() + + assert entity.get_state()["target_temperature_low"] is None + assert entity.get_state()["target_temperature_high"] is None + assert entity.get_state()["target_temperature"] is None + assert thrm_cluster.write_attributes.await_count == 0 + + +async def test_occupancy_reset( + device_climate_sinope: Device, + zha_gateway: Gateway, +): + """Test away preset reset.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_sinope) + thrm_cluster = device_climate_sinope.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_sinope, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["preset_mode"] == "none" + + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + thrm_cluster.write_attributes.reset_mock() + + assert entity.get_state()["preset_mode"] == "away" + + await send_attributes_report( + zha_gateway, + thrm_cluster, + {"occupied_heating_setpoint": zigpy.types.uint16_t(1950)}, + ) + assert entity.get_state()["preset_mode"] == "none" + + +async def test_fan_mode( + device_climate_fan: Device, + zha_gateway: Gateway, +): + """Test fan mode.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan) + thrm_cluster = device_climate_fan.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_fan, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert set(entity.fan_modes) == {FanState.AUTO, FanState.ON} + assert entity.get_state()["fan_mode"] == FanState.AUTO + + await send_attributes_report( + zha_gateway, + thrm_cluster, + {"running_state": Thermostat.RunningState.Fan_State_On}, + ) + assert entity.get_state()["fan_mode"] == FanState.ON + + await send_attributes_report( + zha_gateway, thrm_cluster, {"running_state": Thermostat.RunningState.Idle} + ) + assert entity.get_state()["fan_mode"] == FanState.AUTO + + await send_attributes_report( + zha_gateway, + thrm_cluster, + {"running_state": Thermostat.RunningState.Fan_2nd_Stage_On}, + ) + assert entity.get_state()["fan_mode"] == FanState.ON + + +async def test_set_fan_mode_not_supported( + device_climate_fan: Device, + zha_gateway: Gateway, +): + """Test fan setting unsupported mode.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan) + fan_cluster = device_climate_fan.device.endpoints[1].fan + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_fan, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + await entity.async_set_fan_mode(FanState.LOW) + await zha_gateway.async_block_till_done() + assert fan_cluster.write_attributes.await_count == 0 + + +async def test_set_fan_mode( + device_climate_fan: Device, + zha_gateway: Gateway, +): + """Test fan mode setting.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan) + fan_cluster = device_climate_fan.device.endpoints[1].fan + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_fan, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["fan_mode"] == FanState.AUTO + + await entity.async_set_fan_mode(FanState.ON) + await zha_gateway.async_block_till_done() + + assert fan_cluster.write_attributes.await_count == 1 + assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 4} + + fan_cluster.write_attributes.reset_mock() + await entity.async_set_fan_mode(FanState.AUTO) + await zha_gateway.async_block_till_done() + assert fan_cluster.write_attributes.await_count == 1 + assert fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5} + + +async def test_set_moes_preset(device_climate_moes: Device, zha_gateway: Gateway): + """Test setting preset for moes trv.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_moes) + thrm_cluster = device_climate_moes.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_moes, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()["preset_mode"] == "none" + + await entity.async_set_preset_mode("away") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 0 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("Schedule") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 2 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 1 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("comfort") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 2 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 3 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("eco") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 2 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 4 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("boost") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 2 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 5 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("Complex") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 2 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 6 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("none") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 2 + } + + +async def test_set_moes_operation_mode( + device_climate_moes: Device, zha_gateway: Gateway +): + """Test setting preset for moes trv.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_moes) + thrm_cluster = device_climate_moes.device.endpoints[1].thermostat + assert entity_id is not None + entity: ThermostatEntity = get_entity(device_climate_moes, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 0}) + + assert entity.get_state()["preset_mode"] == "away" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 1}) + + assert entity.get_state()["preset_mode"] == "Schedule" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 2}) + + assert entity.get_state()["preset_mode"] == "none" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 3}) + + assert entity.get_state()["preset_mode"] == "comfort" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 4}) + + assert entity.get_state()["preset_mode"] == "eco" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 5}) + + assert entity.get_state()["preset_mode"] == "boost" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 6}) + + assert entity.get_state()["preset_mode"] == "Complex" + + +# Device is running an energy-saving mode +PRESET_ECO = "eco" + + +@pytest.mark.parametrize( + ("preset_attr", "preset_mode"), + [ + (0, PRESET_AWAY), + (1, PRESET_SCHEDULE), + # (2, PRESET_NONE), # TODO: why does this not work? + (4, PRESET_ECO), + (5, PRESET_BOOST), + (7, PRESET_TEMP_MANUAL), + ], +) +async def test_beca_operation_mode_update( + zha_gateway: Gateway, + device_climate_beca: Device, + preset_attr: int, + preset_mode: str, +) -> None: + """Test beca trv operation mode attribute update.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_beca) + thrm_cluster = device_climate_beca.device.endpoints[1].thermostat + entity: ThermostatEntity = get_entity(device_climate_beca, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + # Test sending an attribute report + await send_attributes_report( + zha_gateway, thrm_cluster, {"operation_preset": preset_attr} + ) + + assert entity.get_state()[ATTR_PRESET_MODE] == preset_mode + + await entity.async_set_preset_mode(preset_mode) + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.mock_calls == [ + call( + {"operation_preset": preset_attr}, + manufacturer=device_climate_beca.manufacturer_code, + ) + ] + + +async def test_set_zonnsmart_preset( + zha_gateway: Gateway, device_climate_zonnsmart +) -> None: + """Test setting preset from homeassistant for zonnsmart trv.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_zonnsmart) + thrm_cluster = device_climate_zonnsmart.device.endpoints[1].thermostat + entity: ThermostatEntity = get_entity(device_climate_zonnsmart, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + assert entity.get_state()[ATTR_PRESET_MODE] == PRESET_NONE + + await entity.async_set_preset_mode(PRESET_SCHEDULE) + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 0 + } + + thrm_cluster.write_attributes.reset_mock() + + await entity.async_set_preset_mode("holiday") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 1 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 3 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode("frost protect") + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 2 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 1 + } + assert thrm_cluster.write_attributes.call_args_list[1][0][0] == { + "operation_preset": 4 + } + + thrm_cluster.write_attributes.reset_mock() + await entity.async_set_preset_mode(PRESET_NONE) + await zha_gateway.async_block_till_done() + + assert thrm_cluster.write_attributes.await_count == 1 + assert thrm_cluster.write_attributes.call_args_list[0][0][0] == { + "operation_preset": 1 + } + + +async def test_set_zonnsmart_operation_mode( + zha_gateway: Gateway, device_climate_zonnsmart +) -> None: + """Test setting preset from trv for zonnsmart trv.""" + + entity_id = find_entity_id(Platform.CLIMATE, device_climate_zonnsmart) + thrm_cluster = device_climate_zonnsmart.device.endpoints[1].thermostat + entity: ThermostatEntity = get_entity(device_climate_zonnsmart, entity_id) + assert entity is not None + assert isinstance(entity, ThermostatEntity) + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 0}) + + assert entity.get_state()[ATTR_PRESET_MODE] == PRESET_SCHEDULE + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 1}) + + assert entity.get_state()[ATTR_PRESET_MODE] == PRESET_NONE + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 2}) + + assert entity.get_state()[ATTR_PRESET_MODE] == "holiday" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 3}) + + assert entity.get_state()[ATTR_PRESET_MODE] == "holiday" + + await send_attributes_report(zha_gateway, thrm_cluster, {"operation_preset": 4}) + + assert entity.get_state()[ATTR_PRESET_MODE] == "frost protect" diff --git a/tests/test_cluster_handlers.py b/tests/test_cluster_handlers.py new file mode 100644 index 00000000..4910333b --- /dev/null +++ b/tests/test_cluster_handlers.py @@ -0,0 +1,1401 @@ +"""Test ZHA Core cluster_handlers.""" + +# pylint:disable=redefined-outer-name,too-many-lines + +from collections.abc import Awaitable, Callable +import logging +import math +from types import NoneType +from unittest import mock +from unittest.mock import AsyncMock, MagicMock, call, patch + +import pytest +from zhaquirks.centralite.cl_3130 import CentraLite3130 +from zhaquirks.xiaomi.aqara.sensor_switch_aq3 import BUTTON_DEVICE_TYPE, SwitchAQ3 +from zigpy.device import Device as ZigpyDevice +from zigpy.endpoint import Endpoint as ZigpyEndpoint +import zigpy.profiles.zha +from zigpy.quirks import _DEVICE_REGISTRY +import zigpy.types as t +from zigpy.zcl import foundation +import zigpy.zcl.clusters +from zigpy.zcl.clusters import CLUSTERS_BY_ID +from zigpy.zcl.clusters.general import ( + Basic, + Identify, + LevelControl, + MultistateInput, + OnOff, + Ota, + PollControl, + PowerConfiguration, +) +from zigpy.zcl.clusters.homeautomation import Diagnostic +from zigpy.zcl.clusters.measurement import TemperatureMeasurement +import zigpy.zdo.types as zdo_t + +from tests.common import make_zcl_header, send_attributes_report +from zha.application.const import ATTR_QUIRK_ID +from zha.application.gateway import Gateway +from zha.exceptions import ZHAException +from zha.zigbee.cluster_handlers import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + ClusterHandlerStatus, + parse_and_log_command, + retry_request, +) +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, +) +from zha.zigbee.cluster_handlers.general import PollControlClusterHandler +from zha.zigbee.cluster_handlers.lighting import ColorClusterHandler +from zha.zigbee.cluster_handlers.lightlink import LightLinkClusterHandler +from zha.zigbee.cluster_handlers.registries import ( + CLIENT_CLUSTER_HANDLER_REGISTRY, + CLUSTER_HANDLER_REGISTRY, +) +from zha.zigbee.device import Device +from zha.zigbee.endpoint import Endpoint + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +@pytest.fixture +def ieee(): + """IEEE fixture.""" + return t.EUI64.deserialize(b"ieeeaddr")[0] + + +@pytest.fixture +def nwk(): + """NWK fixture.""" + return t.NWK(0xBEEF) + + +@pytest.fixture +def zigpy_coordinator_device( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Coordinator device fixture.""" + + coordinator = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [0x1000], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x1234, + SIG_EP_PROFILE: 0x0104, + } + }, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + nwk=0x0000, + ) + coordinator.add_to_group = AsyncMock(return_value=[0]) + return coordinator + + +@pytest.fixture +def endpoint(zigpy_coordinator_device: ZigpyDevice) -> Endpoint: + """Endpoint cluster_handlers fixture.""" + endpoint_mock = mock.MagicMock(spec_set=Endpoint) + endpoint_mock.zigpy_endpoint.device.application.get_device.return_value = ( + zigpy_coordinator_device + ) + endpoint_mock.device.skip_configuration = False + endpoint_mock.id = 1 + return endpoint_mock + + +@pytest.fixture +def poll_control_ch( + endpoint: Endpoint, zigpy_device_mock: Callable[..., ZigpyDevice] +) -> PollControlClusterHandler: + """Poll control cluster_handler fixture.""" + cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x1234, + SIG_EP_PROFILE: 0x0104, + } + }, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get(cluster_id).get(None) + return cluster_handler_class(cluster, endpoint) + + +@pytest.fixture +async def poll_control_device( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> Device: + """Poll control device fixture.""" + cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x1234, + SIG_EP_PROFILE: 0x0104, + } + }, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + zha_device = await device_joined(zigpy_dev) + return zha_device + + +@pytest.mark.parametrize( + ("cluster_id", "bind_count", "attrs"), + [ + (zigpy.zcl.clusters.general.Basic.cluster_id, 0, {}), + ( + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + 1, + {"battery_voltage", "battery_percentage_remaining"}, + ), + ( + zigpy.zcl.clusters.general.DeviceTemperature.cluster_id, + 1, + {"current_temperature"}, + ), + (zigpy.zcl.clusters.general.Identify.cluster_id, 0, {}), + (zigpy.zcl.clusters.general.Groups.cluster_id, 0, {}), + (zigpy.zcl.clusters.general.Scenes.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.OnOff.cluster_id, 1, {"on_off"}), + (zigpy.zcl.clusters.general.OnOffConfiguration.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.LevelControl.cluster_id, 1, {"current_level"}), + (zigpy.zcl.clusters.general.Alarms.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.AnalogInput.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.AnalogOutput.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.AnalogValue.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.AnalogOutput.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.BinaryOutput.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.BinaryValue.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.MultistateInput.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.MultistateOutput.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.MultistateValue.cluster_id, 1, {"present_value"}), + (zigpy.zcl.clusters.general.Commissioning.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.Partition.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.Ota.cluster_id, 0, {}), + (zigpy.zcl.clusters.general.PowerProfile.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.ApplianceControl.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.PollControl.cluster_id, 1, {}), + (zigpy.zcl.clusters.general.GreenPowerProxy.cluster_id, 0, {}), + (zigpy.zcl.clusters.closures.DoorLock.cluster_id, 1, {"lock_state"}), + ( + zigpy.zcl.clusters.hvac.Thermostat.cluster_id, + 1, + { + "local_temperature", + "occupied_cooling_setpoint", + "occupied_heating_setpoint", + "unoccupied_cooling_setpoint", + "unoccupied_heating_setpoint", + "running_mode", + "running_state", + "system_mode", + "occupancy", + "pi_cooling_demand", + "pi_heating_demand", + }, + ), + (zigpy.zcl.clusters.hvac.Fan.cluster_id, 1, {"fan_mode"}), + ( + zigpy.zcl.clusters.lighting.Color.cluster_id, + 1, + { + "current_x", + "current_y", + "color_temperature", + "current_hue", + "enhanced_current_hue", + "current_saturation", + }, + ), + ( + zigpy.zcl.clusters.measurement.IlluminanceMeasurement.cluster_id, + 1, + {"measured_value"}, + ), + ( + zigpy.zcl.clusters.measurement.IlluminanceLevelSensing.cluster_id, + 1, + {"level_status"}, + ), + ( + zigpy.zcl.clusters.measurement.TemperatureMeasurement.cluster_id, + 1, + {"measured_value"}, + ), + ( + zigpy.zcl.clusters.measurement.PressureMeasurement.cluster_id, + 1, + {"measured_value"}, + ), + ( + zigpy.zcl.clusters.measurement.FlowMeasurement.cluster_id, + 1, + {"measured_value"}, + ), + ( + zigpy.zcl.clusters.measurement.RelativeHumidity.cluster_id, + 1, + {"measured_value"}, + ), + (zigpy.zcl.clusters.measurement.OccupancySensing.cluster_id, 1, {"occupancy"}), + ( + zigpy.zcl.clusters.smartenergy.Metering.cluster_id, + 1, + { + "instantaneous_demand", + "current_summ_delivered", + "current_tier1_summ_delivered", + "current_tier2_summ_delivered", + "current_tier3_summ_delivered", + "current_tier4_summ_delivered", + "current_tier5_summ_delivered", + "current_tier6_summ_delivered", + "current_summ_received", + "status", + }, + ), + ( + zigpy.zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id, + 1, + { + "active_power", + "active_power_max", + "apparent_power", + "rms_current", + "rms_current_max", + "rms_voltage", + "rms_voltage_max", + }, + ), + ], +) +async def test_in_cluster_handler_config( + cluster_id, + bind_count, + attrs, + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument +) -> None: + """Test ZHA core cluster handler configuration for input clusters.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_INPUT: [cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None, ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + assert cluster_handler.status == ClusterHandlerStatus.CREATED + + await cluster_handler.async_configure() + + assert cluster_handler.status == ClusterHandlerStatus.CONFIGURED + + assert cluster.bind.call_count == bind_count + assert cluster.configure_reporting.call_count == 0 + assert cluster.configure_reporting_multiple.call_count == math.ceil(len(attrs) / 3) + reported_attrs = { + a + for a in attrs + for attr in cluster.configure_reporting_multiple.call_args_list + for attrs in attr[0][0] + } + assert set(attrs) == reported_attrs + + +async def test_cluster_handler_bind_error( + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ZHA core cluster handler bind error.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_INPUT: [OnOff.cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster: zigpy.zcl.Cluster = zigpy_dev.endpoints[1].in_clusters[OnOff.cluster_id] + cluster.bind.side_effect = zigpy.exceptions.ZigbeeException + + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + OnOff.cluster_id, {None, ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + await cluster_handler.async_configure() + + assert cluster.bind.await_count == 1 + assert cluster.configure_reporting.await_count == 0 + assert f"Failed to bind '{cluster.ep_attribute}' cluster:" in caplog.text + + +async def test_cluster_handler_configure_reporting_error( + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ZHA core cluster handler configure reporting error.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_INPUT: [OnOff.cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster: zigpy.zcl.Cluster = zigpy_dev.endpoints[1].in_clusters[OnOff.cluster_id] + cluster.configure_reporting_multiple.side_effect = zigpy.exceptions.ZigbeeException + + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + OnOff.cluster_id, {None, ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + await cluster_handler.async_configure() + + assert cluster.bind.await_count == 1 + assert cluster.configure_reporting_multiple.await_count == 1 + assert f"failed to set reporting on '{cluster.ep_attribute}' cluster" in caplog.text + + +async def test_write_attributes_safe_key_error( + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument +) -> None: + """Test ZHA core cluster handler write attributes safe key error.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_INPUT: [OnOff.cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster: zigpy.zcl.Cluster = zigpy_dev.endpoints[1].in_clusters[OnOff.cluster_id] + cluster.write_attributes = AsyncMock( + return_value=[ + foundation.WriteAttributesResponse.deserialize(b"\x01\x10\x00")[0] + ] + ) + + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + OnOff.cluster_id, {None, ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + with pytest.raises(ZHAException, match="Failed to write attribute 0x0010=unknown"): + await cluster_handler.write_attributes_safe({0x0010: "bar"}) + + +async def test_get_attributes_error( + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ZHA core cluster handler get attributes timeout error.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_INPUT: [OnOff.cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster: zigpy.zcl.Cluster = zigpy_dev.endpoints[1].in_clusters[OnOff.cluster_id] + cluster.read_attributes.side_effect = zigpy.exceptions.ZigbeeException + + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + OnOff.cluster_id, {None, ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + await cluster_handler.get_attributes(["foo"]) + + assert ( + f"failed to get attributes '['foo']' on '{OnOff.ep_attribute}' cluster" + in caplog.text + ) + + +async def test_get_attributes_error_raises( + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument + caplog: pytest.LogCaptureFixture, +) -> None: + """Test ZHA core cluster handler get attributes timeout error.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_INPUT: [OnOff.cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster: zigpy.zcl.Cluster = zigpy_dev.endpoints[1].in_clusters[OnOff.cluster_id] + cluster.read_attributes.side_effect = zigpy.exceptions.ZigbeeException + + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + OnOff.cluster_id, {None, ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + with pytest.raises(zigpy.exceptions.ZigbeeException): + await cluster_handler._get_attributes(True, ["foo"]) + + assert ( + f"failed to get attributes '['foo']' on '{OnOff.ep_attribute}' cluster" + in caplog.text + ) + + +@pytest.mark.parametrize( + ("cluster_id", "bind_count"), + [ + (0x0000, 0), + (0x0001, 1), + (0x0002, 1), + (0x0003, 0), + (0x0004, 0), + (0x0005, 1), + (0x0006, 1), + (0x0007, 1), + (0x0008, 1), + (0x0009, 1), + (0x0015, 1), + (0x0016, 1), + (0x0019, 0), + (0x001A, 1), + (0x001B, 1), + (0x0020, 1), + (0x0021, 0), + (0x0101, 1), + (0x0202, 1), + (0x0300, 1), + (0x0400, 1), + (0x0402, 1), + (0x0403, 1), + (0x0405, 1), + (0x0406, 1), + (0x0702, 1), + (0x0B04, 1), + ], +) +async def test_out_cluster_handler_config( + cluster_id: int, + bind_count: int, + endpoint: Endpoint, + zigpy_device_mock, + zha_gateway: Gateway, # pylint: disable=unused-argument +) -> None: + """Test ZHA core cluster handler configuration for output clusters.""" + zigpy_dev = zigpy_device_mock( + {1: {SIG_EP_OUTPUT: [cluster_id], SIG_EP_INPUT: [], SIG_EP_TYPE: 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] + cluster.bind_only = True + cluster_handler_class = CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} + ).get(None) + cluster_handler = cluster_handler_class(cluster, endpoint) + + await cluster_handler.async_configure() + + assert cluster.bind.call_count == bind_count + assert cluster.configure_reporting.call_count == 0 + + +def test_cluster_handler_registry() -> None: + """Test ZIGBEE cluster handler Registry.""" + + # get all quirk ID from zigpy quirks registry + all_quirk_ids = {} + for cluster_id in CLUSTERS_BY_ID: + all_quirk_ids[cluster_id] = {None} + for manufacturer in _DEVICE_REGISTRY.registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, ATTR_QUIRK_ID, None) + device_description = getattr(quirk, "replacement", None) or getattr( + quirk, "signature", None + ) + + for endpoint in device_description["endpoints"].values(): + cluster_ids = set() + if "input_clusters" in endpoint: + cluster_ids.update(endpoint["input_clusters"]) + if "output_clusters" in endpoint: + cluster_ids.update(endpoint["output_clusters"]) + for cluster_id in cluster_ids: + if not isinstance(cluster_id, int): + cluster_id = cluster_id.cluster_id + if cluster_id not in all_quirk_ids: + all_quirk_ids[cluster_id] = {None} + all_quirk_ids[cluster_id].add(quirk_id) + + # TODO make sure this is needed + del quirk, model_quirk_list, manufacturer # pylint: disable=undefined-loop-variable + + for ( + cluster_id, + cluster_handler_classes, + ) in CLUSTER_HANDLER_REGISTRY.items(): + assert isinstance(cluster_id, int) + assert 0 <= cluster_id <= 0xFFFF + assert cluster_id in all_quirk_ids + assert isinstance(cluster_handler_classes, dict) + for quirk_id, cluster_handler in cluster_handler_classes.items(): + assert isinstance(quirk_id, (NoneType, str)) + assert issubclass(cluster_handler, ClusterHandler) + assert quirk_id in all_quirk_ids[cluster_id] + + +def test_epch_unclaimed_cluster_handlers(cluster_handler) -> None: + """Test unclaimed cluster handlers.""" + + ch_1 = cluster_handler(CLUSTER_HANDLER_ON_OFF, 6) + ch_2 = cluster_handler(CLUSTER_HANDLER_LEVEL, 8) + ch_3 = cluster_handler(CLUSTER_HANDLER_COLOR, 768) + + ep_cluster_handlers = Endpoint( + mock.MagicMock(spec_set=ZigpyEndpoint), mock.MagicMock(spec_set=Device) + ) + all_cluster_handlers = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + with mock.patch.dict( + ep_cluster_handlers.all_cluster_handlers, all_cluster_handlers, clear=True + ): + available = ep_cluster_handlers.unclaimed_cluster_handlers() + assert ch_1 in available + assert ch_2 in available + assert ch_3 in available + + ep_cluster_handlers.claimed_cluster_handlers[ch_2.id] = ch_2 + available = ep_cluster_handlers.unclaimed_cluster_handlers() + assert ch_1 in available + assert ch_2 not in available + assert ch_3 in available + + ep_cluster_handlers.claimed_cluster_handlers[ch_1.id] = ch_1 + available = ep_cluster_handlers.unclaimed_cluster_handlers() + assert ch_1 not in available + assert ch_2 not in available + assert ch_3 in available + + ep_cluster_handlers.claimed_cluster_handlers[ch_3.id] = ch_3 + available = ep_cluster_handlers.unclaimed_cluster_handlers() + assert ch_1 not in available + assert ch_2 not in available + assert ch_3 not in available + + +def test_epch_claim_cluster_handlers(cluster_handler) -> None: + """Test cluster handler claiming.""" + + ch_1 = cluster_handler(CLUSTER_HANDLER_ON_OFF, 6) + ch_2 = cluster_handler(CLUSTER_HANDLER_LEVEL, 8) + ch_3 = cluster_handler(CLUSTER_HANDLER_COLOR, 768) + + ep_cluster_handlers = Endpoint( + mock.MagicMock(spec_set=ZigpyEndpoint), mock.MagicMock(spec_set=Device) + ) + all_cluster_handlers = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + with mock.patch.dict( + ep_cluster_handlers.all_cluster_handlers, all_cluster_handlers, clear=True + ): + assert ch_1.id not in ep_cluster_handlers.claimed_cluster_handlers + assert ch_2.id not in ep_cluster_handlers.claimed_cluster_handlers + assert ch_3.id not in ep_cluster_handlers.claimed_cluster_handlers + + ep_cluster_handlers.claim_cluster_handlers([ch_2]) + assert ch_1.id not in ep_cluster_handlers.claimed_cluster_handlers + assert ch_2.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_2.id] is ch_2 + assert ch_3.id not in ep_cluster_handlers.claimed_cluster_handlers + + ep_cluster_handlers.claim_cluster_handlers([ch_3, ch_1]) + assert ch_1.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_1.id] is ch_1 + assert ch_2.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_2.id] is ch_2 + assert ch_3.id in ep_cluster_handlers.claimed_cluster_handlers + assert ep_cluster_handlers.claimed_cluster_handlers[ch_3.id] is ch_3 + assert "1:0x0300" in ep_cluster_handlers.claimed_cluster_handlers + + +@mock.patch("zha.zigbee.endpoint.Endpoint.add_client_cluster_handlers") +@mock.patch( + "zha.application.discovery.ENDPOINT_PROBE.discover_entities", + mock.MagicMock(), +) +async def test_ep_cluster_handlers_all_cluster_handlers( + m1, # pylint: disable=unused-argument + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> None: + """Test Endpointcluster_handlers adding all cluster_handlers.""" + zha_device = await device_joined( + zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [0, 1, 6, 8], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: 0x0104, + }, + 2: { + SIG_EP_INPUT: [0, 1, 6, 8, 768], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, + SIG_EP_PROFILE: 0x0104, + }, + } + ) + ) + assert "1:0x0000" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0001" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0006" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0008" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0300" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0000" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0001" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0006" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0008" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0300" not in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0000" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0001" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0006" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0008" not in zha_device._endpoints[2].all_cluster_handlers + assert "1:0x0300" not in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0000" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0006" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0008" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0300" in zha_device._endpoints[2].all_cluster_handlers + + +@mock.patch("zha.zigbee.endpoint.Endpoint.add_client_cluster_handlers") +@mock.patch( + "zha.application.discovery.ENDPOINT_PROBE.discover_entities", + mock.MagicMock(), +) +async def test_cluster_handler_power_config( + m1, # pylint: disable=unused-argument + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> None: + """Test that cluster_handlers only get a single power cluster_handler.""" + in_clusters = [0, 1, 6, 8] + zha_device: Device = await device_joined( + zigpy_device_mock( + endpoints={ + 1: { + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, + SIG_EP_PROFILE: 0x0104, + }, + 2: { + SIG_EP_INPUT: [*in_clusters, 768], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, + SIG_EP_PROFILE: 0x0104, + }, + }, + ieee="01:2d:6f:00:0a:90:69:e8", + ) + ) + assert "1:0x0000" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0001" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0006" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0008" in zha_device._endpoints[1].all_cluster_handlers + assert "1:0x0300" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0000" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0006" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0008" in zha_device._endpoints[2].all_cluster_handlers + assert "2:0x0300" in zha_device._endpoints[2].all_cluster_handlers + + zha_device = await device_joined( + zigpy_device_mock( + endpoints={ + 1: { + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, + SIG_EP_PROFILE: 0x0104, + }, + 2: { + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, + SIG_EP_PROFILE: 0x0104, + }, + }, + ieee="02:2d:6f:00:0a:90:69:e8", + ) + ) + assert "1:0x0001" not in zha_device._endpoints[1].all_cluster_handlers + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + + zha_device = await device_joined( + zigpy_device_mock( + endpoints={ + 2: { + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x0000, + SIG_EP_PROFILE: 0x0104, + } + }, + ieee="03:2d:6f:00:0a:90:69:e8", + ) + ) + assert "2:0x0001" in zha_device._endpoints[2].all_cluster_handlers + + +async def test_ep_cluster_handlers_configure(cluster_handler) -> None: + """Test unclaimed cluster handlers.""" + + ch_1 = cluster_handler(CLUSTER_HANDLER_ON_OFF, 6) + ch_2 = cluster_handler(CLUSTER_HANDLER_LEVEL, 8) + ch_3 = cluster_handler(CLUSTER_HANDLER_COLOR, 768) + ch_3.async_configure = AsyncMock(side_effect=TimeoutError) + ch_3.async_initialize = AsyncMock(side_effect=TimeoutError) + ch_4 = cluster_handler(CLUSTER_HANDLER_ON_OFF, 6) + ch_5 = cluster_handler(CLUSTER_HANDLER_LEVEL, 8) + ch_5.async_configure = AsyncMock(side_effect=TimeoutError) + ch_5.async_initialize = AsyncMock(side_effect=TimeoutError) + + endpoint_mock = mock.MagicMock(spec_set=ZigpyEndpoint) + type(endpoint_mock).in_clusters = mock.PropertyMock(return_value={}) + type(endpoint_mock).out_clusters = mock.PropertyMock(return_value={}) + type(endpoint_mock).profile_id = mock.PropertyMock( + return_value=zigpy.profiles.zha.PROFILE_ID + ) + type(endpoint_mock).device_type = mock.PropertyMock( + return_value=zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT + ) + zha_dev = mock.MagicMock(spec_set=Device) + type(zha_dev).quirk_id = mock.PropertyMock(return_value=None) + endpoint = Endpoint.new(endpoint_mock, zha_dev) + + claimed = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + client_handlers = {ch_4.id: ch_4, ch_5.id: ch_5} + + with ( + mock.patch.dict(endpoint.claimed_cluster_handlers, claimed, clear=True), + mock.patch.dict(endpoint.client_cluster_handlers, client_handlers, clear=True), + ): + await endpoint.async_configure() + await endpoint.async_initialize(mock.sentinel.from_cache) + + for ch in [*claimed.values(), *client_handlers.values()]: + assert ch.async_initialize.call_count == 1 + assert ch.async_initialize.await_count == 1 + assert ch.async_initialize.call_args[0][0] is mock.sentinel.from_cache + assert ch.async_configure.call_count == 1 + assert ch.async_configure.await_count == 1 + + assert ch_3.debug.call_count == 2 + assert ch_5.debug.call_count == 2 + + +async def test_poll_control_configure( + poll_control_ch: PollControlClusterHandler, +) -> None: + """Test poll control cluster_handler configuration.""" + await poll_control_ch.async_configure() + assert poll_control_ch.cluster.write_attributes.call_count == 1 + assert poll_control_ch.cluster.write_attributes.call_args[0][0] == { + "checkin_interval": poll_control_ch.CHECKIN_INTERVAL + } + + +async def test_poll_control_checkin_response( + poll_control_ch: PollControlClusterHandler, +) -> None: + """Test poll control cluster_handler checkin response.""" + rsp_mock = AsyncMock() + set_interval_mock = AsyncMock() + fast_poll_mock = AsyncMock() + cluster = poll_control_ch.cluster + patch_1 = mock.patch.object(cluster, "checkin_response", rsp_mock) + patch_2 = mock.patch.object(cluster, "set_long_poll_interval", set_interval_mock) + patch_3 = mock.patch.object(cluster, "fast_poll_stop", fast_poll_mock) + + with patch_1, patch_2, patch_3: + await poll_control_ch.check_in_response(33) + + assert rsp_mock.call_count == 1 + assert set_interval_mock.call_count == 1 + assert fast_poll_mock.call_count == 1 + + await poll_control_ch.check_in_response(33) + assert cluster.endpoint.request.call_count == 3 + assert cluster.endpoint.request.await_count == 3 + assert cluster.endpoint.request.call_args_list[0][0][1] == 33 + assert cluster.endpoint.request.call_args_list[0][0][0] == 0x0020 + assert cluster.endpoint.request.call_args_list[1][0][0] == 0x0020 + + +async def test_poll_control_cluster_command(poll_control_device: Device) -> None: + """Test poll control cluster_handler response to cluster command.""" + checkin_mock = AsyncMock() + poll_control_ch = poll_control_device._endpoints[1].all_cluster_handlers["1:0x0020"] + cluster = poll_control_ch.cluster + # events = async_capture_events("zha_event") + + poll_control_ch.emit_zha_event = MagicMock(wraps=poll_control_ch.emit_zha_event) + with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock): + tsn = 22 + hdr = make_zcl_header(0, global_command=False, tsn=tsn) + cluster.handle_message( + hdr, [mock.sentinel.args, mock.sentinel.args2, mock.sentinel.args3] + ) + await poll_control_device.gateway.async_block_till_done() + + assert checkin_mock.call_count == 1 + assert checkin_mock.await_count == 1 + assert checkin_mock.await_args[0][0] == tsn + + assert poll_control_ch.emit_zha_event.call_count == 1 + assert poll_control_ch.emit_zha_event.call_args_list[0] == mock.call( + "checkin", [mock.sentinel.args, mock.sentinel.args2, mock.sentinel.args3] + ) + + +async def test_poll_control_ignore_list(poll_control_device: Device) -> None: + """Test poll control cluster_handler ignore list.""" + set_long_poll_mock = AsyncMock() + poll_control_ch = poll_control_device._endpoints[1].all_cluster_handlers["1:0x0020"] + cluster = poll_control_ch.cluster + + with mock.patch.object(cluster, "set_long_poll_interval", set_long_poll_mock): + await poll_control_ch.check_in_response(33) + + assert set_long_poll_mock.call_count == 1 + + set_long_poll_mock.reset_mock() + poll_control_ch.skip_manufacturer_id(4151) + with mock.patch.object(cluster, "set_long_poll_interval", set_long_poll_mock): + await poll_control_ch.check_in_response(33) + + assert set_long_poll_mock.call_count == 0 + + +async def test_poll_control_ikea(poll_control_device: Device) -> None: + """Test poll control cluster_handler ignore list for ikea.""" + set_long_poll_mock = AsyncMock() + poll_control_ch = poll_control_device._endpoints[1].all_cluster_handlers["1:0x0020"] + cluster = poll_control_ch.cluster + + poll_control_device.device.node_desc.manufacturer_code = 4476 + with mock.patch.object(cluster, "set_long_poll_interval", set_long_poll_mock): + await poll_control_ch.check_in_response(33) + + assert set_long_poll_mock.call_count == 0 + + +@pytest.fixture +def zigpy_zll_device(zigpy_device_mock: Callable[..., ZigpyDevice]) -> ZigpyDevice: + """ZLL device fixture.""" + + return zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [0x1000], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: 0x1234, + SIG_EP_PROFILE: zigpy.profiles.zll.PROFILE_ID, + } + }, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + +async def test_zll_device_groups( + zigpy_zll_device: ZigpyDevice, + endpoint: Endpoint, + zigpy_coordinator_device: ZigpyDevice, +) -> None: + """Test adding coordinator to ZLL groups.""" + + cluster = zigpy_zll_device.endpoints[1].lightlink + cluster_handler = LightLinkClusterHandler(cluster, endpoint) + get_group_identifiers_rsp = zigpy.zcl.clusters.lightlink.LightLink.commands_by_name[ + "get_group_identifiers_rsp" + ].schema + + with patch.object( + cluster, + "get_group_identifiers", + AsyncMock( + return_value=get_group_identifiers_rsp( + total=0, start_index=0, group_info_records=[] + ) + ), + ) as get_group_identifiers: + await cluster_handler.async_configure() + assert get_group_identifiers.await_count == 1 + assert cluster.bind.call_count == 0 + assert zigpy_coordinator_device.add_to_group.await_count == 1 + assert zigpy_coordinator_device.add_to_group.await_args[0][0] == 0x0000 + + zigpy_coordinator_device.add_to_group.reset_mock() + group_1 = zigpy.zcl.clusters.lightlink.GroupInfoRecord(0xABCD, 0x00) + group_2 = zigpy.zcl.clusters.lightlink.GroupInfoRecord(0xAABB, 0x00) + with patch.object( + cluster, + "get_group_identifiers", + AsyncMock( + return_value=get_group_identifiers_rsp( + total=2, start_index=0, group_info_records=[group_1, group_2] + ) + ), + ) as get_group_identifiers: + await cluster_handler.async_configure() + assert get_group_identifiers.await_count == 1 + assert cluster.bind.call_count == 0 + assert zigpy_coordinator_device.add_to_group.await_count == 2 + assert ( + zigpy_coordinator_device.add_to_group.await_args_list[0][0][0] + == group_1.group_id + ) + assert ( + zigpy_coordinator_device.add_to_group.await_args_list[1][0][0] + == group_2.group_id + ) + + +@mock.patch( + "zha.application.discovery.ENDPOINT_PROBE.discover_entities", + mock.MagicMock(), +) +async def test_cluster_no_ep_attribute( + zha_gateway: Gateway, # pylint: disable=unused-argument + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[..., Device], +) -> None: + """Test cluster handlers for clusters without ep_attribute.""" + + zha_device = await device_joined( + zigpy_device_mock( + {1: {SIG_EP_INPUT: [0x042E], SIG_EP_OUTPUT: [], SIG_EP_TYPE: 0x1234}} + ) + ) + + assert "1:0x042e" in zha_device._endpoints[1].all_cluster_handlers + assert zha_device._endpoints[1].all_cluster_handlers["1:0x042e"].name + + +async def test_configure_reporting(zha_gateway: Gateway, endpoint: Endpoint) -> None: # pylint: disable=unused-argument + """Test setting up a cluster handler and configuring attribute reporting in two batches.""" + + class TestZigbeeClusterHandler(ClusterHandler): + """Test cluster handler that requests reporting for four attributes.""" + + BIND = True + REPORT_CONFIG = ( + # By name + AttrReportConfig(attr="current_x", config=(1, 60, 1)), + AttrReportConfig(attr="current_hue", config=(1, 60, 2)), + AttrReportConfig(attr="color_temperature", config=(1, 60, 3)), + AttrReportConfig(attr="current_y", config=(1, 60, 4)), + ) + + mock_ep = mock.AsyncMock(spec_set=zigpy.endpoint.Endpoint) + mock_ep.device.zdo = AsyncMock() + + cluster = zigpy.zcl.clusters.lighting.Color(mock_ep) + cluster.bind = AsyncMock( + spec_set=cluster.bind, + return_value=[zdo_t.Status.SUCCESS], # ZDOCmd.Bind_rsp + ) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + cluster_handler = TestZigbeeClusterHandler(cluster, endpoint) + await cluster_handler.async_configure() + + # Since we request reporting for five attributes, we need to make two calls (3 + 1) + assert cluster.configure_reporting_multiple.mock_calls == [ + mock.call( + { + "current_x": (1, 60, 1), + "current_hue": (1, 60, 2), + "color_temperature": (1, 60, 3), + } + ), + mock.call( + { + "current_y": (1, 60, 4), + } + ), + ] + + +async def test_invalid_cluster_handler(zha_gateway: Gateway, caplog) -> None: # pylint: disable=unused-argument + """Test setting up a cluster handler that fails to match properly.""" + + class TestZigbeeClusterHandler(ClusterHandler): + """Test cluster handler that fails to match properly.""" + + REPORT_CONFIG = (AttrReportConfig(attr="missing_attr", config=(1, 60, 1)),) + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=Device) + mock_zha_device.quirk_id = None + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + # The cluster handler throws an error when matching this cluster + with pytest.raises(KeyError): + TestZigbeeClusterHandler(cluster, zha_endpoint) + + # And one is also logged at runtime + with ( + patch.dict( + CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {None: TestZigbeeClusterHandler}, + ), + caplog.at_level(logging.WARNING), + ): + zha_endpoint.add_all_cluster_handlers() + + assert "missing_attr" in caplog.text + + +async def test_standard_cluster_handler(zha_gateway: Gateway) -> None: # pylint: disable=unused-argument + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + """Test cluster handler that matches a standard cluster.""" + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=Device) + mock_zha_device.quirk_id = None + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], ColorClusterHandler + ) + + +async def test_quirk_id_cluster_handler(zha_gateway: Gateway) -> None: # pylint: disable=unused-argument + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + """Test cluster handler that matches a standard cluster.""" + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=Device) + mock_zha_device.quirk_id = "__test_quirk_id" + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], TestZigbeeClusterHandler + ) + + +# parametrize side effects: +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (zigpy.exceptions.ZigbeeException(), "Failed to send request"), + ( + zigpy.exceptions.ZigbeeException("Zigbee exception"), + "Failed to send request: Zigbee exception", + ), + (TimeoutError(), "Failed to send request: device did not respond"), + ], +) +async def test_retry_request( + side_effect: Exception | None, expected_error: str | None +) -> None: + """Test the `retry_request` decorator's handling of zigpy-internal exceptions.""" + + async def func(arg1: int, arg2: int) -> int: + assert arg1 == 1 + assert arg2 == 2 + + raise side_effect + + func = mock.AsyncMock(wraps=func) + decorated_func = retry_request(func) + + with pytest.raises(ZHAException) as exc: + await decorated_func(1, arg2=2) + + assert func.await_count == 3 + assert isinstance(exc.value, ZHAException) + assert str(exc.value) == expected_error + + +async def test_cluster_handler_naming() -> None: + """Test that all cluster handlers are named appropriately.""" + for client_cluster_handler in CLIENT_CLUSTER_HANDLER_REGISTRY.values(): + assert issubclass(client_cluster_handler, ClientClusterHandler) + assert client_cluster_handler.__name__.endswith("ClientClusterHandler") + + for cluster_handler_dict in CLUSTER_HANDLER_REGISTRY.values(): + for cluster_handler in cluster_handler_dict.values(): + assert not issubclass(cluster_handler, ClientClusterHandler) + assert cluster_handler.__name__.endswith("ClusterHandler") + + +def test_parse_and_log_command(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses a known command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop" + + +def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811 + """Test that `parse_and_log_command` correctly parses an unknown command.""" + assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB" + + +async def test_zha_send_event_from_quirk( + zha_gateway: Gateway, + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +): + """Test that a quirk can send an event.""" + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + OnOff.cluster_id, + MultistateInput.cluster_id, + ], + SIG_EP_OUTPUT: [Basic.cluster_id], + SIG_EP_TYPE: BUTTON_DEVICE_TYPE, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + model="lumi.sensor_switch.aq3", + manufacturer="LUMI", + quirk=SwitchAQ3, + ) + + assert isinstance(zigpy_device, SwitchAQ3) + + zha_device = await device_joined(zigpy_device) + + ms_input_ch = zha_device.endpoints[1].all_cluster_handlers["1:0x0012"] + assert ms_input_ch is not None + + ms_input_ch.zha_send_event = MagicMock(wraps=ms_input_ch.zha_send_event) + + await send_attributes_report(zha_gateway, ms_input_ch.cluster, {0x0055: 0x01}) + + assert ms_input_ch.zha_send_event.call_count == 1 + assert ms_input_ch.zha_send_event.mock_calls == [ + call( + "single", + { + "value": 1.0, + }, + ) + ] + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + PollControl.cluster_id, + TemperatureMeasurement.cluster_id, + Diagnostic.cluster_id, + ], + SIG_EP_OUTPUT: [ + Identify.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + ], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + model="LIGHTIFY Dimming Switch", + manufacturer="OSRAM", + quirk=CentraLite3130, + ieee="00:15:8e:01:01:02:03:04", + ) + + assert isinstance(zigpy_device, CentraLite3130) + + zha_device = await device_joined(zigpy_device) + + on_off_ch = zha_device.endpoints[1].client_cluster_handlers["1:0x0006"] + assert on_off_ch is not None + + on_off_ch.emit_zha_event = MagicMock(wraps=on_off_ch.emit_zha_event) + on_off_ch.emit_zha_event.reset_mock() + + on_off_ch.cluster_command(1, OnOff.ServerCommandDefs.on.id, []) + + assert on_off_ch.emit_zha_event.call_count == 1 + assert on_off_ch.emit_zha_event.mock_calls == [call("on", [])] + on_off_ch.emit_zha_event.reset_mock() + + await send_attributes_report( + zha_gateway, on_off_ch.cluster, {OnOff.AttributeDefs.on_off.name: 0x01} + ) + + assert on_off_ch.emit_zha_event.call_count == 1 + assert on_off_ch.emit_zha_event.mock_calls == [ + call( + "attribute_updated", + {"attribute_id": 0, "attribute_name": "on_off", "attribute_value": True}, + ) + ] + + on_off_ch.emit_zha_event.reset_mock() + + await send_attributes_report(zha_gateway, on_off_ch.cluster, {0x25: "Bar"}) + + assert on_off_ch.emit_zha_event.call_count == 1 + assert on_off_ch.emit_zha_event.mock_calls == [ + call( + "attribute_updated", + { + "attribute_id": 0x25, + "attribute_name": "Unknown", + "attribute_value": "Bar", + }, + ) + ] + + +async def test_zdo_cluster_handler( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +): + """Test that a quirk can send an event.""" + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + OnOff.cluster_id, + MultistateInput.cluster_id, + ], + SIG_EP_OUTPUT: [Basic.cluster_id], + SIG_EP_TYPE: BUTTON_DEVICE_TYPE, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + model="lumi.sensor_switch.aq3", + manufacturer="LUMI", + quirk=SwitchAQ3, + ) + + assert isinstance(zigpy_device, SwitchAQ3) + + zha_device = await device_joined(zigpy_device) + + assert zha_device.zdo_cluster_handler is not None + assert zha_device.zdo_cluster_handler.status == ClusterHandlerStatus.INITIALIZED + assert zha_device.zdo_cluster_handler.cluster is not None + assert zha_device.zdo_cluster_handler.cluster == zigpy_device.endpoints[0] + assert ( + zha_device.zdo_cluster_handler.unique_id + == f"{str(zha_device.ieee)}:{zha_device.name}_ZDO" + ) diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 00000000..af136d35 --- /dev/null +++ b/tests/test_color.py @@ -0,0 +1,579 @@ +"""Test Home Assistant color util methods.""" + +import pytest +import voluptuous as vol + +import zha.application.platforms.light.helpers as color_util + +GAMUT = color_util.GamutType( + color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08), +) +GAMUT_INVALID_1 = color_util.GamutType( + color_util.XYPoint(0.704, 0.296), + color_util.XYPoint(-0.201, 0.7106), + color_util.XYPoint(0.138, 0.08), +) +GAMUT_INVALID_2 = color_util.GamutType( + color_util.XYPoint(0.704, 1.296), + color_util.XYPoint(0.2151, 0.7106), + color_util.XYPoint(0.138, 0.08), +) +GAMUT_INVALID_3 = color_util.GamutType( + color_util.XYPoint(0.0, 0.0), + color_util.XYPoint(0.0, 0.0), + color_util.XYPoint(0.0, 0.0), +) +GAMUT_INVALID_4 = color_util.GamutType( + color_util.XYPoint(0.1, 0.1), + color_util.XYPoint(0.3, 0.3), + color_util.XYPoint(0.7, 0.7), +) + + +# pylint: disable=invalid-name +def test_color_RGB_to_xy_brightness(): + """Test color_RGB_to_xy_brightness.""" + assert color_util.color_RGB_to_xy_brightness(0, 0, 0) == (0, 0, 0) + assert color_util.color_RGB_to_xy_brightness(255, 255, 255) == (0.323, 0.329, 255) + + assert color_util.color_RGB_to_xy_brightness(0, 0, 255) == (0.136, 0.04, 12) + + assert color_util.color_RGB_to_xy_brightness(0, 255, 0) == (0.172, 0.747, 170) + + assert color_util.color_RGB_to_xy_brightness(255, 0, 0) == (0.701, 0.299, 72) + + assert color_util.color_RGB_to_xy_brightness(128, 0, 0) == (0.701, 0.299, 16) + + assert color_util.color_RGB_to_xy_brightness(255, 0, 0, GAMUT) == (0.7, 0.299, 72) + + assert color_util.color_RGB_to_xy_brightness(0, 255, 0, GAMUT) == ( + 0.215, + 0.711, + 170, + ) + + assert color_util.color_RGB_to_xy_brightness(0, 0, 255, GAMUT) == (0.138, 0.08, 12) + + +def test_color_RGB_to_xy(): + """Test color_RGB_to_xy.""" + assert color_util.color_RGB_to_xy(0, 0, 0) == (0, 0) + assert color_util.color_RGB_to_xy(255, 255, 255) == (0.323, 0.329) + + assert color_util.color_RGB_to_xy(0, 0, 255) == (0.136, 0.04) + + assert color_util.color_RGB_to_xy(0, 255, 0) == (0.172, 0.747) + + assert color_util.color_RGB_to_xy(255, 0, 0) == (0.701, 0.299) + + assert color_util.color_RGB_to_xy(128, 0, 0) == (0.701, 0.299) + + assert color_util.color_RGB_to_xy(0, 0, 255, GAMUT) == (0.138, 0.08) + + assert color_util.color_RGB_to_xy(0, 255, 0, GAMUT) == (0.215, 0.711) + + assert color_util.color_RGB_to_xy(255, 0, 0, GAMUT) == (0.7, 0.299) + + +def test_color_xy_brightness_to_RGB(): + """Test color_xy_brightness_to_RGB.""" + assert color_util.color_xy_brightness_to_RGB(1, 1, 0) == (0, 0, 0) + + assert color_util.color_xy_brightness_to_RGB(0.35, 0.35, 128) == (194, 186, 169) + + assert color_util.color_xy_brightness_to_RGB(0.35, 0.35, 255) == (255, 243, 222) + + assert color_util.color_xy_brightness_to_RGB(1, 0, 255) == (255, 0, 60) + + assert color_util.color_xy_brightness_to_RGB(0, 1, 255) == (0, 255, 0) + + assert color_util.color_xy_brightness_to_RGB(0, 0, 255) == (0, 63, 255) + + assert color_util.color_xy_brightness_to_RGB(1, 0, 255, GAMUT) == (255, 0, 3) + + assert color_util.color_xy_brightness_to_RGB(0, 1, 255, GAMUT) == (82, 255, 0) + + assert color_util.color_xy_brightness_to_RGB(0, 0, 255, GAMUT) == (9, 85, 255) + + +def test_color_xy_to_RGB(): + """Test color_xy_to_RGB.""" + assert color_util.color_xy_to_RGB(0.35, 0.35) == (255, 243, 222) + + assert color_util.color_xy_to_RGB(1, 0) == (255, 0, 60) + + assert color_util.color_xy_to_RGB(0, 1) == (0, 255, 0) + + assert color_util.color_xy_to_RGB(0, 0) == (0, 63, 255) + + assert color_util.color_xy_to_RGB(1, 0, GAMUT) == (255, 0, 3) + + assert color_util.color_xy_to_RGB(0, 1, GAMUT) == (82, 255, 0) + + assert color_util.color_xy_to_RGB(0, 0, GAMUT) == (9, 85, 255) + + +def test_color_RGB_to_hsv(): + """Test color_RGB_to_hsv.""" + assert color_util.color_RGB_to_hsv(0, 0, 0) == (0, 0, 0) + + assert color_util.color_RGB_to_hsv(255, 255, 255) == (0, 0, 100) + + assert color_util.color_RGB_to_hsv(0, 0, 255) == (240, 100, 100) + + assert color_util.color_RGB_to_hsv(0, 255, 0) == (120, 100, 100) + + assert color_util.color_RGB_to_hsv(255, 0, 0) == (0, 100, 100) + + +def test_color_hsv_to_RGB(): + """Test color_hsv_to_RGB.""" + assert color_util.color_hsv_to_RGB(0, 0, 0) == (0, 0, 0) + + assert color_util.color_hsv_to_RGB(0, 0, 100) == (255, 255, 255) + + assert color_util.color_hsv_to_RGB(240, 100, 100) == (0, 0, 255) + + assert color_util.color_hsv_to_RGB(120, 100, 100) == (0, 255, 0) + + assert color_util.color_hsv_to_RGB(0, 100, 100) == (255, 0, 0) + + +def test_color_hsb_to_RGB(): + """Test color_hsb_to_RGB.""" + assert color_util.color_hsb_to_RGB(0, 0, 0) == (0, 0, 0) + + assert color_util.color_hsb_to_RGB(0, 0, 1.0) == (255, 255, 255) + + assert color_util.color_hsb_to_RGB(240, 1.0, 1.0) == (0, 0, 255) + + assert color_util.color_hsb_to_RGB(120, 1.0, 1.0) == (0, 255, 0) + + assert color_util.color_hsb_to_RGB(0, 1.0, 1.0) == (255, 0, 0) + + +def test_color_xy_to_hs(): + """Test color_xy_to_hs.""" + assert color_util.color_xy_to_hs(1, 1) == (47.294, 100) + + assert color_util.color_xy_to_hs(0.35, 0.35) == (38.182, 12.941) + + assert color_util.color_xy_to_hs(1, 0) == (345.882, 100) + + assert color_util.color_xy_to_hs(0, 1) == (120, 100) + + assert color_util.color_xy_to_hs(0, 0) == (225.176, 100) + + assert color_util.color_xy_to_hs(1, 0, GAMUT) == (359.294, 100) + + assert color_util.color_xy_to_hs(0, 1, GAMUT) == (100.706, 100) + + assert color_util.color_xy_to_hs(0, 0, GAMUT) == (221.463, 96.471) + + +def test_color_hs_to_xy(): + """Test color_hs_to_xy.""" + assert color_util.color_hs_to_xy(180, 100) == (0.151, 0.343) + + assert color_util.color_hs_to_xy(350, 12.5) == (0.356, 0.321) + + assert color_util.color_hs_to_xy(140, 50) == (0.229, 0.474) + + assert color_util.color_hs_to_xy(0, 40) == (0.474, 0.317) + + assert color_util.color_hs_to_xy(360, 0) == (0.323, 0.329) + + assert color_util.color_hs_to_xy(0, 100, GAMUT) == (0.7, 0.299) + + assert color_util.color_hs_to_xy(120, 100, GAMUT) == (0.215, 0.711) + + assert color_util.color_hs_to_xy(180, 100, GAMUT) == (0.17, 0.34) + + assert color_util.color_hs_to_xy(240, 100, GAMUT) == (0.138, 0.08) + + assert color_util.color_hs_to_xy(360, 100, GAMUT) == (0.7, 0.299) + + +def test_rgb_hex_to_rgb_list(): + """Test rgb_hex_to_rgb_list.""" + assert [255, 255, 255] == color_util.rgb_hex_to_rgb_list("ffffff") + + assert [0, 0, 0] == color_util.rgb_hex_to_rgb_list("000000") + + assert [255, 255, 255, 255] == color_util.rgb_hex_to_rgb_list("ffffffff") + + assert [0, 0, 0, 0] == color_util.rgb_hex_to_rgb_list("00000000") + + assert [51, 153, 255] == color_util.rgb_hex_to_rgb_list("3399ff") + + assert [51, 153, 255, 0] == color_util.rgb_hex_to_rgb_list("3399ff00") + + +def test_color_name_to_rgb_valid_name(): + """Test color_name_to_rgb.""" + assert color_util.color_name_to_rgb("red") == (255, 0, 0) + + assert color_util.color_name_to_rgb("blue") == (0, 0, 255) + + assert color_util.color_name_to_rgb("green") == (0, 128, 0) + + # spaces in the name + assert color_util.color_name_to_rgb("dark slate blue") == (72, 61, 139) + + # spaces removed from name + assert color_util.color_name_to_rgb("darkslateblue") == (72, 61, 139) + assert color_util.color_name_to_rgb("dark slateblue") == (72, 61, 139) + assert color_util.color_name_to_rgb("darkslate blue") == (72, 61, 139) + + +def test_color_name_to_rgb_unknown_name_raises_value_error(): + """Test color_name_to_rgb.""" + with pytest.raises(ValueError): + color_util.color_name_to_rgb("not a color") + + +def test_color_rgb_to_rgbw(): + """Test color_rgb_to_rgbw.""" + assert color_util.color_rgb_to_rgbw(0, 0, 0) == (0, 0, 0, 0) + + assert color_util.color_rgb_to_rgbw(255, 255, 255) == (0, 0, 0, 255) + + assert color_util.color_rgb_to_rgbw(255, 0, 0) == (255, 0, 0, 0) + + assert color_util.color_rgb_to_rgbw(0, 255, 0) == (0, 255, 0, 0) + + assert color_util.color_rgb_to_rgbw(0, 0, 255) == (0, 0, 255, 0) + + assert color_util.color_rgb_to_rgbw(255, 127, 0) == (255, 127, 0, 0) + + assert color_util.color_rgb_to_rgbw(255, 127, 127) == (255, 0, 0, 253) + + assert color_util.color_rgb_to_rgbw(127, 127, 127) == (0, 0, 0, 127) + + +def test_color_rgbw_to_rgb(): + """Test color_rgbw_to_rgb.""" + assert color_util.color_rgbw_to_rgb(0, 0, 0, 0) == (0, 0, 0) + + assert color_util.color_rgbw_to_rgb(0, 0, 0, 255) == (255, 255, 255) + + assert color_util.color_rgbw_to_rgb(255, 0, 0, 0) == (255, 0, 0) + + assert color_util.color_rgbw_to_rgb(0, 255, 0, 0) == (0, 255, 0) + + assert color_util.color_rgbw_to_rgb(0, 0, 255, 0) == (0, 0, 255) + + assert color_util.color_rgbw_to_rgb(255, 127, 0, 0) == (255, 127, 0) + + assert color_util.color_rgbw_to_rgb(255, 0, 0, 253) == (255, 127, 127) + + assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) + + +def test_color_rgb_to_hex(): + """Test color_rgb_to_hex.""" + assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff" + assert color_util.color_rgb_to_hex(0, 0, 0) == "000000" + assert color_util.color_rgb_to_hex(51, 153, 255) == "3399ff" + assert color_util.color_rgb_to_hex(255, 67.9204190, 0) == "ff4400" + + +def test_match_max_scale(): + """Test match_max_scale.""" + match_max_scale = color_util.match_max_scale + assert match_max_scale((255, 255, 255), (255, 255, 255)) == (255, 255, 255) + assert match_max_scale((0, 0, 0), (0, 0, 0)) == (0, 0, 0) + assert match_max_scale((255, 255, 255), (128, 128, 128)) == (255, 255, 255) + assert match_max_scale((0, 255, 0), (64, 128, 128)) == (128, 255, 255) + assert match_max_scale((0, 100, 0), (128, 64, 64)) == (100, 50, 50) + assert match_max_scale((10, 20, 33), (100, 200, 333)) == (10, 20, 33) + assert match_max_scale((255,), (100, 200, 333)) == (77, 153, 255) + assert match_max_scale((128,), (10.5, 20.9, 30.4)) == (44, 88, 128) + assert match_max_scale((10, 20, 30, 128), (100, 200, 333)) == (38, 77, 128) + + +def test_gamut(): + """Test gamut functions.""" + assert color_util.check_valid_gamut(GAMUT) + assert not color_util.check_valid_gamut(GAMUT_INVALID_1) + assert not color_util.check_valid_gamut(GAMUT_INVALID_2) + assert not color_util.check_valid_gamut(GAMUT_INVALID_3) + assert not color_util.check_valid_gamut(GAMUT_INVALID_4) + + +def test_color_temperature_mired_to_kelvin(): + """Test color_temperature_mired_to_kelvin.""" + assert color_util.color_temperature_mired_to_kelvin(40) == 25000 + assert color_util.color_temperature_mired_to_kelvin(200) == 5000 + with pytest.raises(ZeroDivisionError): + assert color_util.color_temperature_mired_to_kelvin(0) + + +def test_color_temperature_kelvin_to_mired(): + """Test color_temperature_kelvin_to_mired.""" + assert color_util.color_temperature_kelvin_to_mired(25000) == 40 + assert color_util.color_temperature_kelvin_to_mired(5000) == 200 + with pytest.raises(ZeroDivisionError): + assert color_util.color_temperature_kelvin_to_mired(0) + + +def test_returns_same_value_for_any_two_temperatures_below_1000(): + """Function should return same value for 999 Kelvin and 0 Kelvin.""" + rgb_1 = color_util.color_temperature_to_rgb(999) + rgb_2 = color_util.color_temperature_to_rgb(0) + assert rgb_1 == rgb_2 + + +def test_returns_same_value_for_any_two_temperatures_above_40000(): + """Function should return same value for 40001K and 999999K.""" + rgb_1 = color_util.color_temperature_to_rgb(40001) + rgb_2 = color_util.color_temperature_to_rgb(999999) + assert rgb_1 == rgb_2 + + +def test_should_return_pure_white_at_6600(): + """Function should return red=255, blue=255, green=255 when given 6600K. + + 6600K is considered "pure white" light. + This is just a rough estimate because the formula itself is a "best + guess" approach. + """ + rgb = color_util.color_temperature_to_rgb(6600) + assert rgb == (255, 255, 255) + + +def test_color_above_6600_should_have_more_blue_than_red_or_green(): + """Function should return a higher blue value for blue-ish light.""" + rgb = color_util.color_temperature_to_rgb(6700) + assert rgb[2] > rgb[1] + assert rgb[2] > rgb[0] + + +def test_color_below_6600_should_have_more_red_than_blue_or_green(): + """Function should return a higher red value for red-ish light.""" + rgb = color_util.color_temperature_to_rgb(6500) + assert rgb[0] > rgb[1] + assert rgb[0] > rgb[2] + + +def test_get_color_in_voluptuous(): + """Test using the get method in color validation.""" + schema = vol.Schema(color_util.color_name_to_rgb) + + with pytest.raises(vol.Invalid): + schema("not a color") + + assert schema("red") == (255, 0, 0) + + +def test_color_rgb_to_rgbww(): + """Test color_rgb_to_rgbww conversions.""" + assert color_util.color_rgb_to_rgbww(255, 255, 255, 154, 370) == ( + 0, + 54, + 98, + 255, + 255, + ) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 100, 1000) == ( + 255, + 255, + 255, + 0, + 0, + ) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 1000) == ( + 0, + 118, + 241, + 255, + 255, + ) + assert color_util.color_rgb_to_rgbww(128, 128, 128, 154, 370) == ( + 0, + 27, + 49, + 128, + 128, + ) + assert color_util.color_rgb_to_rgbww(64, 64, 64, 154, 370) == (0, 14, 25, 64, 64) + assert color_util.color_rgb_to_rgbww(32, 64, 16, 154, 370) == (9, 64, 0, 38, 38) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 154, 370) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(0, 0, 0, 0, 100) == (0, 0, 0, 0, 0) + assert color_util.color_rgb_to_rgbww(255, 255, 255, 1, 5) == (103, 69, 0, 255, 255) + + +def test_color_rgbww_to_rgb(): + """Test color_rgbww_to_rgb conversions.""" + assert color_util.color_rgbww_to_rgb(0, 54, 98, 255, 255, 154, 370) == ( + 255, + 255, + 255, + ) + assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 154, 370) == ( + 255, + 255, + 255, + ) + assert color_util.color_rgbww_to_rgb(0, 118, 241, 255, 255, 154, 370) == ( + 163, + 204, + 255, + ) + assert color_util.color_rgbww_to_rgb(0, 27, 49, 128, 128, 154, 370) == ( + 128, + 128, + 128, + ) + assert color_util.color_rgbww_to_rgb(0, 14, 25, 64, 64, 154, 370) == (64, 64, 64) + assert color_util.color_rgbww_to_rgb(9, 64, 0, 38, 38, 154, 370) == (32, 64, 16) + assert color_util.color_rgbww_to_rgb(0, 0, 0, 0, 0, 154, 370) == (0, 0, 0) + assert color_util.color_rgbww_to_rgb(103, 69, 0, 255, 255, 153, 370) == ( + 255, + 193, + 112, + ) + assert color_util.color_rgbww_to_rgb(255, 255, 255, 0, 0, 0, 0) == (255, 255, 255) + assert color_util.color_rgbww_to_rgb(255, 255, 255, 255, 255, 0, 0) == ( + 255, + 161, + 128, + ) + assert color_util.color_rgbww_to_rgb(255, 255, 255, 255, 255, 0, 370) == ( + 255, + 245, + 237, + ) + + +def test_color_temperature_to_rgbww(): + """Test color temp to warm, cold conversion. + + Temperature values must be in mireds + Home Assistant uses rgbcw for rgbww + """ + assert color_util.color_temperature_to_rgbww(153, 255, 153, 500) == ( + 0, + 0, + 0, + 255, + 0, + ) + assert color_util.color_temperature_to_rgbww(153, 128, 153, 500) == ( + 0, + 0, + 0, + 128, + 0, + ) + assert color_util.color_temperature_to_rgbww(500, 255, 153, 500) == ( + 0, + 0, + 0, + 0, + 255, + ) + assert color_util.color_temperature_to_rgbww(500, 128, 153, 500) == ( + 0, + 0, + 0, + 0, + 128, + ) + assert color_util.color_temperature_to_rgbww(347, 255, 153, 500) == ( + 0, + 0, + 0, + 112, + 143, + ) + assert color_util.color_temperature_to_rgbww(347, 128, 153, 500) == ( + 0, + 0, + 0, + 56, + 72, + ) + + +def test_rgbww_to_color_temperature(): + """Test rgbww conversion to color temp. + + Temperature values must be in mireds + Home Assistant uses rgbcw for rgbww + """ + assert color_util.rgbww_to_color_temperature( + ( + 0, + 0, + 0, + 255, + 0, + ), + 153, + 500, + ) == (153, 255) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 128, 0), 153, 500) == ( + 153, + 128, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 255), 153, 500) == ( + 500, + 255, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 128), 153, 500) == ( + 500, + 128, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 112, 143), 153, 500) == ( + 348, + 255, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 56, 72), 153, 500) == ( + 348, + 128, + ) + assert color_util.rgbww_to_color_temperature((0, 0, 0, 0, 0), 153, 500) == ( + 500, + 0, + ) + + +def test_white_levels_to_color_temperature(): + """Test warm, cold conversion to color temp. + + Temperature values must be in mireds + Home Assistant uses rgbcw for rgbww + """ + assert color_util.while_levels_to_color_temperature( + 255, + 0, + 153, + 500, + ) == (153, 255) + assert color_util.while_levels_to_color_temperature(128, 0, 153, 500) == ( + 153, + 128, + ) + assert color_util.while_levels_to_color_temperature(0, 255, 153, 500) == ( + 500, + 255, + ) + assert color_util.while_levels_to_color_temperature(0, 128, 153, 500) == ( + 500, + 128, + ) + assert color_util.while_levels_to_color_temperature(112, 143, 153, 500) == ( + 348, + 255, + ) + assert color_util.while_levels_to_color_temperature(56, 72, 153, 500) == ( + 348, + 128, + ) + assert color_util.while_levels_to_color_temperature(0, 0, 153, 500) == ( + 500, + 0, + ) diff --git a/tests/test_cover.py b/tests/test_cover.py new file mode 100644 index 00000000..7e0da052 --- /dev/null +++ b/tests/test_cover.py @@ -0,0 +1,813 @@ +"""Test zha cover.""" + +# pylint: disable=redefined-outer-name + +import asyncio +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from slugify import slugify +from zigpy.device import Device as ZigpyDevice +import zigpy.profiles.zha +import zigpy.types +from zigpy.zcl.clusters import closures, general +import zigpy.zcl.foundation as zcl_f + +from tests.common import ( + find_entity_id, + make_zcl_header, + send_attributes_report, + update_attribute_cache, +) +from tests.conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zha.application import Platform +from zha.application.const import ATTR_COMMAND +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.platforms.cover import ( + ATTR_CURRENT_POSITION, + STATE_CLOSED, + STATE_OPEN, +) +from zha.application.platforms.cover.const import STATE_CLOSING, STATE_OPENING +from zha.exceptions import ZHAException +from zha.zigbee.device import Device + +Default_Response = zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Default_Response].schema + + +@pytest.fixture +def zigpy_cover_device( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Zigpy cover device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE, + SIG_EP_INPUT: [closures.WindowCovering.cluster_id], + SIG_EP_OUTPUT: [], + } + } + return zigpy_device_mock(endpoints) + + +@pytest.fixture +def zigpy_cover_remote( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Zigpy cover remote device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_CONTROLLER, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [closures.WindowCovering.cluster_id], + } + } + return zigpy_device_mock(endpoints) + + +@pytest.fixture +def zigpy_shade_device( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Zigpy shade device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SHADE, + SIG_EP_INPUT: [ + closures.Shade.cluster_id, + general.LevelControl.cluster_id, + general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [], + } + } + return zigpy_device_mock(endpoints) + + +@pytest.fixture +def zigpy_keen_vent( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Zigpy Keen Vent device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT, + SIG_EP_INPUT: [general.LevelControl.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [], + } + } + return zigpy_device_mock( + endpoints, manufacturer="Keen Home Inc", model="SV02-612-MP-1.3" + ) + + +WCAttrs = closures.WindowCovering.AttributeDefs +WCCmds = closures.WindowCovering.ServerCommandDefs +WCT = closures.WindowCovering.WindowCoveringType +WCCS = closures.WindowCovering.ConfigStatus + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +async def test_cover_non_tilt_initial_state( # pylint: disable=unused-argument + zha_gateway: Gateway, + device_joined, + zigpy_cover_device, +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.window_covering_type.name: WCT.Drapery, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await device_joined(zigpy_cover_device) + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.COVER, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + state = entity.get_state() + assert state["state"] == STATE_OPEN + assert state[ATTR_CURRENT_POSITION] == 100 + + # test update + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 100, + WCAttrs.window_covering_type.name: WCT.Drapery, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + prev_call_count = cluster.read_attributes.call_count + await entity.async_update() + assert cluster.read_attributes.call_count == prev_call_count + 1 + + assert entity.get_state()["state"] == STATE_CLOSED + assert entity.get_state()[ATTR_CURRENT_POSITION] == 0 + + +async def test_cover( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_cover_device: ZigpyDevice, + zha_gateway: Gateway, +) -> None: + """Test zha cover platform.""" + + cluster = zigpy_cover_device.endpoints.get(1).window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await device_joined(zigpy_cover_device) + + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + + assert cluster.read_attributes.call_count == 3 + + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.COVER, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test that the state has changed from unavailable to off + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 100} + ) + assert entity.get_state()["state"] == STATE_CLOSED + + # test to see if it opens + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) + assert entity.get_state()["state"] == STATE_OPEN + + # test that the state remains after tilting to 100% + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + assert entity.get_state()["state"] == STATE_OPEN + + # test to see the state remains after tilting to 0% + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + assert entity.get_state()["state"] == STATE_OPEN + + cluster.PLUGGED_ATTR_READS = {1: 100} + update_attribute_cache(cluster) + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["state"] == STATE_OPEN + + # close from client + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): + await entity.async_close_cover() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x01 + assert cluster.request.call_args[0][2].command.name == WCCmds.down_close.name + assert cluster.request.call_args[1]["expect_reply"] is True + + assert entity.get_state()["state"] == STATE_CLOSING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 100} + ) + + assert entity.get_state()["state"] == STATE_CLOSED + + # tilt close from client + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): + await entity.async_close_cover_tilt() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + + assert entity.get_state()["state"] == STATE_CLOSING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_tilt_percentage.id: 100} + ) + + assert entity.get_state()["state"] == STATE_CLOSED + + # open from client + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): + await entity.async_open_cover() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x00 + assert cluster.request.call_args[0][2].command.name == WCCmds.up_open.name + assert cluster.request.call_args[1]["expect_reply"] is True + + assert entity.get_state()["state"] == STATE_OPENING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) + + assert entity.get_state()["state"] == STATE_OPEN + + # open tilt from client + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): + await entity.async_open_cover_tilt() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) + assert cluster.request.call_args[0][3] == 0 + assert cluster.request.call_args[1]["expect_reply"] is True + + assert entity.get_state()["state"] == STATE_OPENING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + + assert entity.get_state()["state"] == STATE_OPEN + + # set position UI + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): + await entity.async_set_cover_position(position=47) + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x05 + assert cluster.request.call_args[0][2].command.name == "go_to_lift_percentage" + assert cluster.request.call_args[0][3] == 53 + assert cluster.request.call_args[1]["expect_reply"] is True + + assert entity.get_state()["state"] == STATE_CLOSING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + ) + + assert entity.get_state()["state"] == STATE_CLOSING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + ) + + assert entity.get_state()["state"] == STATE_OPEN + + # set tilt position UI + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): + await entity.async_set_cover_tilt_position(tilt_position=47) + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert ( + cluster.request.call_args[0][2].command.name + == WCCmds.go_to_tilt_percentage.name + ) + assert cluster.request.call_args[0][3] == 53 + assert cluster.request.call_args[1]["expect_reply"] is True + + assert entity.get_state()["state"] == STATE_CLOSING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 35} + ) + + assert entity.get_state()["state"] == STATE_CLOSING + + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 53} + ) + + assert entity.get_state()["state"] == STATE_OPEN + + # stop from client + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await entity.async_stop_cover() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x02 + assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name + assert cluster.request.call_args[1]["expect_reply"] is True + + # stop tilt from client + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await entity.async_stop_cover_tilt() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x02 + assert cluster.request.call_args[0][2].command.name == WCCmds.stop.name + assert cluster.request.call_args[1]["expect_reply"] is True + + +async def test_cover_failures( + zha_gateway: Gateway, device_joined, zigpy_cover_device +) -> None: + """Test ZHA cover platform failure cases.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + } + update_attribute_cache(cluster) + zha_device = await device_joined(zigpy_cover_device) + + entity_id = find_entity_id(Platform.COVER, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test to see if it opens + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_lift_percentage.id: 0} + ) + + assert entity.get_state()["state"] == STATE_OPEN + + # close from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.down_close.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to close cover"): + await entity.async_close_cover() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.down_close.id + ) + assert entity.get_state()["state"] == STATE_OPEN + + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to close cover tilt"): + await entity.async_close_cover_tilt() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + + # open from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.up_open.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to open cover"): + await entity.async_open_cover() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.up_open.id + ) + + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to open cover tilt"): + await entity.async_open_cover_tilt() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + + # set position UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to set cover position"): + await entity.async_set_cover_position(position=47) + await zha_gateway.async_block_till_done() + + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id + ) + + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to set cover tilt position"): + await entity.async_set_cover_tilt_position(tilt_position=47) + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + + # stop from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.stop.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to stop cover"): + await entity.async_stop_cover() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.stop.id + ) + + # stop tilt from UI + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.stop.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(ZHAException, match=r"Failed to stop cover"): + await entity.async_stop_cover_tilt() + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.stop.id + ) + + +async def test_shade( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_shade_device: ZigpyDevice, + zha_gateway: Gateway, +) -> None: + """Test zha cover platform for shade device type.""" + + zha_device = await device_joined(zigpy_shade_device) + cluster_on_off = zigpy_shade_device.endpoints.get(1).on_off + cluster_level = zigpy_shade_device.endpoints.get(1).level + entity_id = find_entity_id(Platform.COVER, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test that the state has changed from unavailable to off + await send_attributes_report( + zha_gateway, cluster_on_off, {cluster_on_off.AttributeDefs.on_off.id: 0} + ) + assert entity.get_state()["state"] == STATE_CLOSED + + # test to see if it opens + await send_attributes_report( + zha_gateway, cluster_on_off, {cluster_on_off.AttributeDefs.on_off.id: 1} + ) + assert entity.get_state()["state"] == STATE_OPEN + + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["state"] == STATE_OPEN + + # close from client command fails + with ( + patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=general.OnOff.ServerCommandDefs.off.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ), + pytest.raises(ZHAException, match="Failed to close cover"), + ): + await entity.async_close_cover() + await zha_gateway.async_block_till_done() + assert cluster_on_off.request.call_count == 1 + assert cluster_on_off.request.call_args[0][0] is False + assert cluster_on_off.request.call_args[0][1] == 0x0000 + assert entity.get_state()["state"] == STATE_OPEN + + with patch( + "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x1, zcl_f.Status.SUCCESS]) + ): + await entity.async_close_cover() + await zha_gateway.async_block_till_done() + assert cluster_on_off.request.call_count == 1 + assert cluster_on_off.request.call_args[0][0] is False + assert cluster_on_off.request.call_args[0][1] == 0x0000 + assert entity.get_state()["state"] == STATE_CLOSED + + # open from client command fails + await send_attributes_report(zha_gateway, cluster_level, {0: 0}) + assert entity.get_state()["state"] == STATE_CLOSED + + with ( + patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=general.OnOff.ServerCommandDefs.on.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ), + pytest.raises(ZHAException, match="Failed to open cover"), + ): + await entity.async_open_cover() + await zha_gateway.async_block_till_done() + assert cluster_on_off.request.call_count == 1 + assert cluster_on_off.request.call_args[0][0] is False + assert cluster_on_off.request.call_args[0][1] == 0x0001 + assert entity.get_state()["state"] == STATE_CLOSED + + # open from client succeeds + with patch( + "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x0, zcl_f.Status.SUCCESS]) + ): + await entity.async_open_cover() + await zha_gateway.async_block_till_done() + assert cluster_on_off.request.call_count == 1 + assert cluster_on_off.request.call_args[0][0] is False + assert cluster_on_off.request.call_args[0][1] == 0x0001 + assert entity.get_state()["state"] == STATE_OPEN + + # set position UI command fails + with ( + patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=general.LevelControl.ServerCommandDefs.move_to_level_with_on_off.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ), + pytest.raises(ZHAException, match="Failed to set cover position"), + ): + await entity.async_set_cover_position(position=47) + await zha_gateway.async_block_till_done() + assert cluster_level.request.call_count == 1 + assert cluster_level.request.call_args[0][0] is False + assert cluster_level.request.call_args[0][1] == 0x0004 + assert int(cluster_level.request.call_args[0][3] * 100 / 255) == 47 + assert entity.get_state()["current_position"] == 0 + + # set position UI success + with patch( + "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x5, zcl_f.Status.SUCCESS]) + ): + await entity.async_set_cover_position(position=47) + await zha_gateway.async_block_till_done() + assert cluster_level.request.call_count == 1 + assert cluster_level.request.call_args[0][0] is False + assert cluster_level.request.call_args[0][1] == 0x0004 + assert int(cluster_level.request.call_args[0][3] * 100 / 255) == 47 + assert entity.get_state()["current_position"] == 47 + + # report position change + await send_attributes_report(zha_gateway, cluster_level, {8: 0, 0: 100, 1: 1}) + assert entity.get_state()["current_position"] == int(100 * 100 / 255) + + # stop command fails + with ( + patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=general.LevelControl.ServerCommandDefs.stop.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ), + pytest.raises(ZHAException, match="Failed to stop cover"), + ): + await entity.async_stop_cover() + await zha_gateway.async_block_till_done() + assert cluster_level.request.call_count == 1 + assert cluster_level.request.call_args[0][0] is False + assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007) + + # test cover stop + with patch( + "zigpy.zcl.Cluster.request", AsyncMock(return_value=[0x0, zcl_f.Status.SUCCESS]) + ): + await entity.async_stop_cover() + await zha_gateway.async_block_till_done() + assert cluster_level.request.call_count == 1 + assert cluster_level.request.call_args[0][0] is False + assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007) + + +async def test_keen_vent( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_keen_vent: ZigpyDevice, + zha_gateway: Gateway, +) -> None: + """Test keen vent.""" + + zha_device = await device_joined(zigpy_keen_vent) + cluster_on_off = zigpy_keen_vent.endpoints.get(1).on_off + cluster_level = zigpy_keen_vent.endpoints.get(1).level + entity_id = find_entity_id(Platform.COVER, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test that the state has changed from unavailable to off + await send_attributes_report(zha_gateway, cluster_on_off, {8: 0, 0: False, 1: 1}) + assert entity.get_state()["state"] == STATE_CLOSED + + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["state"] == STATE_CLOSED + + # open from client command fails + p1 = patch.object(cluster_on_off, "request", side_effect=asyncio.TimeoutError) + p2 = patch.object(cluster_level, "request", AsyncMock(return_value=[4, 0])) + p3 = pytest.raises( + ZHAException, match="Failed to send request: device did not respond" + ) + + with p1, p2, p3: + await entity.async_open_cover() + await zha_gateway.async_block_till_done() + assert cluster_on_off.request.call_count == 1 + assert cluster_on_off.request.call_args[0][0] is False + assert cluster_on_off.request.call_args[0][1] == 0x0001 + assert cluster_level.request.call_count == 1 + assert entity.get_state()["state"] == STATE_CLOSED + + # open from client command success + p1 = patch.object(cluster_on_off, "request", AsyncMock(return_value=[1, 0])) + p2 = patch.object(cluster_level, "request", AsyncMock(return_value=[4, 0])) + + with p1, p2: + await entity.async_open_cover() + await zha_gateway.async_block_till_done() + assert cluster_on_off.request.call_count == 1 + assert cluster_on_off.request.call_args[0][0] is False + assert cluster_on_off.request.call_args[0][1] == 0x0001 + assert cluster_level.request.call_count == 1 + assert entity.get_state()["state"] == STATE_OPEN + assert entity.get_state()["current_position"] == 100 + + +async def test_cover_remote( + zha_gateway: Gateway, device_joined, zigpy_cover_remote +) -> None: + """Test ZHA cover remote.""" + + # load up cover domain + zha_device = await device_joined(zigpy_cover_remote) + zha_device.emit_zha_event = MagicMock(wraps=zha_device.emit_zha_event) + + cluster = zigpy_cover_remote.endpoints[1].out_clusters[ + closures.WindowCovering.cluster_id + ] + + zha_device.emit_zha_event.reset_mock() + + # up command + hdr = make_zcl_header(0, global_command=False) + cluster.handle_message(hdr, []) + await zha_gateway.async_block_till_done() + + assert zha_device.emit_zha_event.call_count == 1 + assert ATTR_COMMAND in zha_device.emit_zha_event.call_args[0][0] + assert zha_device.emit_zha_event.call_args[0][0][ATTR_COMMAND] == "up_open" + + zha_device.emit_zha_event.reset_mock() + + # down command + hdr = make_zcl_header(1, global_command=False) + cluster.handle_message(hdr, []) + await zha_gateway.async_block_till_done() + + assert zha_device.emit_zha_event.call_count == 1 + assert ATTR_COMMAND in zha_device.emit_zha_event.call_args[0][0] + assert zha_device.emit_zha_event.call_args[0][0][ATTR_COMMAND] == "down_close" diff --git a/tests/test_debouncer.py b/tests/test_debouncer.py new file mode 100644 index 00000000..f65aa7fb --- /dev/null +++ b/tests/test_debouncer.py @@ -0,0 +1,539 @@ +"""Tests for debouncer implementation.""" + +import asyncio +import logging +from unittest.mock import AsyncMock, Mock + +import pytest + +from zha.application.gateway import Gateway +from zha.debounce import Debouncer +from zha.decorators import callback + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.looptime +async def test_immediate_works(zha_gateway: Gateway) -> None: + """Test immediate works.""" + calls = [] + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=AsyncMock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function # type: ignore[unreachable] + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + await debouncer.async_call() + assert len(calls) == 2 + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +@pytest.mark.looptime +async def test_immediate_works_with_schedule_call(zha_gateway: Gateway) -> None: + """Test immediate works with scheduled calls.""" + calls = [] + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=AsyncMock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function # type: ignore[unreachable] + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +async def test_immediate_works_with_callback_function(zha_gateway: Gateway) -> None: + """Test immediate works with callback function.""" + calls = [] + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=callback(Mock(side_effect=lambda: calls.append(None))), + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + debouncer.async_cancel() + + +async def test_immediate_works_with_executor_function(zha_gateway: Gateway) -> None: + """Test immediate works with executor function.""" + calls = [] + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=Mock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + debouncer.async_cancel() + + +@pytest.mark.looptime +async def test_immediate_works_with_passed_callback_function_raises( + zha_gateway: Gateway, +) -> None: + """Test immediate works with a callback function that raises.""" + calls = [] + + @callback + def _append_and_raise() -> None: + calls.append(None) + raise RuntimeError("forced_raise") + + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=_append_and_raise, + ) + + # Call when nothing happening + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function # type: ignore[unreachable] + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 2 + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +@pytest.mark.looptime +async def test_immediate_works_with_passed_coroutine_raises( + zha_gateway: Gateway, +) -> None: + """Test immediate works with a coroutine that raises.""" + calls = [] + + async def _append_and_raise() -> None: + calls.append(None) + raise RuntimeError("forced_raise") + + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=_append_and_raise, + ) + + # Call when nothing happening + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function # type: ignore[unreachable] + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + + # Call and let timer run out + with pytest.raises(RuntimeError, match="forced_raise"): + await debouncer.async_call() + assert len(calls) == 2 + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job == before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +@pytest.mark.looptime +async def test_not_immediate_works(zha_gateway: Gateway) -> None: + """Test immediate works.""" + calls = [] + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.8, + immediate=False, + function=AsyncMock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + # Call while still on cooldown + await debouncer.async_call() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + # Canceling while on cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False # type: ignore[unreachable] + + # Call and let timer run out + await debouncer.async_call() + assert len(calls) == 0 + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Reset debouncer + debouncer.async_cancel() + + # Test calling doesn't schedule if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +@pytest.mark.looptime +async def test_not_immediate_works_schedule_call(zha_gateway: Gateway) -> None: + """Test immediate works with schedule call.""" + calls = [] + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.8, + immediate=False, + function=AsyncMock(side_effect=lambda: calls.append(None)), + ) + + # Call when nothing happening + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + # Call while still on cooldown + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 0 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + + # Canceling while on cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False # type: ignore[unreachable] + + # Call and let timer run out + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 0 + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Reset debouncer + debouncer.async_cancel() + + # Test calling doesn't schedule if currently executing. + await debouncer._execute_lock.acquire() + debouncer.async_schedule_call() + await zha_gateway.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +@pytest.mark.looptime +async def test_immediate_works_with_function_swapped(zha_gateway: Gateway) -> None: + """Test immediate works and we can change out the function.""" + calls = [] + + one_function = AsyncMock(side_effect=lambda: calls.append(1)) + two_function = AsyncMock(side_effect=lambda: calls.append(2)) + + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=True, + function=one_function, + ) + + # Call when nothing happening + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + # Call when cooldown active setting execute at end to True + await debouncer.async_call() + assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is True + assert debouncer._job.target == debouncer.function # type: ignore[unreachable] + + # Canceling debounce in cooldown + debouncer.async_cancel() + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + + before_job = debouncer._job + debouncer.function = two_function + + # Call and let timer run out + await debouncer.async_call() + assert len(calls) == 2 + assert calls == [1, 2] + await asyncio.sleep(1) + await zha_gateway.async_block_till_done() + assert len(calls) == 2 + assert calls == [1, 2] + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + assert debouncer._job.target == debouncer.function + assert debouncer._job != before_job + + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert calls == [1, 2] + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + assert debouncer._job.target == debouncer.function + + +async def test_shutdown(zha_gateway: Gateway, caplog: pytest.LogCaptureFixture) -> None: + """Test shutdown.""" + calls = [] + future = asyncio.Future() + + async def _func() -> None: + await future + calls.append(None) + + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.01, + immediate=False, + function=_func, + ) + + # Ensure shutdown during a run doesn't create a cooldown timer + zha_gateway.async_create_task(debouncer.async_call()) + await asyncio.sleep(0.01) + debouncer.async_shutdown() + future.set_result(True) + await zha_gateway.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is None + + assert "Debouncer call ignored as shutdown has been requested." not in caplog.text + await debouncer.async_call() + assert "Debouncer call ignored as shutdown has been requested." in caplog.text + + assert len(calls) == 1 + assert debouncer._timer_task is None + + +@pytest.mark.looptime +async def test_background(zha_gateway: Gateway) -> None: + """Test background tasks are created when background is True.""" + calls = [] + + async def _func() -> None: + await asyncio.sleep(0.5) + calls.append(None) + + debouncer = Debouncer( + zha_gateway, + _LOGGER, + cooldown=0.8, + immediate=True, + function=_func, + background=True, + ) + + await debouncer.async_call() + assert len(calls) == 1 + + debouncer.async_schedule_call() + assert len(calls) == 1 + + await asyncio.sleep(1) + await zha_gateway.async_block_till_done(wait_background_tasks=False) + assert len(calls) == 1 + + await zha_gateway.async_block_till_done(wait_background_tasks=True) + assert len(calls) == 2 + + await asyncio.sleep(1) + await zha_gateway.async_block_till_done(wait_background_tasks=False) + assert len(calls) == 2 diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 00000000..74209af6 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,267 @@ +"""Test ZHA device switch.""" + +import asyncio +from collections.abc import Awaitable, Callable +import logging +import time +from unittest import mock +from unittest.mock import patch + +import pytest +from zigpy.device import Device as ZigpyDevice +import zigpy.profiles.zha +import zigpy.types +from zigpy.zcl.clusters import general +import zigpy.zdo.types as zdo_t + +from tests.conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +from zha.application.gateway import Gateway +from zha.zigbee.device import Device + + +@pytest.fixture +def zigpy_device( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> Callable[..., ZigpyDevice]: + """Device tracker zigpy device.""" + + def _dev(with_basic_cluster_handler: bool = True, **kwargs): + in_clusters = [general.OnOff.cluster_id] + if with_basic_cluster_handler: + in_clusters.append(general.Basic.cluster_id) + + endpoints = { + 3: { + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + } + } + return zigpy_device_mock(endpoints, **kwargs) + + return _dev + + +@pytest.fixture +def zigpy_device_mains( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> Callable[..., ZigpyDevice]: + """Device tracker zigpy device.""" + + def _dev(with_basic_cluster_handler: bool = True): + in_clusters = [general.OnOff.cluster_id] + if with_basic_cluster_handler: + in_clusters.append(general.Basic.cluster_id) + + endpoints = { + 3: { + SIG_EP_INPUT: in_clusters, + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00" + ) + + return _dev + + +@pytest.fixture +def device_with_basic_cluster_handler( + zigpy_device_mains: Callable[..., ZigpyDevice], # pylint: disable=redefined-outer-name +) -> ZigpyDevice: + """Return a ZHA device with a basic cluster handler present.""" + return zigpy_device_mains(with_basic_cluster_handler=True) + + +@pytest.fixture +def device_without_basic_cluster_handler( + zigpy_device: Callable[..., ZigpyDevice], # pylint: disable=redefined-outer-name +) -> ZigpyDevice: + """Return a ZHA device without a basic cluster handler present.""" + return zigpy_device(with_basic_cluster_handler=False) + + +@pytest.fixture +async def ota_zha_device( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> Device: + """ZHA device with OTA cluster fixture.""" + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: 0x1234, + } + }, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + zha_device = await device_joined(zigpy_dev) + return zha_device + + +async def _send_time_changed(zha_gateway: Gateway, seconds: int): + """Send a time changed event.""" + await asyncio.sleep(seconds) + await zha_gateway.async_block_till_done() + + +@patch( + "zha.zigbee.cluster_handlers.general.BasicClusterHandler.async_initialize", + new=mock.AsyncMock(), +) +@pytest.mark.looptime +async def test_check_available_success( + zha_gateway: Gateway, + device_with_basic_cluster_handler: ZigpyDevice, # pylint: disable=redefined-outer-name + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> None: + """Check device availability success on 1st try.""" + zha_device = await device_joined(device_with_basic_cluster_handler) + basic_ch = device_with_basic_cluster_handler.endpoints[3].basic + + basic_ch.read_attributes.reset_mock() + device_with_basic_cluster_handler.last_seen = None + assert zha_device.available is True + await _send_time_changed(zha_gateway, zha_device.consider_unavailable_time + 2) + assert zha_device.available is False + assert basic_ch.read_attributes.await_count == 0 # type: ignore[unreachable] + + device_with_basic_cluster_handler.last_seen = ( + time.time() - zha_device.consider_unavailable_time - 100 + ) + _seens = [time.time(), device_with_basic_cluster_handler.last_seen] + + def _update_last_seen(*args, **kwargs): # pylint: disable=unused-argument + new_last_seen = _seens.pop() + device_with_basic_cluster_handler.last_seen = new_last_seen + + basic_ch.read_attributes.side_effect = _update_last_seen + + # successfully ping zigpy device, but zha_device is not yet available + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + assert basic_ch.read_attributes.await_count == 1 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is False + + # There was traffic from the device: pings, but not yet available + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is False + + # There was traffic from the device: don't try to ping, marked as available + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is True + + +@patch( + "zha.zigbee.cluster_handlers.general.BasicClusterHandler.async_initialize", + new=mock.AsyncMock(), +) +@pytest.mark.looptime +async def test_check_available_unsuccessful( + zha_gateway: Gateway, + device_with_basic_cluster_handler: ZigpyDevice, # pylint: disable=redefined-outer-name + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> None: + """Check device availability all tries fail.""" + + zha_device = await device_joined(device_with_basic_cluster_handler) + basic_ch = device_with_basic_cluster_handler.endpoints[3].basic + + assert zha_device.available is True + assert basic_ch.read_attributes.await_count == 0 + + device_with_basic_cluster_handler.last_seen = ( + time.time() - zha_device.consider_unavailable_time - 2 + ) + + # unsuccessfully ping zigpy device, but zha_device is still available + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + + assert basic_ch.read_attributes.await_count == 1 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is True + + # still no traffic, but zha_device is still available + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is True + + # not even trying to update, device is unavailable + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is False + + +@patch( + "zha.zigbee.cluster_handlers.general.BasicClusterHandler.async_initialize", + new=mock.AsyncMock(), +) +@pytest.mark.looptime +async def test_check_available_no_basic_cluster_handler( + zha_gateway: Gateway, + device_without_basic_cluster_handler: ZigpyDevice, # pylint: disable=redefined-outer-name + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + caplog: pytest.LogCaptureFixture, +) -> None: + """Check device availability for a device without basic cluster.""" + caplog.set_level(logging.DEBUG, logger="homeassistant.components.zha") + + zha_device = await device_joined(device_without_basic_cluster_handler) + + assert zha_device.available is True + + device_without_basic_cluster_handler.last_seen = ( + time.time() - zha_device.consider_unavailable_time - 2 + ) + + assert "does not have a mandatory basic cluster" not in caplog.text + await _send_time_changed(zha_gateway, zha_device.__polling_interval + 1) + + assert zha_device.available is False + assert "does not have a mandatory basic cluster" in caplog.text # type: ignore[unreachable] + + +async def test_device_is_active_coordinator( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: Callable[..., ZigpyDevice], # pylint: disable=redefined-outer-name +) -> None: + """Test that the current coordinator is uniquely detected.""" + + current_coord_dev = zigpy_device(ieee="aa:bb:cc:dd:ee:ff:00:11", nwk=0x0000) + current_coord_dev.node_desc = current_coord_dev.node_desc.replace( + logical_type=zdo_t.LogicalType.Coordinator + ) + + old_coord_dev = zigpy_device(ieee="aa:bb:cc:dd:ee:ff:00:12", nwk=0x0000) + old_coord_dev.node_desc = old_coord_dev.node_desc.replace( + logical_type=zdo_t.LogicalType.Coordinator + ) + + # The two coordinators have different IEEE addresses + assert current_coord_dev.ieee != old_coord_dev.ieee + + current_coordinator = await device_joined(current_coord_dev) + stale_coordinator = await device_joined(old_coord_dev) + + # Ensure the current ApplicationController's IEEE matches our coordinator's + current_coordinator.gateway.application_controller.state.node_info.ieee = ( + current_coord_dev.ieee + ) + + assert current_coordinator.is_active_coordinator + assert not stale_coordinator.is_active_coordinator diff --git a/tests/test_device_tracker.py b/tests/test_device_tracker.py new file mode 100644 index 00000000..fecfc83f --- /dev/null +++ b/tests/test_device_tracker.py @@ -0,0 +1,103 @@ +"""Test ZHA Device Tracker.""" + +import asyncio +import time +from unittest.mock import AsyncMock + +import pytest +from slugify import slugify +import zigpy.profiles.zha +from zigpy.zcl.clusters import general + +from tests.common import find_entity_id, send_attributes_report +from tests.conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.platforms.device_tracker import SourceType +from zha.application.registries import SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE +from zha.zigbee.device import Device + + +@pytest.fixture +def zigpy_device_dt(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.PowerConfiguration.cluster_id, + general.Identify.cluster_id, + general.PollControl.cluster_id, + general.BinaryInput.cluster_id, + ], + SIG_EP_OUTPUT: [general.Identify.cluster_id, general.Ota.cluster_id], + SIG_EP_TYPE: SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + return zigpy_device_mock(endpoints) + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +@pytest.mark.looptime +async def test_device_tracker( + zha_gateway: Gateway, + device_joined, + zigpy_device_dt, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA device tracker platform.""" + + zha_device = await device_joined(zigpy_device_dt) + cluster = zigpy_device_dt.endpoints.get(1).power + entity_id = find_entity_id(Platform.DEVICE_TRACKER, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["connected"] is False + + # turn state flip + await send_attributes_report( + zha_gateway, cluster, {0x0000: 0, 0x0020: 23, 0x0021: 200, 0x0001: 2} + ) + + entity.async_update = AsyncMock(wraps=entity.async_update) + zigpy_device_dt.last_seen = time.time() + 10 + await asyncio.sleep(48) + await zha_gateway.async_block_till_done() + assert entity.async_update.await_count == 1 + + assert entity.get_state()["connected"] is True + assert entity.is_connected is True + assert entity.source_type == SourceType.ROUTER + assert entity.battery_level == 100 + + # knock it offline by setting last seen in the past + zigpy_device_dt.last_seen = time.time() - 90 + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["connected"] is False + assert entity.is_connected is False + + # bring it back + zigpy_device_dt.last_seen = time.time() # type: ignore[unreachable] + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["connected"] is True + assert entity.is_connected is True + + # knock it offline by setting last seen None + zigpy_device_dt.last_seen = None + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["connected"] is False + assert entity.is_connected is False diff --git a/tests/test_discover.py b/tests/test_discover.py new file mode 100644 index 00000000..fa1def0d --- /dev/null +++ b/tests/test_discover.py @@ -0,0 +1,1087 @@ +"""Test ZHA device discovery.""" + +from collections.abc import Callable +import enum +import itertools +import re +from typing import Any, Final +from unittest import mock +from unittest.mock import AsyncMock, patch + +import pytest +from slugify import slugify +from zhaquirks.ikea import PowerConfig1CRCluster, ScenesCluster +from zhaquirks.xiaomi import ( + BasicCluster, + LocalIlluminanceMeasurementCluster, + XiaomiPowerConfigurationPercent, +) +from zhaquirks.xiaomi.aqara.driver_curtain_e1 import ( + WindowCoveringE1, + XiaomiAqaraDriverE1, +) +from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC +import zigpy.profiles.zha +import zigpy.quirks +from zigpy.quirks.v2 import ( + BinarySensorMetadata, + EntityMetadata, + EntityType, + NumberMetadata, + QuirksV2RegistryEntry, + ZCLCommandButtonMetadata, + ZCLSensorMetadata, + add_to_registry_v2, +) +from zigpy.quirks.v2.homeassistant import UnitOfTime +import zigpy.types +from zigpy.zcl import ClusterType +import zigpy.zcl.clusters.closures +import zigpy.zcl.clusters.general +import zigpy.zcl.clusters.security +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform, discovery +from zha.application.discovery import ENDPOINT_PROBE, PLATFORMS, EndpointProbe +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.registries import ( + PLATFORM_ENTITIES, + SINGLE_INPUT_CLUSTER_DEVICE_CLASS, +) +from zha.zigbee.cluster_handlers import ClusterHandler +from zha.zigbee.device import Device +from zha.zigbee.endpoint import Endpoint + +from .common import find_entity_id, update_attribute_cache +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from .zha_devices_list import ( + DEV_SIG_ATTRIBUTES, + DEV_SIG_CLUSTER_HANDLERS, + DEV_SIG_ENT_MAP, + DEV_SIG_ENT_MAP_CLASS, + DEV_SIG_ENT_MAP_ID, + DEV_SIG_EVT_CLUSTER_HANDLERS, + DEVICES, +) + +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) +STATE_OFF: Final[str] = "off" + +IGNORE_SUFFIXES = [ + zigpy.zcl.clusters.general.OnOff.StartUpOnOff.__name__, + "on_off_transition_time", + "on_level", + "on_transition_time", + "off_transition_time", + "default_move_rate", + "start_up_current_level", + "counter", +] + + +def contains_ignored_suffix(unique_id: str) -> bool: + """Return true if the unique_id ends with an ignored suffix.""" + return any(suffix.lower() in unique_id.lower() for suffix in IGNORE_SUFFIXES) + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +@pytest.fixture +def zha_device_mock( + zigpy_device_mock: Callable[..., zigpy.device.Device], + device_joined: Callable[..., Device], +) -> Callable[..., Device]: + """Mock device factory.""" + + async def _mock( + endpoints, + ieee="00:11:22:33:44:55:66:77", + manufacturer="mock manufacturer", + model="mock model", + node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + patch_cluster=False, + ): + return await device_joined( + zigpy_device_mock( + endpoints, + ieee=ieee, + manufacturer=manufacturer, + model=model, + node_descriptor=node_desc, + patch_cluster=patch_cluster, + ) + ) + + return _mock + + +@patch( + "zigpy.zcl.clusters.general.Identify.request", + new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]), +) +@pytest.mark.parametrize("device", DEVICES) +async def test_devices( + device, + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, +) -> None: + """Test device discovery.""" + zigpy_device = zigpy_device_mock( + endpoints=device[SIG_ENDPOINTS], + ieee="00:11:22:33:44:55:66:77", + manufacturer=device[SIG_MANUFACTURER], + model=device[SIG_MODEL], + node_descriptor=device[SIG_NODE_DESC], + attributes=device.get(DEV_SIG_ATTRIBUTES), + patch_cluster=False, + ) + + cluster_identify = _get_first_identify_cluster(zigpy_device) + if cluster_identify: + cluster_identify.request.reset_mock() + + zha_dev: Device = await device_joined(zigpy_device) + await zha_gateway.async_block_till_done() + + if cluster_identify and not zha_dev.skip_configuration: + assert cluster_identify.request.mock_calls == [ + mock.call( + False, + cluster_identify.commands_by_name["trigger_effect"].id, + cluster_identify.commands_by_name["trigger_effect"].schema, + effect_id=zigpy.zcl.clusters.general.Identify.EffectIdentifier.Okay, + effect_variant=( + zigpy.zcl.clusters.general.Identify.EffectVariant.Default + ), + expect_reply=True, + manufacturer=None, + tsn=None, + ) + ] + + event_cluster_handlers = { + ch.id + for endpoint in zha_dev._endpoints.values() + for ch in endpoint.client_cluster_handlers.values() + } + assert event_cluster_handlers == set(device[DEV_SIG_EVT_CLUSTER_HANDLERS]) + # we need to probe the class create entity factory so we need to reset this to get accurate results + PLATFORM_ENTITIES.clean_up() + + # Keep track of unhandled entities: they should always be ones we explicitly ignore + created_entities: dict[str, PlatformEntity] = {} + for dev in zha_gateway.devices.values(): + for entity in dev.platform_entities.values(): + if entity.device.ieee == zigpy_device.ieee: + created_entities[entity.unique_id] = entity + + unhandled_entities = set(created_entities.keys()) + + for (platform, unique_id), ent_info in device[DEV_SIG_ENT_MAP].items(): + no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID]) + message1 = f"No entity found for platform[{platform}] unique_id[{unique_id}] no_tail_id[{no_tail_id}]" + + if not contains_ignored_suffix( + unique_id + ): # TODO remove this when update is fixed + assert unique_id in created_entities, message1 + entity = created_entities[unique_id] + unhandled_entities.remove(unique_id) + + assert platform == entity.PLATFORM + assert type(entity).__name__ == ent_info[DEV_SIG_ENT_MAP_CLASS] + # unique_id used for discover is the same for "multi entities" + assert unique_id == entity.unique_id + assert {ch.name for ch in entity.cluster_handlers.values()} == set( + ent_info[DEV_SIG_CLUSTER_HANDLERS] + ) + + # All unhandled entities should be ones we explicitly ignore + for unique_id in unhandled_entities: + platform = created_entities[unique_id].PLATFORM + assert platform in PLATFORMS + assert contains_ignored_suffix(unique_id) + + +def _get_first_identify_cluster(zigpy_device): + for endpoint in list(zigpy_device.endpoints.values())[1:]: + if hasattr(endpoint, "identify"): + return endpoint.identify + + +@mock.patch("zha.application.discovery.EndpointProbe.discover_by_device_type") +@mock.patch("zha.application.discovery.EndpointProbe.discover_by_cluster_id") +def test_discover_entities(m1, m2) -> None: + """Test discover endpoint class method.""" + endpoint = mock.MagicMock() + ENDPOINT_PROBE.discover_entities(endpoint) + assert m1.call_count == 1 + assert m1.call_args[0][0] is endpoint + assert m2.call_count == 1 + assert m2.call_args[0][0] is endpoint + + +@pytest.mark.parametrize( + ("device_type", "platform", "hit"), + [ + (zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT, Platform.LIGHT, True), + (zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST, Platform.SWITCH, True), + (zigpy.profiles.zha.DeviceType.SMART_PLUG, Platform.SWITCH, True), + (0xFFFF, None, False), + ], +) +def test_discover_by_device_type(device_type, platform, hit) -> None: + """Test entity discovery by device type.""" + + endpoint = mock.MagicMock(spec_set=Endpoint) + ep_mock = mock.PropertyMock() + ep_mock.return_value.profile_id = 0x0104 + ep_mock.return_value.device_type = device_type + type(endpoint).zigpy_endpoint = ep_mock + + get_entity_mock = mock.MagicMock( + return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) + ) + with mock.patch( + "zha.application.registries.PLATFORM_ENTITIES.get_entity", + get_entity_mock, + ): + ENDPOINT_PROBE.discover_by_device_type(endpoint) + if hit: + assert get_entity_mock.call_count == 1 + assert endpoint.claim_cluster_handlers.call_count == 1 + assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed + assert endpoint.async_new_entity.call_count == 1 + assert endpoint.async_new_entity.call_args[0][0] == platform + assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + + +def test_discover_by_device_type_override() -> None: + """Test entity discovery by device type overriding.""" + + endpoint = mock.MagicMock(spec_set=Endpoint) + ep_mock = mock.PropertyMock() + ep_mock.return_value.profile_id = 0x0104 + ep_mock.return_value.device_type = 0x0100 + type(endpoint).zigpy_endpoint = ep_mock + + overrides = {endpoint.unique_id: {"type": Platform.SWITCH}} + get_entity_mock = mock.MagicMock( + return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) + ) + with ( + mock.patch( + "zha.application.registries.PLATFORM_ENTITIES.get_entity", + get_entity_mock, + ), + mock.patch.dict(ENDPOINT_PROBE._device_configs, overrides, clear=True), + ): + ENDPOINT_PROBE.discover_by_device_type(endpoint) + assert get_entity_mock.call_count == 1 + assert endpoint.claim_cluster_handlers.call_count == 1 + assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed + assert endpoint.async_new_entity.call_count == 1 + assert endpoint.async_new_entity.call_args[0][0] == Platform.SWITCH + assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + + +def test_discover_probe_single_cluster() -> None: + """Test entity discovery by single cluster.""" + + endpoint = mock.MagicMock(spec_set=Endpoint) + ep_mock = mock.PropertyMock() + ep_mock.return_value.profile_id = 0x0104 + ep_mock.return_value.device_type = 0x0100 + type(endpoint).zigpy_endpoint = ep_mock + + get_entity_mock = mock.MagicMock( + return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) + ) + cluster_handler_mock = mock.MagicMock(spec_set=ClusterHandler) + with mock.patch( + "zha.application.registries.PLATFORM_ENTITIES.get_entity", + get_entity_mock, + ): + ENDPOINT_PROBE.probe_single_cluster( + Platform.SWITCH, cluster_handler_mock, endpoint + ) + + assert get_entity_mock.call_count == 1 + assert endpoint.claim_cluster_handlers.call_count == 1 + assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed + assert endpoint.async_new_entity.call_count == 1 + assert endpoint.async_new_entity.call_args[0][0] == Platform.SWITCH + assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + assert endpoint.async_new_entity.call_args[0][3] == mock.sentinel.claimed + + +@pytest.mark.parametrize("device_info", DEVICES) +async def test_discover_endpoint( + device_info: dict[str, Any], + zha_device_mock: Callable[..., Device], + zha_gateway: Gateway, +) -> None: + """Test device discovery.""" + + with mock.patch("zha.zigbee.endpoint.Endpoint.async_new_entity") as new_ent: + device = await zha_device_mock( + device_info[SIG_ENDPOINTS], + manufacturer=device_info[SIG_MANUFACTURER], + model=device_info[SIG_MODEL], + node_desc=device_info[SIG_NODE_DESC], + patch_cluster=True, + ) + + assert device_info[DEV_SIG_EVT_CLUSTER_HANDLERS] == sorted( + ch.id + for endpoint in device._endpoints.values() + for ch in endpoint.client_cluster_handlers.values() + ) + + # build a dict of entity_class -> (platform, unique_id, cluster_handlers) tuple + ha_ent_info = {} + for call in new_ent.call_args_list: + platform, entity_cls, unique_id, cluster_handlers = call[0] + if not contains_ignored_suffix(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__)] = ( + platform, + unique_id, + cluster_handlers, + ) + + for platform_id, ent_info in device_info[DEV_SIG_ENT_MAP].items(): + platform, unique_id = platform_id + + test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS] + test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0) + if ( + test_ent_class != "FirmwareUpdateEntity" + ): # TODO remove this when update is fixed + assert (test_unique_id_head, test_ent_class) in ha_ent_info + + entity_platform, entity_unique_id, entity_cluster_handlers = ha_ent_info[ + (test_unique_id_head, test_ent_class) + ] + assert platform is entity_platform.value + # unique_id used for discover is the same for "multi entities" + assert unique_id.startswith(entity_unique_id) + assert {ch.name for ch in entity_cluster_handlers} == set( + ent_info[DEV_SIG_CLUSTER_HANDLERS] + ) + + +def _ch_mock(cluster): + """Return mock of a cluster_handler with a cluster.""" + cluster_handler = mock.MagicMock() + type(cluster_handler).cluster = mock.PropertyMock( + return_value=cluster(mock.MagicMock()) + ) + return cluster_handler + + +@mock.patch( + ( + "zha.application.discovery.EndpointProbe" + ".handle_on_off_output_cluster_exception" + ), + new=mock.MagicMock(), +) +@mock.patch("zha.application.discovery.EndpointProbe.probe_single_cluster") +def _test_single_input_cluster_device_class(probe_mock): + """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class.""" + + door_ch = _ch_mock(zigpy.zcl.clusters.closures.DoorLock) + cover_ch = _ch_mock(zigpy.zcl.clusters.closures.WindowCovering) + multistate_ch = _ch_mock(zigpy.zcl.clusters.general.MultistateInput) + + class QuirkedIAS(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.security.IasZone): + """Quirked IAS Zone cluster.""" + + pass + + ias_ch = _ch_mock(QuirkedIAS) + + class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput): + pass + + analog_ch = _ch_mock(_Analog) + + endpoint = mock.MagicMock(spec_set=Endpoint) + endpoint.unclaimed_cluster_handlers.return_value = [ + door_ch, + cover_ch, + multistate_ch, + ias_ch, + ] + + EndpointProbe().discover_by_cluster_id(endpoint) + assert probe_mock.call_count == len(endpoint.unclaimed_cluster_handlers()) + probes = ( + (Platform.LOCK, door_ch), + (Platform.COVER, cover_ch), + (Platform.SENSOR, multistate_ch), + (Platform.BINARY_SENSOR, ias_ch), + (Platform.SENSOR, analog_ch), + ) + for call, details in zip(probe_mock.call_args_list, probes): + platform, ch = details + assert call[0][0] == platform + assert call[0][1] == ch + + +def test_single_input_cluster_device_class_by_cluster_class() -> None: + """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class.""" + mock_reg = { + zigpy.zcl.clusters.closures.DoorLock.cluster_id: Platform.LOCK, + zigpy.zcl.clusters.closures.WindowCovering.cluster_id: Platform.COVER, + zigpy.zcl.clusters.general.AnalogInput: Platform.SENSOR, + zigpy.zcl.clusters.general.MultistateInput: Platform.SENSOR, + zigpy.zcl.clusters.security.IasZone: Platform.BINARY_SENSOR, + } + + with mock.patch.dict(SINGLE_INPUT_CLUSTER_DEVICE_CLASS, mock_reg, clear=True): + _test_single_input_cluster_device_class() + + +@pytest.mark.parametrize( + ("override", "entity_id"), + [ + (None, "light.manufacturer_model_light"), + ("switch", "switch.manufacturer_model_switch"), + ], +) +async def test_device_override( + zha_gateway: Gateway, zigpy_device_mock, override, entity_id +) -> None: + """Test device discovery override.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT, + "endpoint_id": 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + } + }, + "00:11:22:33:44:55:66:77", + "manufacturer", + "model", + patch_cluster=False, + ) + + if override is not None: + override = {"device_config": {"00:11:22:33:44:55:66:77-1": {"type": override}}} + zha_gateway.config.yaml_config = override + discovery.ENDPOINT_PROBE.initialize(zha_gateway) + + await zha_gateway.async_device_initialized(zigpy_device) + await zha_gateway.async_block_till_done() + zha_device = zha_gateway.get_device(zigpy_device.ieee) + + entity_id = find_entity_id( + Platform.SWITCH if override else Platform.LIGHT, + zha_device, + ) + assert entity_id is not None + assert get_entity(zha_device, entity_id) is not None + + +async def test_quirks_v2_entity_discovery( + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, +) -> None: + """Test quirks v2 discovery.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + zigpy.zcl.clusters.general.Groups.cluster_id, + zigpy.zcl.clusters.general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Scenes.cluster_id, + ], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.NON_COLOR_CONTROLLER, + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + manufacturer="Ikea of Sweden", + model="TRADFRI remote control", + ) + + ( + add_to_registry_v2( + "Ikea of Sweden", "TRADFRI remote control", zigpy.quirks._DEVICE_REGISTRY + ) + .replaces(PowerConfig1CRCluster) + .replaces(ScenesCluster, cluster_type=ClusterType.Client) + .number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + min_value=1, + max_value=100, + step=1, + unit=UnitOfTime.SECONDS, + multiplier=1, + ) + ) + + zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { + "battery_voltage": 3, + "battery_percentage_remaining": 100, + } + update_attribute_cache(zigpy_device.endpoints[1].power) + zigpy_device.endpoints[1].on_off.PLUGGED_ATTR_READS = { + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name: 3, + } + update_attribute_cache(zigpy_device.endpoints[1].on_off) + + zha_device = await device_joined(zigpy_device) + + entity_id = find_entity_id( + Platform.NUMBER, + zha_device, + ) + assert entity_id is not None + assert get_entity(zha_device, entity_id) is not None + + +async def test_quirks_v2_entity_discovery_e1_curtain( + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, +) -> None: + """Test quirks v2 discovery for e1 curtain motor.""" + + class AqaraE1HookState(zigpy.types.enum8): + """Aqara hook state.""" + + Unlocked = 0x00 + Locked = 0x01 + Locking = 0x02 + Unlocking = 0x03 + + class FakeXiaomiAqaraDriverE1(XiaomiAqaraDriverE1): + """Fake XiaomiAqaraDriverE1 cluster.""" + + attributes = XiaomiAqaraDriverE1.attributes.copy() + attributes.update( + { + 0x9999: ("error_detected", zigpy.types.Bool, True), + } + ) + + ( + add_to_registry_v2("LUMI", "lumi.curtain.agl006") + .adds(LocalIlluminanceMeasurementCluster) + .replaces(BasicCluster) + .replaces(XiaomiPowerConfigurationPercent) + .replaces(WindowCoveringE1) + .replaces(FakeXiaomiAqaraDriverE1) + .removes(FakeXiaomiAqaraDriverE1, cluster_type=ClusterType.Client) + .enum( + BasicCluster.AttributeDefs.power_source.name, + BasicCluster.PowerSource, + BasicCluster.cluster_id, + entity_platform=Platform.SENSOR, + entity_type=EntityType.DIAGNOSTIC, + ) + .enum( + "hooks_state", + AqaraE1HookState, + FakeXiaomiAqaraDriverE1.cluster_id, + entity_platform=Platform.SENSOR, + entity_type=EntityType.DIAGNOSTIC, + ) + .binary_sensor("error_detected", FakeXiaomiAqaraDriverE1.cluster_id) + ) + + aqara_E1_device = zigpy_device_mock( + { + 1: { + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE, + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.Basic.cluster_id, + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.general.Time.cluster_id, + WindowCoveringE1.cluster_id, + XiaomiAqaraDriverE1.cluster_id, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Identify.cluster_id, + zigpy.zcl.clusters.general.Time.cluster_id, + zigpy.zcl.clusters.general.Ota.cluster_id, + XiaomiAqaraDriverE1.cluster_id, + ], + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + manufacturer="LUMI", + model="lumi.curtain.agl006", + ) + aqara_E1_device = zigpy.quirks._DEVICE_REGISTRY.get_device(aqara_E1_device) + + aqara_E1_device.endpoints[1].opple_cluster.PLUGGED_ATTR_READS = { + "hand_open": 0, + "positions_stored": 0, + "hooks_lock": 0, + "hooks_state": AqaraE1HookState.Unlocked, + "light_level": 0, + "error_detected": 0, + } + update_attribute_cache(aqara_E1_device.endpoints[1].opple_cluster) + + aqara_E1_device.endpoints[1].basic.PLUGGED_ATTR_READS = { + BasicCluster.AttributeDefs.power_source.name: BasicCluster.PowerSource.Mains_single_phase, + } + update_attribute_cache(aqara_E1_device.endpoints[1].basic) + + WCAttrs = zigpy.zcl.clusters.closures.WindowCovering.AttributeDefs + WCT = zigpy.zcl.clusters.closures.WindowCovering.WindowCoveringType + WCCS = zigpy.zcl.clusters.closures.WindowCovering.ConfigStatus + aqara_E1_device.endpoints[1].window_covering.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 0, + WCAttrs.window_covering_type.name: WCT.Drapery, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(aqara_E1_device.endpoints[1].window_covering) + + zha_device = await device_joined(aqara_E1_device) + + power_source_entity_id = find_entity_id( + Platform.SENSOR, + zha_device, + qualifier=BasicCluster.AttributeDefs.power_source.name, + ) + assert power_source_entity_id is not None + + power_source_entity = get_entity(zha_device, power_source_entity_id) + assert power_source_entity is not None + assert ( + power_source_entity.get_state()["state"] + == BasicCluster.PowerSource.Mains_single_phase.name + ) + + hook_state_entity_id = find_entity_id( + Platform.SENSOR, + zha_device, + qualifier="hooks_state", + ) + assert hook_state_entity_id is not None + hook_state_entity = get_entity(zha_device, hook_state_entity_id) + assert hook_state_entity is not None + assert hook_state_entity.get_state()["state"] == AqaraE1HookState.Unlocked.name + + error_detected_entity_id = find_entity_id( + Platform.BINARY_SENSOR, + zha_device, + ) + assert error_detected_entity_id is not None + error_detected_entity = get_entity(zha_device, error_detected_entity_id) + assert error_detected_entity is not None + assert error_detected_entity.get_state()["state"] is False + + +def _get_test_device( + zigpy_device_mock, + manufacturer: str, + model: str, + augment_method: Callable[[QuirksV2RegistryEntry], QuirksV2RegistryEntry] + | None = None, +): + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + zigpy.zcl.clusters.general.PowerConfiguration.cluster_id, + zigpy.zcl.clusters.general.Groups.cluster_id, + zigpy.zcl.clusters.general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [ + zigpy.zcl.clusters.general.Scenes.cluster_id, + ], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.NON_COLOR_CONTROLLER, + } + }, + ieee="01:2d:6f:00:0a:90:69:e8", + manufacturer=manufacturer, + model=model, + ) + + v2_quirk = ( + add_to_registry_v2(manufacturer, model, zigpy.quirks._DEVICE_REGISTRY) + .replaces(PowerConfig1CRCluster) + .replaces(ScenesCluster, cluster_type=ClusterType.Client) + .number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + endpoint_id=3, + min_value=1, + max_value=100, + step=1, + unit=UnitOfTime.SECONDS, + multiplier=1, + translation_key="on_off_transition_time", + ) + .number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.Time.cluster_id, + min_value=1, + max_value=100, + step=1, + unit=UnitOfTime.SECONDS, + multiplier=1, + translation_key="on_off_transition_time", + ) + .sensor( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + entity_type=EntityType.CONFIG, + translation_key="analog_input", + ) + ) + + if augment_method: + v2_quirk = augment_method(v2_quirk) + + zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { + "battery_voltage": 3, + "battery_percentage_remaining": 100, + } + update_attribute_cache(zigpy_device.endpoints[1].power) + zigpy_device.endpoints[1].on_off.PLUGGED_ATTR_READS = { + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name: 3, + } + update_attribute_cache(zigpy_device.endpoints[1].on_off) + return zigpy_device + + +async def test_quirks_v2_entity_no_metadata( + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test quirks v2 discovery skipped - no metadata.""" + + zigpy_device = _get_test_device( + zigpy_device_mock, "Ikea of Sweden2", "TRADFRI remote control2" + ) + setattr(zigpy_device, "_exposes_metadata", {}) + zha_device = await device_joined(zigpy_device) + assert ( + f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not expose any quirks v2 entities" + in caplog.text + ) + + +async def test_quirks_v2_entity_discovery_errors( + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test quirks v2 discovery skipped - errors.""" + + zigpy_device = _get_test_device( + zigpy_device_mock, "Ikea of Sweden3", "TRADFRI remote control3" + ) + zha_device = await device_joined(zigpy_device) + + m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not have an" + m2 = " endpoint with id: 3 - unable to create entity with cluster" + m3 = " details: (3, 6, )" + assert f"{m1}{m2}{m3}" in caplog.text + + time_cluster_id = zigpy.zcl.clusters.general.Time.cluster_id + + m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} does not have a" + m2 = f" cluster with id: {time_cluster_id} - unable to create entity with " + m3 = f"cluster details: (1, {time_cluster_id}, )" + assert f"{m1}{m2}{m3}" in caplog.text + + # fmt: off + entity_details = ( + "{'cluster_details': (1, 6, ), 'entity_metadata': " + "ZCLSensorMetadata(entity_platform=, " + "entity_type=, cluster_id=6, endpoint_id=1, " + "cluster_type=, initially_disabled=False, " + "attribute_initialized_from_cache=True, translation_key='analog_input', " + "attribute_name='off_wait_time', divisor=1, multiplier=1, " + "unit=None, device_class=None, state_class=None)}" + ) + # fmt: on + + m1 = f"Device: {str(zigpy_device.ieee)}-{zha_device.name} has an entity with " + m2 = f"details: {entity_details} that does not have an entity class mapping - " + m3 = "unable to create entity" + assert f"{m1}{m2}{m3}" in caplog.text + + +DEVICE_CLASS_TYPES = [NumberMetadata, BinarySensorMetadata, ZCLSensorMetadata] + + +def validate_device_class_unit( + quirk: QuirksV2RegistryEntry, + entity_metadata: EntityMetadata, + platform: Platform, + translations: dict, +) -> None: + """Ensure device class and unit are used correctly.""" + if ( + hasattr(entity_metadata, "unit") + and entity_metadata.unit is not None + and hasattr(entity_metadata, "device_class") + and entity_metadata.device_class is not None + ): + m1 = "device_class and unit are both set - unit: " + m2 = f"{entity_metadata.unit} device_class: " + m3 = f"{entity_metadata.device_class} for {platform.name} " + raise ValueError(f"{m1}{m2}{m3}{quirk}") + + +def validate_translation_keys( + quirk: QuirksV2RegistryEntry, + entity_metadata: EntityMetadata, + platform: Platform, + translations: dict, +) -> None: + """Ensure translation keys exist for all v2 quirks.""" + if isinstance(entity_metadata, ZCLCommandButtonMetadata): + default_translation_key = entity_metadata.command_name + else: + default_translation_key = entity_metadata.attribute_name + translation_key = entity_metadata.translation_key or default_translation_key + + if ( + translation_key is not None + and translation_key not in translations["entity"][platform] + ): + raise ValueError( + f"Missing translation key: {translation_key} for {platform.name} {quirk}" + ) + + +def validate_translation_keys_device_class( + quirk: QuirksV2RegistryEntry, + entity_metadata: EntityMetadata, + platform: Platform, + translations: dict, +) -> None: + """Validate translation keys and device class usage.""" + if isinstance(entity_metadata, ZCLCommandButtonMetadata): + default_translation_key = entity_metadata.command_name + else: + default_translation_key = entity_metadata.attribute_name + translation_key = entity_metadata.translation_key or default_translation_key + + metadata_type = type(entity_metadata) + if metadata_type in DEVICE_CLASS_TYPES: + device_class = entity_metadata.device_class + if device_class is not None and translation_key is not None: + m1 = "translation_key and device_class are both set - translation_key: " + m2 = f"{translation_key} device_class: {device_class} for {platform.name} " + raise ValueError(f"{m1}{m2}{quirk}") + + +def bad_device_class_unit_combination( + v2_quirk: QuirksV2RegistryEntry, +) -> QuirksV2RegistryEntry: + """Introduce a bad device class and unit combination.""" + return v2_quirk.sensor( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + entity_type=EntityType.CONFIG, + unit="invalid", + device_class="invalid", + translation_key="analog_input", + ) + + +def bad_device_class_translation_key_usage( + v2_quirk: QuirksV2RegistryEntry, +) -> QuirksV2RegistryEntry: + """Introduce a bad device class and translation key combination.""" + return v2_quirk.sensor( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + entity_type=EntityType.CONFIG, + translation_key="invalid", + device_class="invalid", + ) + + +def validate_metadata(validator: Callable) -> None: + """Ensure v2 quirks metadata does not violate HA rules.""" + all_v2_quirks = itertools.chain.from_iterable( + zigpy.quirks._DEVICE_REGISTRY._registry_v2.values() + ) + translations = {} + for quirk in all_v2_quirks: + for entity_metadata in quirk.entity_metadata: + platform = Platform(entity_metadata.entity_platform.value) + validator(quirk, entity_metadata, platform, translations) + + +@pytest.mark.parametrize( + ("augment_method", "validate_method", "expected_exception_string"), + [ + ( + bad_device_class_unit_combination, + validate_device_class_unit, + "cannot have both unit and device_class", + ), + ( + bad_device_class_translation_key_usage, + validate_translation_keys_device_class, + "cannot have both a translation_key and a device_class", + ), + ], +) +async def test_quirks_v2_metadata_errors( + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, + augment_method: Callable[[QuirksV2RegistryEntry], QuirksV2RegistryEntry], + validate_method: Callable, + expected_exception_string: str, +) -> None: + """Ensure all v2 quirks translation keys exist.""" + + # no error yet + validate_metadata(validate_method) + + # ensure the error is caught and raised + with pytest.raises(ValueError, match=expected_exception_string): + try: + # introduce an error + zigpy_device = _get_test_device( + zigpy_device_mock, + "Ikea of Sweden4", + "TRADFRI remote control4", + augment_method=augment_method, + ) + await device_joined(zigpy_device) + + validate_metadata(validate_method) + # if the device was created we remove it + # so we don't pollute the rest of the tests + zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device) + except ValueError as e: + # if the device was not created we remove it + # so we don't pollute the rest of the tests + zigpy.quirks._DEVICE_REGISTRY._registry_v2.pop( + ( + "Ikea of Sweden4", + "TRADFRI remote control4", + ) + ) + raise e + + +class BadDeviceClass(enum.Enum): + """Bad device class.""" + + BAD = "bad" + + +def bad_binary_sensor_device_class( + v2_quirk: QuirksV2RegistryEntry, +) -> QuirksV2RegistryEntry: + """Introduce a bad device class on a binary sensor.""" + + return v2_quirk.binary_sensor( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.on_off.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + device_class=BadDeviceClass.BAD, + ) + + +def bad_sensor_device_class( + v2_quirk: QuirksV2RegistryEntry, +) -> QuirksV2RegistryEntry: + """Introduce a bad device class on a sensor.""" + + return v2_quirk.sensor( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + device_class=BadDeviceClass.BAD, + ) + + +def bad_number_device_class( + v2_quirk: QuirksV2RegistryEntry, +) -> QuirksV2RegistryEntry: + """Introduce a bad device class on a number.""" + + return v2_quirk.number( + zigpy.zcl.clusters.general.OnOff.AttributeDefs.on_time.name, + zigpy.zcl.clusters.general.OnOff.cluster_id, + device_class=BadDeviceClass.BAD, + ) + + +ERROR_ROOT = "Quirks provided an invalid device class" + + +@pytest.mark.parametrize( + ("augment_method", "expected_exception_string"), + [ + ( + bad_binary_sensor_device_class, + f"{ERROR_ROOT}: BadDeviceClass.BAD for platform binary_sensor", + ), + ( + bad_sensor_device_class, + f"{ERROR_ROOT}: BadDeviceClass.BAD for platform sensor", + ), + ( + bad_number_device_class, + f"{ERROR_ROOT}: BadDeviceClass.BAD for platform number", + ), + ], +) +async def test_quirks_v2_metadata_bad_device_classes( + zha_gateway: Gateway, + zigpy_device_mock, + device_joined, + caplog: pytest.LogCaptureFixture, + augment_method: Callable[[QuirksV2RegistryEntry], QuirksV2RegistryEntry], + expected_exception_string: str, +) -> None: + """Test bad quirks v2 device classes.""" + + # introduce an error + zigpy_device = _get_test_device( + zigpy_device_mock, + "Ikea of Sweden4", + "TRADFRI remote control4", + augment_method=augment_method, + ) + await device_joined(zigpy_device) + + assert expected_exception_string in caplog.text + + # remove the device so we don't pollute the rest of the tests + zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device) diff --git a/tests/test_fan.py b/tests/test_fan.py new file mode 100644 index 00000000..0270f9c6 --- /dev/null +++ b/tests/test_fan.py @@ -0,0 +1,821 @@ +"""Test zha fan.""" + +# pylint: disable=redefined-outer-name + +from collections.abc import Awaitable, Callable +import logging +from typing import Optional +from unittest.mock import AsyncMock, call, patch + +import pytest +from slugify import slugify +import zhaquirks +from zigpy.device import Device as ZigpyDevice +from zigpy.exceptions import ZigbeeException +from zigpy.profiles import zha +from zigpy.zcl.clusters import general, hvac +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import GroupEntity, PlatformEntity +from zha.application.platforms.fan.const import ( + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, + PRESET_MODE_AUTO, + PRESET_MODE_ON, + PRESET_MODE_SMART, + SPEED_HIGH, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_OFF, +) +from zha.application.platforms.fan.helpers import NotValidPresetModeError +from zha.exceptions import ZHAException +from zha.zigbee.device import Device +from zha.zigbee.group import Group, GroupMemberReference + +from .common import async_find_group_entity_id, find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +def zigpy_device( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [hvac.Fan.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00" + ) + + +@pytest.fixture +async def device_fan_1( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha fan platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Groups.cluster_id, + general.OnOff.cluster_id, + hvac.Fan.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + }, + ieee=IEEE_GROUPABLE_DEVICE, + ) + zha_device = await device_joined(zigpy_dev) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_fan_2( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha fan platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Groups.cluster_id, + general.OnOff.cluster_id, + hvac.Fan.cluster_id, + general.LevelControl.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.LEVEL_CONTROL_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + }, + ieee=IEEE_GROUPABLE_DEVICE2, + ) + zha_device = await device_joined(zigpy_dev) + zha_device.available = True + return zha_device + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def get_group_entity(group: Group, entity_id: str) -> Optional[GroupEntity]: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in group.group_entities.values() + } + + return entities.get(entity_id) + + +async def test_fan( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, + zha_gateway: Gateway, +) -> None: + """Test zha fan platform.""" + + zha_device = await device_joined(zigpy_device) + cluster = zigpy_device.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.get_state()["is_on"] is False + + # turn on at fan + await send_attributes_report(zha_gateway, cluster, {1: 2, 0: 1, 2: 3}) + assert entity.get_state()["is_on"] is True + + # turn off at fan + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 2}) + assert entity.get_state()["is_on"] is False + + # turn on from client + cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call( + {"fan_mode": 2}, manufacturer=None + ) + assert entity.get_state()["is_on"] is True + + # turn off from client + cluster.write_attributes.reset_mock() + await async_turn_off(zha_gateway, entity) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call( + {"fan_mode": 0}, manufacturer=None + ) + assert entity.get_state()["is_on"] is False + + # change speed from client + cluster.write_attributes.reset_mock() + await async_set_speed(zha_gateway, entity, speed=SPEED_HIGH) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call( + {"fan_mode": 3}, manufacturer=None + ) + assert entity.get_state()["is_on"] is True + assert entity.get_state()["speed"] == SPEED_HIGH + + # change preset_mode from client + cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_ON) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call( + {"fan_mode": 4}, manufacturer=None + ) + assert entity.get_state()["is_on"] is True + assert entity.get_state()["preset_mode"] == PRESET_MODE_ON + + # test set percentage from client + cluster.write_attributes.reset_mock() + await entity.async_set_percentage(50) + await zha_gateway.async_block_till_done() + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call( + {"fan_mode": 2}, manufacturer=None + ) + # this is converted to a ranged value + assert entity.get_state()["percentage"] == 66 + assert entity.get_state()["is_on"] is True + + # set invalid preset_mode from client + cluster.write_attributes.reset_mock() + + with pytest.raises(NotValidPresetModeError): + await entity.async_set_preset_mode("invalid") + assert len(cluster.write_attributes.mock_calls) == 0 + + # test percentage in turn on command + await entity.async_turn_on(percentage=25) + await zha_gateway.async_block_till_done() + assert entity.get_state()["percentage"] == 33 # this is converted to a ranged value + assert entity.get_state()["speed"] == SPEED_LOW + + # test speed in turn on command + await entity.async_turn_on(speed=SPEED_HIGH) + await zha_gateway.async_block_till_done() + assert entity.get_state()["percentage"] == 100 + assert entity.get_state()["speed"] == SPEED_HIGH + + +async def async_turn_on( + zha_gateway: Gateway, + entity: PlatformEntity, + speed: Optional[str] = None, +) -> None: + """Turn fan on.""" + await entity.async_turn_on(speed=speed) + await zha_gateway.async_block_till_done() + + +async def async_turn_off(zha_gateway: Gateway, entity: PlatformEntity) -> None: + """Turn fan off.""" + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + + +async def async_set_speed( + zha_gateway: Gateway, + entity: PlatformEntity, + speed: Optional[str] = None, +) -> None: + """Set speed for specified fan.""" + await entity.async_turn_on(speed=speed) + await zha_gateway.async_block_till_done() + + +async def async_set_percentage( + zha_gateway: Gateway, entity: PlatformEntity, percentage=None +): + """Set percentage for specified fan.""" + await entity.async_set_percentage(percentage) + await zha_gateway.async_block_till_done() + + +async def async_set_preset_mode( + zha_gateway: Gateway, + entity: PlatformEntity, + preset_mode: Optional[str] = None, +) -> None: + """Set preset_mode for specified fan.""" + assert preset_mode is not None + await entity.async_set_preset_mode(preset_mode) + await zha_gateway.async_block_till_done() + + +@patch( + "zigpy.zcl.clusters.hvac.Fan.write_attributes", + new=AsyncMock(return_value=zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]), +) +async def test_zha_group_fan_entity( + device_fan_1: Device, device_fan_2: Device, zha_gateway: Gateway +): + """Test the fan entity for a ZHAWS group.""" + + member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee] + members = [ + GroupMemberReference(ieee=device_fan_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_fan_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.FAN, zha_group) + assert entity_id is not None + + entity: GroupEntity = get_group_entity(zha_group, entity_id) # type: ignore + assert entity is not None + + assert entity.group_id == zha_group.group_id + + assert isinstance(entity, GroupEntity) + + group_fan_cluster = zha_group.zigpy_group.endpoint[hvac.Fan.cluster_id] + + dev1_fan_cluster = device_fan_1.device.endpoints[1].fan + dev2_fan_cluster = device_fan_2.device.endpoints[1].fan + + # test that the fan group entity was created and is off + assert entity.get_state()["is_on"] is False + + # turn on from client + group_fan_cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity) + await zha_gateway.async_block_till_done() + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2} + + # turn off from client + group_fan_cluster.write_attributes.reset_mock() + await async_turn_off(zha_gateway, entity) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 0} + + # change speed from client + group_fan_cluster.write_attributes.reset_mock() + await async_set_speed(zha_gateway, entity, speed=SPEED_HIGH) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 3} + + # change preset mode from client + group_fan_cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_ON) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 4} + + # change preset mode from client + group_fan_cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_AUTO) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 5} + + # change preset mode from client + group_fan_cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_SMART) + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 6} + + # test some of the group logic to make sure we key off states correctly + await send_attributes_report(zha_gateway, dev1_fan_cluster, {0: 0}) + await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 0}) + + # test that group fan is off + assert entity.get_state()["is_on"] is False + + await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 2}) + await zha_gateway.async_block_till_done() + + # test that group fan is speed medium + assert entity.get_state()["is_on"] is True + + await send_attributes_report(zha_gateway, dev2_fan_cluster, {0: 0}) + await zha_gateway.async_block_till_done() + + # test that group fan is now off + assert entity.get_state()["is_on"] is False + + +@patch( + "zigpy.zcl.clusters.hvac.Fan.write_attributes", + new=AsyncMock(side_effect=ZigbeeException), +) +async def test_zha_group_fan_entity_failure_state( + device_fan_1: Device, + device_fan_2: Device, + zha_gateway: Gateway, + caplog: pytest.LogCaptureFixture, +): + """Test the fan entity for a ZHA group when writing attributes generates an exception.""" + + member_ieee_addresses = [device_fan_1.ieee, device_fan_2.ieee] + members = [ + GroupMemberReference(ieee=device_fan_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_fan_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.FAN, zha_group) + assert entity_id is not None + + entity: GroupEntity = get_group_entity(zha_group, entity_id) # type: ignore + assert entity is not None + + assert isinstance(entity, GroupEntity) + assert entity.group_id == zha_group.group_id + + group_fan_cluster = zha_group.zigpy_group.endpoint[hvac.Fan.cluster_id] + + # test that the fan group entity was created and is off + assert entity.get_state()["is_on"] is False + + # turn on from client + group_fan_cluster.write_attributes.reset_mock() + with pytest.raises(ZHAException, match="Failed to send request"): + await async_turn_on(zha_gateway, entity) + await zha_gateway.async_block_till_done() + assert len(group_fan_cluster.write_attributes.mock_calls) == 1 + assert group_fan_cluster.write_attributes.call_args[0][0] == {"fan_mode": 2} + assert "Could not set fan mode" in caplog.text + + +@pytest.mark.parametrize( + "plug_read, expected_state, expected_speed, expected_percentage", + ( + ({"fan_mode": None}, False, None, None), + ({"fan_mode": 0}, False, SPEED_OFF, 0), + ({"fan_mode": 1}, True, SPEED_LOW, 33), + ({"fan_mode": 2}, True, SPEED_MEDIUM, 66), + ({"fan_mode": 3}, True, SPEED_HIGH, 100), + ), +) +async def test_fan_init( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, + zha_gateway: Gateway, # pylint: disable=unused-argument + plug_read: dict, + expected_state: bool, + expected_speed: Optional[str], + expected_percentage: Optional[int], +): + """Test zha fan platform.""" + + cluster = zigpy_device.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = plug_read + zha_device = await device_joined(zigpy_device) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] == expected_state + assert entity.get_state()["speed"] == expected_speed + assert entity.get_state()["percentage"] == expected_percentage + assert entity.get_state()["preset_mode"] is None + + +async def test_fan_update_entity( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, + zha_gateway: Gateway, +): + """Test zha fan refresh state.""" + + cluster = zigpy_device.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + zha_device = await device_joined(zigpy_device) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + assert entity.get_state()["speed"] == SPEED_OFF + assert entity.get_state()["percentage"] == 0 + assert entity.get_state()["preset_mode"] is None + assert entity.percentage_step == 100 / 3 + assert cluster.read_attributes.await_count == 2 + + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["is_on"] is False + assert entity.get_state()["speed"] == SPEED_OFF + assert cluster.read_attributes.await_count == 3 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await entity.async_update() + await zha_gateway.async_block_till_done() + assert entity.get_state()["is_on"] is True + assert entity.get_state()["percentage"] == 33 + assert entity.get_state()["speed"] == SPEED_LOW + assert entity.get_state()["preset_mode"] is None + assert entity.percentage_step == 100 / 3 + assert cluster.read_attributes.await_count == 4 + + +@pytest.fixture +def zigpy_device_ikea(zigpy_device_mock) -> ZigpyDevice: + """Ikea fan zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="IKEA of Sweden", + model="STARKVIND Air purifier", + quirk=zhaquirks.ikea.starkvind.IkeaSTARKVIND, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_ikea( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_ikea: ZigpyDevice, +) -> None: + """Test ZHA fan Ikea platform.""" + zha_device = await device_joined(zigpy_device_ikea) + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + + # turn on at fan + await send_attributes_report(zha_gateway, cluster, {6: 1}) + assert entity.get_state()["is_on"] is True + + # turn off at fan + await send_attributes_report(zha_gateway, cluster, {6: 0}) + assert entity.get_state()["is_on"] is False + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 1}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(zha_gateway, entity, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 10}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_AUTO) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 1}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode( + zha_gateway, + entity, + preset_mode="invalid does not exist", + ) + assert len(cluster.write_attributes.mock_calls) == 0 + + +@pytest.mark.parametrize( + ( + "ikea_plug_read", + "ikea_expected_state", + "ikea_expected_percentage", + "ikea_preset_mode", + ), + [ + (None, False, None, None), + ({"fan_mode": 0}, False, 0, None), + ({"fan_mode": 1}, True, 10, PRESET_MODE_AUTO), + ({"fan_mode": 10}, True, 20, "Speed 1"), + ({"fan_mode": 15}, True, 30, "Speed 1.5"), + ({"fan_mode": 20}, True, 40, "Speed 2"), + ({"fan_mode": 25}, True, 50, "Speed 2.5"), + ({"fan_mode": 30}, True, 60, "Speed 3"), + ({"fan_mode": 35}, True, 70, "Speed 3.5"), + ({"fan_mode": 40}, True, 80, "Speed 4"), + ({"fan_mode": 45}, True, 90, "Speed 4.5"), + ({"fan_mode": 50}, True, 100, "Speed 5"), + ], +) +async def test_fan_ikea_init( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_ikea: ZigpyDevice, + ikea_plug_read: dict, + ikea_expected_state: bool, + ikea_expected_percentage: int, + ikea_preset_mode: Optional[str], +) -> None: + """Test ZHA fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = ikea_plug_read + + zha_device = await device_joined(zigpy_device_ikea) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.get_state()["is_on"] == ikea_expected_state + assert entity.get_state()["percentage"] == ikea_expected_percentage + assert entity.get_state()["preset_mode"] == ikea_preset_mode + + +async def test_fan_ikea_update_entity( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_ikea: ZigpyDevice, +) -> None: + """Test ZHA fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await device_joined(zigpy_device_ikea) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + assert entity.get_state()[ATTR_PERCENTAGE] == 0 + assert entity.get_state()[ATTR_PRESET_MODE] is None + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 10 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + + await entity.async_update() + await zha_gateway.async_block_till_done() + + assert entity.get_state()["is_on"] is True + assert entity.get_state()[ATTR_PERCENTAGE] == 10 + assert entity.get_state()[ATTR_PRESET_MODE] is PRESET_MODE_AUTO + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 10 + + +@pytest.fixture +def zigpy_device_kof(zigpy_device_mock) -> ZigpyDevice: + """Fan by King of Fans zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="King Of Fans, Inc.", + model="HBUniversalCFRemote", + quirk=zhaquirks.kof.kof_mr101z.CeilingFan, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_kof( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_kof: ZigpyDevice, +) -> None: + """Test ZHA fan platform for King of Fans.""" + zha_device = await device_joined(zigpy_device_kof) + cluster = zigpy_device_kof.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + + # turn on at fan + await send_attributes_report(zha_gateway, cluster, {1: 2, 0: 1, 2: 3}) + assert entity.get_state()["is_on"] is True + + # turn off at fan + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 2}) + assert entity.get_state()["is_on"] is False + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(zha_gateway, entity) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(zha_gateway, entity, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 4}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_SMART) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 6}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode(zha_gateway, entity, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 0 + + +@pytest.mark.parametrize( + ("plug_read", "expected_state", "expected_percentage", "expected_preset"), + [ + (None, False, None, None), + ({"fan_mode": 0}, False, 0, None), + ({"fan_mode": 1}, True, 25, None), + ({"fan_mode": 2}, True, 50, None), + ({"fan_mode": 3}, True, 75, None), + ({"fan_mode": 4}, True, 100, None), + ({"fan_mode": 6}, True, None, PRESET_MODE_SMART), + ], +) +async def test_fan_kof_init( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_kof: ZigpyDevice, + plug_read: dict, + expected_state: bool, + expected_percentage: Optional[int], + expected_preset: Optional[str], +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = plug_read + + zha_device = await device_joined(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is expected_state + assert entity.get_state()[ATTR_PERCENTAGE] == expected_percentage + assert entity.get_state()[ATTR_PRESET_MODE] == expected_preset + + +async def test_fan_kof_update_entity( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device_kof: ZigpyDevice, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await device_joined(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_on"] is False + assert entity.get_state()[ATTR_PERCENTAGE] == 0 + assert entity.get_state()[ATTR_PRESET_MODE] is None + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 4 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + + await entity.async_update() + await zha_gateway.async_block_till_done() + + assert entity.get_state()["is_on"] is True + assert entity.get_state()[ATTR_PERCENTAGE] == 25 + assert entity.get_state()[ATTR_PRESET_MODE] is None + assert entity.to_json()[ATTR_PERCENTAGE_STEP] == 100 / 4 diff --git a/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 00000000..d5743b5b --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,452 @@ +"""Test ZHA Gateway.""" + +import asyncio +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from slugify import slugify +from zigpy.application import ControllerApplication +from zigpy.device import Device as ZigpyDevice +from zigpy.profiles import zha +import zigpy.types +from zigpy.zcl.clusters import general, lighting +import zigpy.zdo.types + +from tests.common import async_find_group_entity_id, find_entity_id +from tests.conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.helpers import ZHAData +from zha.application.platforms import GroupEntity, PlatformEntity +from zha.application.platforms.light.const import ColorMode, LightEntityFeature +from zha.zigbee.device import Device +from zha.zigbee.group import Group, GroupMemberReference + +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" + + +@pytest.fixture +def zigpy_dev_basic(zigpy_device_mock: Callable[..., ZigpyDevice]) -> ZigpyDevice: + """Zigpy device with just a basic cluster.""" + return zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + ) + + +@pytest.fixture +async def zha_dev_basic( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_dev_basic: ZigpyDevice, # pylint: disable=redefined-outer-name +) -> Device: + """ZHA device with just a basic cluster.""" + + zha_device = await device_joined(zigpy_dev_basic) + return zha_device + + +@pytest.fixture +async def coordinator( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test ZHA light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee="00:15:8d:00:02:32:4f:32", + nwk=0x0000, + node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ) + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_light_1( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test ZHA light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE, + manufacturer="Philips", + model="LWA004", + ) + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_light_2( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test ZHA light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE2, + manufacturer="Sengled", + ) + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def get_group_entity(group: Group, entity_id: str) -> GroupEntity | None: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in group.group_entities.values() + } + + return entities.get(entity_id) + + +async def test_device_left( + zha_gateway: Gateway, + zigpy_dev_basic: ZigpyDevice, # pylint: disable=redefined-outer-name + zha_dev_basic: Device, # pylint: disable=redefined-outer-name +) -> None: + """Device leaving the network should become unavailable.""" + + assert zha_dev_basic.available is True + + zha_gateway.device_left(zigpy_dev_basic) + await zha_gateway.async_block_till_done() + assert zha_dev_basic.available is False + + +async def test_gateway_group_methods( + zha_gateway: Gateway, + device_light_1, # pylint: disable=redefined-outer-name + device_light_2, # pylint: disable=redefined-outer-name + coordinator, # pylint: disable=redefined-outer-name +) -> None: + """Test creating a group with 2 members.""" + + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + device_light_1._zha_gateway = zha_gateway + device_light_2._zha_gateway = zha_gateway + + member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee] + members = [ + GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.LIGHT, zha_group) + assert entity_id is not None + + entity: GroupEntity | None = get_group_entity(zha_group, entity_id) + assert entity is not None + + assert isinstance(entity, GroupEntity) + assert entity is not None + + assert entity.to_json() == { + "class_name": "LightGroup", + "effect_list": None, + "group_id": zha_group.group_id, + "max_mireds": 500, + "min_mireds": 153, + "name": "Test Group_0x0002", + "platform": Platform.LIGHT, + "state": { + "brightness": None, + "class_name": "LightGroup", + "color_mode": ColorMode.UNKNOWN, + "color_temp": None, + "effect": None, + "hs_color": None, + "off_brightness": None, + "off_with_transition": False, + "on": False, + "supported_color_modes": { + ColorMode.BRIGHTNESS, + ColorMode.ONOFF, + ColorMode.XY, + }, + "supported_features": LightEntityFeature.TRANSITION, + "xy_color": None, + }, + "supported_features": LightEntityFeature.TRANSITION, + "unique_id": "light.0x0002", + } + + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1) + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2) + + assert device_1_entity_id != device_2_entity_id + + device_1_light_entity = get_entity(device_light_1, device_1_entity_id) + device_2_light_entity = get_entity(device_light_2, device_2_entity_id) + + assert device_1_light_entity is not None + assert device_2_light_entity is not None + + # test get group by name + assert zha_group == zha_gateway.get_group(zha_group.name) + + # test removing a group + await zha_gateway.async_remove_zigpy_group(zha_group.group_id) + await zha_gateway.async_block_till_done() + + # we shouldn't have the group anymore + assert zha_gateway.get_group(zha_group.name) is None + + # the group entity should be cleaned up + entity = get_group_entity(zha_group, entity_id) + assert entity is None + + # test creating a group with 1 member + zha_group = await zha_gateway.async_create_zigpy_group( + "Test Group", [GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1)] + ) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 1 + for member in zha_group.members: + assert member.device.ieee in [device_light_1.ieee] + + # no entity should be created for a group with a single member + entity = get_group_entity(zha_group, entity_id) + assert entity is None + + with patch("zigpy.zcl.Cluster.request", side_effect=TimeoutError): + await zha_group.members[0].async_remove_from_group() + assert len(zha_group.members) == 1 + for member in zha_group.members: + assert member.device.ieee in [device_light_1.ieee] + + +async def test_gateway_create_group_with_id( + zha_gateway: Gateway, + device_light_1, # pylint: disable=redefined-outer-name + coordinator, # pylint: disable=redefined-outer-name +) -> None: + """Test creating a group with a specific ID.""" + + assert zha_gateway is not None + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + device_light_1._zha_gateway = zha_gateway + + zha_group = await zha_gateway.async_create_zigpy_group( + "Test Group", + [GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1)], + group_id=0x1234, + ) + await zha_gateway.async_block_till_done() + + assert len(zha_group.members) == 1 + assert zha_group.members[0].device is device_light_1 + assert zha_group.group_id == 0x1234 + + +@patch( + "zha.application.gateway.Gateway.load_devices", + MagicMock(), +) +@patch( + "zha.application.gateway.Gateway.load_groups", + MagicMock(), +) +@pytest.mark.parametrize( + ("device_path", "thread_state", "config_override"), + [ + ("/dev/ttyUSB0", True, {}), + ("socket://192.168.1.123:9999", False, {}), + ("socket://192.168.1.123:9999", True, {"use_thread": True}), + ], +) +async def test_gateway_initialize_bellows_thread( + device_path: str, + thread_state: bool, + config_override: dict, + zigpy_app_controller: ControllerApplication, + zha_data: ZHAData, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + zha_data.config_entry_data["data"]["device"]["path"] = device_path + zha_data.yaml_config["zigpy_config"] = config_override + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + zha_gw = Gateway(zha_data) + await zha_gw.async_initialize() + assert mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + await zha_gw.shutdown() + + +# pylint: disable=pointless-string-statement +"""TODO +@pytest.mark.parametrize( + ("device_path", "config_override", "expected_channel"), + [ + ("/dev/ttyUSB0", {}, None), + ("socket://192.168.1.123:9999", {}, None), + ("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20), + ("socket://core-silabs-multiprotocol:9999", {}, 15), + ("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20), + ], +) +async def test_gateway_force_multi_pan_channel( + device_path: str, + config_override: dict, + expected_channel: int | None, + zha_data: ZHAData, +) -> None: + #Test ZHA disabling the UART thread when connecting to a TCP coordinator. + zha_data.config_entry_data["data"]["device"]["path"] = device_path + zha_data.yaml_config["zigpy_config"] = config_override + zha_gw = Gateway(zha_data) + + _, config = zha_gw.get_application_controller_data() + assert config["network"]["channel"] == expected_channel +""" + + +@pytest.mark.parametrize("radio_concurrency", [1, 2, 8]) +async def test_startup_concurrency_limit( + radio_concurrency: int, + zigpy_app_controller: ControllerApplication, + zha_data: ZHAData, + zigpy_device_mock, +): + """Test ZHA gateway limits concurrency on startup.""" + zha_gw = Gateway(zha_data) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ): + await zha_gw.async_initialize() + + for i in range(50): + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=f"11:22:33:44:{i:08x}", + nwk=0x1234 + i, + ) + zigpy_dev.node_desc.mac_capability_flags |= ( + zigpy.zdo.types.NodeDescriptor.MACCapabilityFlags.MainsPowered + ) + + zha_gw.get_or_create_device(zigpy_dev) + + # Keep track of request concurrency during initialization + current_concurrency = 0 + concurrencies = [] + + async def mock_send_packet(*args, **kwargs): # pylint: disable=unused-argument + """Mock send packet.""" + nonlocal current_concurrency + + current_concurrency += 1 + concurrencies.append(current_concurrency) + + await asyncio.sleep(0.001) + + current_concurrency -= 1 + concurrencies.append(current_concurrency) + + type(zha_gw).radio_concurrency = PropertyMock(return_value=radio_concurrency) + assert zha_gw.radio_concurrency == radio_concurrency + + with patch( + "zha.zigbee.device.Device.async_initialize", + side_effect=mock_send_packet, + ): + await zha_gw.async_fetch_updated_state_mains() + + await zha_gw.shutdown() + + # Make sure concurrency was always limited + assert current_concurrency == 0 + assert min(concurrencies) == 0 + + if radio_concurrency > 1: + assert 1 <= max(concurrencies) < zha_gw.radio_concurrency + else: + assert 1 == max(concurrencies) == zha_gw.radio_concurrency diff --git a/tests/test_light.py b/tests/test_light.py new file mode 100644 index 00000000..0684c6f3 --- /dev/null +++ b/tests/test_light.py @@ -0,0 +1,1930 @@ +"""Test zha light.""" + +# pylint: disable=too-many-lines + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +import logging +from typing import Any +from unittest.mock import AsyncMock, call, patch, sentinel + +import pytest +from slugify import slugify +from zigpy.device import Device as ZigpyDevice +from zigpy.profiles import zha +from zigpy.zcl.clusters import general, lighting +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.const import ( + CONF_ALWAYS_PREFER_XY_COLOR_MODE, + CONF_GROUP_MEMBERS_ASSUME_STATE, + CUSTOM_CONFIGURATION, + ZHA_OPTIONS, +) +from zha.application.gateway import Gateway +from zha.application.platforms import GroupEntity, PlatformEntity +from zha.application.platforms.light.const import ( + FLASH_EFFECTS, + FLASH_LONG, + FLASH_SHORT, + ColorMode, +) +from zha.zigbee.device import Device +from zha.zigbee.group import Group, GroupMemberReference + +from .common import ( + async_find_group_entity_id, + find_entity_id, + send_attributes_report, + update_attribute_cache, +) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +ON = 1 +OFF = 0 +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e9" +IEEE_GROUPABLE_DEVICE3 = "03:2d:6f:00:0a:90:69:e7" + +_LOGGER = logging.getLogger(__name__) + +LIGHT_ON_OFF = { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.ON_OFF_LIGHT, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + } +} + +LIGHT_LEVEL = { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.DIMMABLE_LIGHT, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.LevelControl.cluster_id, + general.OnOff.cluster_id, + ], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + } +} + +LIGHT_COLOR = { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.LevelControl.cluster_id, + general.OnOff.cluster_id, + lighting.Color.cluster_id, + ], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + } +} + + +@pytest.fixture +async def coordinator( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee="00:15:8d:00:02:32:4f:32", + nwk=0x0000, + node_descriptor=b"\xf8\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff", + ) + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_light_1( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE, + manufacturer="Philips", + model="LWA004", + nwk=0xB79D, + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + } + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_light_2( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE2, + manufacturer="sengled", + nwk=0xC79E, + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + } + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_light_3( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha light platform.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE3, + nwk=0xB89F, + ) + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def eWeLink_light( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +): + """Mock eWeLink light.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + general.Groups.cluster_id, + general.Identify.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee="03:2d:6f:00:0a:90:69:e3", + manufacturer="eWeLink", + nwk=0xB79D, + ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes, + "color_temp_physical_min": 0, + "color_temp_physical_max": 0, + } + zha_device = await device_joined(zigpy_device) + zha_device.available = True + return zha_device + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def get_group_entity(group: Group, entity_id: str) -> GroupEntity | None: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in group.group_entities.values() + } + + return entities.get(entity_id) + + +@pytest.mark.looptime +async def test_light_refresh( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, +): + """Test zha light platform refresh.""" + zigpy_device = zigpy_device_mock(LIGHT_ON_OFF) + on_off_cluster = zigpy_device.endpoints[1].on_off + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} + zha_device = await device_joined(zigpy_device) + + entity_id = find_entity_id(Platform.LIGHT, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert bool(entity.get_state()["on"]) is False + + on_off_cluster.read_attributes.reset_mock() + + # not enough time passed + await asyncio.sleep(60) # 1 minute + await zha_gateway.async_block_till_done() + assert on_off_cluster.read_attributes.call_count == 0 + assert on_off_cluster.read_attributes.await_count == 0 + assert bool(entity.get_state()["on"]) is False + + # 1 interval - at least 1 call + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 1} + await asyncio.sleep(4800) # 80 minutes + await zha_gateway.async_block_till_done() + assert on_off_cluster.read_attributes.call_count >= 1 + assert on_off_cluster.read_attributes.await_count >= 1 + assert bool(entity.get_state()["on"]) is True + + # 2 intervals - at least 2 calls + on_off_cluster.PLUGGED_ATTR_READS = {"on_off": 0} + await asyncio.sleep(4800) # 80 minutes + await zha_gateway.async_block_till_done() + assert on_off_cluster.read_attributes.call_count >= 2 + assert on_off_cluster.read_attributes.await_count >= 2 + assert bool(entity.get_state()["on"]) is False + + +# TODO reporting is not checked +@patch( + "zigpy.zcl.clusters.lighting.Color.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.Identify.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@pytest.mark.parametrize( + "device, reporting", + [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 3))], +) +@pytest.mark.looptime +async def test_light( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, + device: dict, + reporting: tuple, # pylint: disable=unused-argument +) -> None: + """Test zha light platform.""" + + # create zigpy devices + zigpy_device = zigpy_device_mock(device) + cluster_color: lighting.Color = getattr( + zigpy_device.endpoints[1], "light_color", None + ) + if cluster_color: + cluster_color.PLUGGED_ATTR_READS = { + "color_temperature": 100, + "color_temp_physical_min": 0, + "color_temp_physical_max": 600, + "color_capabilities": lighting.ColorCapabilities.XY_attributes + | lighting.ColorCapabilities.Color_temperature, + } + update_attribute_cache(cluster_color) + zha_device = await device_joined(zigpy_device) + + entity_id = find_entity_id(Platform.LIGHT, zha_device) + assert entity_id is not None + + cluster_on_off: general.OnOff = zigpy_device.endpoints[1].on_off + cluster_level: general.LevelControl = getattr( + zigpy_device.endpoints[1], "level", None + ) + cluster_identify: general.Identify = getattr( + zigpy_device.endpoints[1], "identify", None + ) + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert bool(entity.get_state()["on"]) is False + + # test turning the lights on and off from the light + await async_test_on_off_from_light(zha_gateway, cluster_on_off, entity) + + # test turning the lights on and off from the client + await async_test_on_off_from_client(zha_gateway, cluster_on_off, entity) + await _async_shift_time(zha_gateway) + + # test short flashing the lights from the client + if cluster_identify: + await async_test_flash_from_client( + zha_gateway, cluster_identify, entity, FLASH_SHORT + ) + await _async_shift_time(zha_gateway) + + # test turning the lights on and off from the client + if cluster_level: + await async_test_level_on_off_from_client( + zha_gateway, cluster_on_off, cluster_level, entity + ) + await _async_shift_time(zha_gateway) + + # test getting a brightness change from the network + await async_test_on_from_light(zha_gateway, cluster_on_off, entity) + await async_test_dimmer_from_light( + zha_gateway, cluster_level, entity, 150, True + ) + + await async_test_off_from_client(zha_gateway, cluster_on_off, entity) + await _async_shift_time(zha_gateway) + + # test long flashing the lights from the client + if cluster_identify: + await async_test_flash_from_client( + zha_gateway, cluster_identify, entity, FLASH_LONG + ) + await _async_shift_time(zha_gateway) + await async_test_flash_from_client( + zha_gateway, cluster_identify, entity, FLASH_SHORT + ) + await _async_shift_time(zha_gateway) + + if cluster_color: + # test color temperature from the client with transition + assert entity.get_state()["brightness"] != 50 + assert entity.get_state()["color_temp"] != 200 + await entity.async_turn_on(brightness=50, transition=10, color_temp=200) + await zha_gateway.async_block_till_done() + assert entity.get_state()["brightness"] == 50 + assert entity.get_state()["color_temp"] == 200 + assert bool(entity.get_state()["on"]) is True + assert cluster_color.request.call_count == 1 + assert cluster_color.request.await_count == 1 + assert cluster_color.request.call_args == call( + False, + 10, + cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=200, + transition_time=100.0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + cluster_color.request.reset_mock() + + # test color xy from the client + assert entity.get_state()["xy_color"] != [13369, 18087] + await entity.async_turn_on(brightness=50, xy_color=[13369, 18087]) + await zha_gateway.async_block_till_done() + assert entity.get_state()["brightness"] == 50 + assert entity.get_state()["xy_color"] == [13369, 18087] + assert cluster_color.request.call_count == 1 + assert cluster_color.request.await_count == 1 + assert cluster_color.request.call_args == call( + False, + 7, + cluster_color.commands_by_name["move_to_color"].schema, + color_x=876137415, + color_y=1185331545, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + cluster_color.request.reset_mock() + + +async def async_test_on_off_from_light( + zha_gateway: Gateway, + cluster: general.OnOff, + entity: PlatformEntity | GroupEntity, +) -> None: + """Test on off functionality from the light.""" + # turn on at light + await send_attributes_report(zha_gateway, cluster, {1: 0, 0: 1, 2: 3}) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + + # turn off at light + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 3}) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is False + + +async def async_test_on_from_light( + zha_gateway: Gateway, + cluster: general.OnOff, + entity: PlatformEntity | GroupEntity, +) -> None: + """Test on off functionality from the light.""" + # turn on at light + await send_attributes_report( + zha_gateway, cluster, {general.OnOff.AttributeDefs.on_off.id: 1} + ) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + + +async def async_test_on_off_from_client( + zha_gateway: Gateway, + cluster: general.OnOff, + entity: PlatformEntity | GroupEntity, +) -> None: + """Test on off functionality from client.""" + # turn on via UI + cluster.request.reset_mock() + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + assert cluster.request.call_count == 1 + assert cluster.request.await_count == 1 + assert cluster.request.call_args == call( + False, + ON, + cluster.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + await async_test_off_from_client(zha_gateway, cluster, entity) + + +async def async_test_off_from_client( + zha_gateway: Gateway, + cluster: general.OnOff, + entity: PlatformEntity | GroupEntity, +) -> None: + """Test turning off the light from the client.""" + + # turn off via UI + cluster.request.reset_mock() + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is False + assert cluster.request.call_count == 1 + assert cluster.request.await_count == 1 + assert cluster.request.call_args == call( + False, + OFF, + cluster.commands_by_name["off"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + +async def _async_shift_time(zha_gateway: Gateway): + """Shift time to cause call later tasks to run.""" + await asyncio.sleep(11) + await zha_gateway.async_block_till_done() + + +@pytest.mark.looptime +async def async_test_level_on_off_from_client( + zha_gateway: Gateway, + on_off_cluster: general.OnOff, + level_cluster: general.LevelControl, + entity: PlatformEntity | GroupEntity, + expected_default_transition: int = 0, +) -> None: + """Test on off functionality from client.""" + + async def _reset_light(): + # reset the light + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + on_off_cluster.request.reset_mock() + level_cluster.request.reset_mock() + assert bool(entity.get_state()["on"]) is False + + await _reset_light() + await _async_shift_time(zha_gateway) + + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + assert on_off_cluster.request.call_count == 1 + assert on_off_cluster.request.await_count == 1 + assert level_cluster.request.call_count == 0 + assert level_cluster.request.await_count == 0 + assert on_off_cluster.request.call_args == call( + False, + ON, + on_off_cluster.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + await _reset_light() + await _async_shift_time(zha_gateway) + + await entity.async_turn_on(transition=10) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + assert on_off_cluster.request.call_count == 0 + assert on_off_cluster.request.await_count == 0 + assert level_cluster.request.call_count == 1 + assert level_cluster.request.await_count == 1 + assert level_cluster.request.call_args == call( + False, + level_cluster.commands_by_name["move_to_level_with_on_off"].id, + level_cluster.commands_by_name["move_to_level_with_on_off"].schema, + level=254, + transition_time=100, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + await _reset_light() + + await entity.async_turn_on(brightness=10) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + # the onoff cluster is now not used when brightness is present by default + assert on_off_cluster.request.call_count == 0 + assert on_off_cluster.request.await_count == 0 + assert level_cluster.request.call_count == 1 + assert level_cluster.request.await_count == 1 + assert level_cluster.request.call_args == call( + False, + level_cluster.commands_by_name["move_to_level_with_on_off"].id, + level_cluster.commands_by_name["move_to_level_with_on_off"].schema, + level=10, + transition_time=int(expected_default_transition), + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + await _reset_light() + + await async_test_off_from_client(zha_gateway, on_off_cluster, entity) + + +async def async_test_dimmer_from_light( + zha_gateway: Gateway, + cluster: general.LevelControl, + entity: PlatformEntity | GroupEntity, + level: int, + expected_state: bool, +) -> None: + """Test dimmer functionality from the light.""" + + await send_attributes_report( + zha_gateway, cluster, {1: level + 10, 0: level, 2: level - 10 or 22} + ) + await zha_gateway.async_block_till_done() + assert entity.get_state()["on"] == expected_state + # hass uses None for brightness of 0 in state attributes + if level == 0: + assert entity.get_state()["brightness"] is None + else: + assert entity.get_state()["brightness"] == level + + +async def async_test_flash_from_client( + zha_gateway: Gateway, + cluster: general.Identify, + entity: PlatformEntity | GroupEntity, + flash: str, +) -> None: + """Test flash functionality from client.""" + # turn on via UI + cluster.request.reset_mock() + await entity.async_turn_on(flash=flash) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + assert cluster.request.call_count == 1 + assert cluster.request.await_count == 1 + assert cluster.request.call_args == call( + False, + cluster.commands_by_name["trigger_effect"].id, + cluster.commands_by_name["trigger_effect"].schema, + effect_id=FLASH_EFFECTS[flash], + effect_variant=general.Identify.EffectVariant.Default, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + +@patch( + "zigpy.zcl.clusters.lighting.Color.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.Identify.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@pytest.mark.looptime +async def test_zha_group_light_entity( + device_light_1: Device, # pylint: disable=redefined-outer-name + device_light_2: Device, # pylint: disable=redefined-outer-name + device_light_3: Device, # pylint: disable=redefined-outer-name + coordinator: Device, # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test the light entity for a ZHA group.""" + + member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee] + members = [ + GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.LIGHT, zha_group) + assert entity_id is not None + + entity: GroupEntity | None = get_group_entity(zha_group, entity_id) + assert entity is not None + + assert isinstance(entity, GroupEntity) + assert entity.group_id == zha_group.group_id + + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1) + assert device_1_entity_id is not None + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2) + assert device_2_entity_id is not None + device_3_entity_id = find_entity_id(Platform.LIGHT, device_light_3) + assert device_3_entity_id is not None + + device_1_light_entity = get_entity(device_light_1, device_1_entity_id) + assert device_1_light_entity is not None + + device_2_light_entity = get_entity(device_light_2, device_2_entity_id) + assert device_2_light_entity is not None + + device_3_light_entity = get_entity(device_light_3, device_3_entity_id) + assert device_3_light_entity is not None + + assert device_1_entity_id not in (device_2_entity_id, device_3_entity_id) + assert device_2_entity_id != device_3_entity_id + + group_entity_id = async_find_group_entity_id(Platform.LIGHT, zha_group) + assert group_entity_id is not None + entity = get_group_entity(zha_group, group_entity_id) + assert entity is not None + + assert device_1_light_entity.unique_id in zha_group.all_member_entity_unique_ids + assert device_2_light_entity.unique_id in zha_group.all_member_entity_unique_ids + assert device_3_light_entity.unique_id not in zha_group.all_member_entity_unique_ids + + group_cluster_on_off = zha_group.zigpy_group.endpoint[general.OnOff.cluster_id] + group_cluster_level = zha_group.zigpy_group.endpoint[ + general.LevelControl.cluster_id + ] + group_cluster_identify = zha_group.zigpy_group.endpoint[general.Identify.cluster_id] + assert group_cluster_identify is not None + + dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off + dev2_cluster_on_off = device_light_2.device.endpoints[1].on_off + dev3_cluster_on_off = device_light_3.device.endpoints[1].on_off + + dev1_cluster_level = device_light_1.device.endpoints[1].level + + # test that the lights were created and are off + assert bool(entity.get_state()["on"]) is False + + # test turning the lights on and off from the client + await async_test_on_off_from_client(zha_gateway, group_cluster_on_off, entity) + await _async_shift_time(zha_gateway) + + # test turning the lights on and off from the light + await async_test_on_off_from_light(zha_gateway, dev1_cluster_on_off, entity) + await _async_shift_time(zha_gateway) + + # test turning the lights on and off from the client + await async_test_level_on_off_from_client( + zha_gateway, + group_cluster_on_off, + group_cluster_level, + entity, + expected_default_transition=1, + ) + await _async_shift_time(zha_gateway) + + # test getting a brightness change from the network + await async_test_on_from_light(zha_gateway, dev1_cluster_on_off, entity) + await async_test_dimmer_from_light( + zha_gateway, dev1_cluster_level, entity, 150, True + ) + + # test short flashing the lights from the client + await async_test_flash_from_client( + zha_gateway, group_cluster_identify, entity, FLASH_SHORT + ) + await _async_shift_time(zha_gateway) + # test long flashing the lights from the client + await async_test_flash_from_client( + zha_gateway, group_cluster_identify, entity, FLASH_LONG + ) + await _async_shift_time(zha_gateway) + + assert len(zha_group.members) == 2 + # test some of the group logic to make sure we key off states correctly + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1}) + await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + + # test that group light is on + assert device_1_light_entity.get_state()["on"] is True + assert device_2_light_entity.get_state()["on"] is True + assert bool(entity.get_state()["on"]) is True + + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0}) + await zha_gateway.async_block_till_done() + + # test that group light is still on + assert device_1_light_entity.get_state()["on"] is False + assert device_2_light_entity.get_state()["on"] is True + assert bool(entity.get_state()["on"]) is True + + await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 0}) + await zha_gateway.async_block_till_done() + + # test that group light is now off + assert device_1_light_entity.get_state()["on"] is False + assert device_2_light_entity.get_state()["on"] is False + assert bool(entity.get_state()["on"]) is False + + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + + # test that group light is now back on + assert device_1_light_entity.get_state()["on"] is True + assert device_2_light_entity.get_state()["on"] is False + assert bool(entity.get_state()["on"]) is True + + # turn it off to test a new member add being tracked + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0}) + await zha_gateway.async_block_till_done() + assert device_1_light_entity.get_state()["on"] is False + assert device_2_light_entity.get_state()["on"] is False + assert bool(entity.get_state()["on"]) is False + + # add a new member and test that his state is also tracked + await zha_group.async_add_members( + [GroupMemberReference(ieee=device_light_3.ieee, endpoint_id=1)] + ) + await zha_gateway.async_block_till_done() + assert device_3_light_entity.unique_id in zha_group.all_member_entity_unique_ids + assert len(zha_group.members) == 3 + entity = get_group_entity(zha_group, group_entity_id) + assert entity is not None + await send_attributes_report(zha_gateway, dev3_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + + assert device_1_light_entity.get_state()["on"] is False + assert device_2_light_entity.get_state()["on"] is False + assert device_3_light_entity.get_state()["on"] is True + assert bool(entity.get_state()["on"]) is True + + # make the group have only 1 member and now there should be no entity + await zha_group.async_remove_members( + [ + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_light_3.ieee, endpoint_id=1), + ] + ) + await zha_gateway.async_block_till_done() + assert len(zha_group.members) == 1 + assert device_2_light_entity.unique_id not in zha_group.all_member_entity_unique_ids + assert device_3_light_entity.unique_id not in zha_group.all_member_entity_unique_ids + # assert entity.unique_id not in group_proxy.group_model.entities + + entity = get_group_entity(zha_group, group_entity_id) + assert entity is None + + # add a member back and ensure that the group entity was created again + await zha_group.async_add_members( + [GroupMemberReference(ieee=device_light_3.ieee, endpoint_id=1)] + ) + await zha_gateway.async_block_till_done() + assert len(zha_group.members) == 2 + + entity = get_group_entity(zha_group, group_entity_id) + assert entity is not None + await send_attributes_report(zha_gateway, dev3_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + + # add a 3rd member and ensure we still have an entity and we track the new member + # First we turn the lights currently in the group off + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0}) + await send_attributes_report(zha_gateway, dev3_cluster_on_off, {0: 0}) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is False + + # this will test that _reprobe_group is used correctly + await zha_group.async_add_members( + [ + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + GroupMemberReference(ieee=coordinator.ieee, endpoint_id=1), + ] + ) + await zha_gateway.async_block_till_done() + assert len(zha_group.members) == 4 + entity = get_group_entity(zha_group, group_entity_id) + assert entity is not None + await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["on"]) is True + + await zha_group.async_remove_members( + [GroupMemberReference(ieee=coordinator.ieee, endpoint_id=1)] + ) + await zha_gateway.async_block_till_done() + entity = get_group_entity(zha_group, group_entity_id) + assert entity is not None + assert bool(entity.get_state()["on"]) is True + assert len(zha_group.members) == 3 + + # remove the group and ensure that there is no entity and that the entity registry is cleaned up + await zha_gateway.async_remove_zigpy_group(zha_group.group_id) + await zha_gateway.async_block_till_done() + entity = get_group_entity(zha_group, group_entity_id) + assert entity is None + + +@pytest.mark.parametrize( + ("plugged_attr_reads", "config_override", "expected_state"), + [ + # HS light without cached hue or saturation + ( + { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Hue_and_saturation + ), + }, + {CONF_ALWAYS_PREFER_XY_COLOR_MODE: False}, + {}, + ), + # HS light with cached hue + ( + { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Hue_and_saturation + ), + "current_hue": 100, + }, + {CONF_ALWAYS_PREFER_XY_COLOR_MODE: False}, + {}, + ), + # HS light with cached saturation + ( + { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Hue_and_saturation + ), + "current_saturation": 100, + }, + {CONF_ALWAYS_PREFER_XY_COLOR_MODE: False}, + {}, + ), + # HS light with both + ( + { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Hue_and_saturation + ), + "current_hue": 100, + "current_saturation": 100, + }, + {CONF_ALWAYS_PREFER_XY_COLOR_MODE: False}, + {}, + ), + ], +) +# TODO expected_state is not used +async def test_light_initialization( + zha_gateway: Gateway, + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + plugged_attr_reads: dict[str, Any], + config_override: dict[str, Any], + expected_state: dict[str, Any], # pylint: disable=unused-argument +) -> None: + """Test ZHA light initialization with cached attributes and color modes.""" + + # create zigpy devices + zigpy_device = zigpy_device_mock(LIGHT_COLOR) + + # mock attribute reads + zigpy_device.endpoints[1].light_color.PLUGGED_ATTR_READS = plugged_attr_reads + + for key in config_override: + zha_gateway.config.config_entry_data["options"][CUSTOM_CONFIGURATION][ + ZHA_OPTIONS + ][key] = config_override[key] + zha_device = await device_joined(zigpy_device) + entity_id = find_entity_id(Platform.LIGHT, zha_device) + assert entity_id is not None + + # TODO ensure hue and saturation are properly set on startup + + +@patch( + "zigpy.zcl.clusters.lighting.Color.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.Identify.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_transitions( + zha_gateway: Gateway, + device_light_1, # pylint: disable=redefined-outer-name + device_light_2, # pylint: disable=redefined-outer-name + eWeLink_light, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA light transition code.""" + + member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee] + members = [ + GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.LIGHT, zha_group) + assert entity_id is not None + + entity: GroupEntity | None = get_group_entity(zha_group, entity_id) + assert entity is not None + + assert isinstance(entity, GroupEntity) + assert entity.group_id == zha_group.group_id + + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1) + assert device_1_entity_id is not None + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2) + assert device_2_entity_id is not None + device_3_entity_id = find_entity_id(Platform.LIGHT, eWeLink_light) + assert device_3_entity_id is not None + + device_1_light_entity = get_entity(device_light_1, device_1_entity_id) + assert device_1_light_entity is not None + + device_2_light_entity = get_entity(device_light_2, device_2_entity_id) + assert device_2_light_entity is not None + + eWeLink_light_entity = get_entity(eWeLink_light, device_3_entity_id) + assert eWeLink_light_entity is not None + + assert device_1_entity_id not in (device_2_entity_id, device_3_entity_id) + assert device_2_entity_id != device_3_entity_id + + group_entity_id = async_find_group_entity_id(Platform.LIGHT, zha_group) + assert group_entity_id is not None + entity = get_group_entity(zha_group, group_entity_id) + assert entity is not None + + assert device_1_light_entity.unique_id in zha_group.all_member_entity_unique_ids + assert device_2_light_entity.unique_id in zha_group.all_member_entity_unique_ids + assert eWeLink_light_entity.unique_id not in zha_group.all_member_entity_unique_ids + + dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off + dev2_cluster_on_off = device_light_2.device.endpoints[1].on_off + eWeLink_cluster_on_off = eWeLink_light.device.endpoints[1].on_off + + dev1_cluster_level = device_light_1.device.endpoints[1].level + dev2_cluster_level = device_light_2.device.endpoints[1].level + eWeLink_cluster_level = eWeLink_light.device.endpoints[1].level + + dev1_cluster_color = device_light_1.device.endpoints[1].light_color + dev2_cluster_color = device_light_2.device.endpoints[1].light_color + eWeLink_cluster_color = eWeLink_light.device.endpoints[1].light_color + + # test that the lights were created and are off + assert bool(entity.get_state()["on"]) is False + assert bool(device_1_light_entity.get_state()["on"]) is False + assert bool(device_2_light_entity.get_state()["on"]) is False + + # first test 0 length transition with no color and no brightness provided + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_level.request.reset_mock() + await device_1_light_entity.async_turn_on(transition=0) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=254, # default "full on" brightness + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is True + assert device_1_light_entity.get_state()["brightness"] == 254 + + # test 0 length transition with no color and no brightness provided again, but for "force on" lights + eWeLink_cluster_on_off.request.reset_mock() + eWeLink_cluster_level.request.reset_mock() + + await eWeLink_light_entity.async_turn_on(transition=0) + await zha_gateway.async_block_till_done() + assert eWeLink_cluster_on_off.request.call_count == 1 + assert eWeLink_cluster_on_off.request.await_count == 1 + assert eWeLink_cluster_on_off.request.call_args_list[0] == call( + False, + eWeLink_cluster_on_off.commands_by_name["on"].id, + eWeLink_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert eWeLink_cluster_color.request.call_count == 0 + assert eWeLink_cluster_color.request.await_count == 0 + assert eWeLink_cluster_level.request.call_count == 1 + assert eWeLink_cluster_level.request.await_count == 1 + assert eWeLink_cluster_level.request.call_args == call( + False, + eWeLink_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + eWeLink_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=254, # default "full on" brightness + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(eWeLink_light_entity.get_state()["on"]) is True + assert eWeLink_light_entity.get_state()["brightness"] == 254 + + eWeLink_cluster_on_off.request.reset_mock() + eWeLink_cluster_level.request.reset_mock() + + # test 0 length transition with brightness, but no color provided + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_level.request.reset_mock() + await device_1_light_entity.async_turn_on(transition=0, brightness=50) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=50, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is True + assert device_1_light_entity.get_state()["brightness"] == 50 + + dev1_cluster_level.request.reset_mock() + + # test non 0 length transition with color provided while light is on + await device_1_light_entity.async_turn_on( + transition=3.5, brightness=18, color_temp=432 + ) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=18, + transition_time=35, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + dev1_cluster_color.commands_by_name["move_to_color_temp"].id, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=432, + transition_time=35, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is True + assert device_1_light_entity.get_state()["brightness"] == 18 + assert device_1_light_entity.get_state()["color_temp"] == 432 + assert device_1_light_entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # test 0 length transition to turn light off + await device_1_light_entity.async_turn_off(transition=0) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 1 + assert dev1_cluster_level.request.await_count == 1 + assert dev1_cluster_level.request.call_args == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=0, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is False + + dev1_cluster_level.request.reset_mock() + + # test non 0 length transition and color temp while turning light on (new_color_provided_while_off) + await device_1_light_entity.async_turn_on( + transition=1, brightness=25, color_temp=235 + ) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 2 + assert dev1_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev1_cluster_level.request.call_args_list[0] == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=2, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + dev1_cluster_color.commands_by_name["move_to_color_temp"].id, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=235, + transition_time=0, # no transition when new_color_provided_while_off + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + dev1_cluster_level.commands_by_name["move_to_level"].id, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + level=25, + transition_time=10, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is True + assert device_1_light_entity.get_state()["brightness"] == 25 + assert device_1_light_entity.get_state()["color_temp"] == 235 + assert device_1_light_entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off + await device_1_light_entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + + assert bool(entity.get_state()["on"]) is False + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test no transition provided and color temp while turning light on (new_color_provided_while_off) + await device_1_light_entity.async_turn_on(brightness=25, color_temp=236) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 2 + assert dev1_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev1_cluster_level.request.call_args_list[0] == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=2, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + dev1_cluster_color.commands_by_name["move_to_color_temp"].id, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=236, + transition_time=0, # no transition when new_color_provided_while_off + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + dev1_cluster_level.commands_by_name["move_to_level"].id, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + level=25, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is True + assert device_1_light_entity.get_state()["brightness"] == 25 + assert device_1_light_entity.get_state()["color_temp"] == 236 + assert device_1_light_entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off to setup group test + await device_1_light_entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + assert bool(entity.get_state()["on"]) is False + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test no transition when the same color temp is provided from off + await device_1_light_entity.async_turn_on(color_temp=236) + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + + assert dev1_cluster_on_off.request.call_args == call( + False, + dev1_cluster_on_off.commands_by_name["on"].id, + dev1_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert dev1_cluster_color.request.call_args == call( + False, + dev1_cluster_color.commands_by_name["move_to_color_temp"].id, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=236, + transition_time=0, # no transition when new_color_provided_while_off + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_1_light_entity.get_state()["on"]) is True + assert device_1_light_entity.get_state()["brightness"] == 25 + assert device_1_light_entity.get_state()["color_temp"] == 236 + assert device_1_light_entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + # turn light 1 back off to setup group test + await device_1_light_entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 0 + assert dev1_cluster_color.request.await_count == 0 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + assert bool(entity.get_state()["on"]) is False + + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_color.request.reset_mock() + dev1_cluster_level.request.reset_mock() + + # test sengled light uses default minimum transition time + dev2_cluster_on_off.request.reset_mock() + dev2_cluster_color.request.reset_mock() + dev2_cluster_level.request.reset_mock() + + await device_2_light_entity.async_turn_on(transition=0, brightness=100) + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=100, + transition_time=1, # transition time - sengled light uses default minimum + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_2_light_entity.get_state()["on"]) is True + assert device_2_light_entity.get_state()["brightness"] == 100 + + dev2_cluster_level.request.reset_mock() + + # turn the sengled light back off + await device_2_light_entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + assert bool(device_2_light_entity.get_state()["on"]) is False + + dev2_cluster_on_off.request.reset_mock() + + # test non 0 length transition and color temp while turning light on and sengled (new_color_provided_while_off) + await device_2_light_entity.async_turn_on( + transition=1, brightness=25, color_temp=235 + ) + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 1 + assert dev2_cluster_color.request.await_count == 1 + assert dev2_cluster_level.request.call_count == 2 + assert dev2_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev2_cluster_level.request.call_args_list[0] == call( + False, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=2, + transition_time=1, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev2_cluster_color.request.call_args == call( + False, + dev2_cluster_color.commands_by_name["move_to_color_temp"].id, + dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=235, + transition_time=1, # sengled transition == 1 when new_color_provided_while_off + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev2_cluster_level.request.call_args_list[1] == call( + False, + dev2_cluster_level.commands_by_name["move_to_level"].id, + dev2_cluster_level.commands_by_name["move_to_level"].schema, + level=25, + transition_time=10, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_2_light_entity.get_state()["on"]) is True + assert device_2_light_entity.get_state()["brightness"] == 25 + assert device_2_light_entity.get_state()["color_temp"] == 235 + assert device_2_light_entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + dev2_cluster_level.request.reset_mock() + dev2_cluster_color.request.reset_mock() + + # turn the sengled light back off + await device_2_light_entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + assert bool(device_2_light_entity.get_state()["on"]) is False + + dev2_cluster_on_off.request.reset_mock() + + # test non 0 length transition and color temp while turning group light on (new_color_provided_while_off) + await entity.async_turn_on(transition=1, brightness=25, color_temp=235) + await zha_gateway.async_block_till_done() + + group_on_off_cluster_handler = zha_group.endpoint[general.OnOff.cluster_id] + group_level_cluster_handler = zha_group.endpoint[general.LevelControl.cluster_id] + group_color_cluster_handler = zha_group.endpoint[lighting.Color.cluster_id] + assert group_on_off_cluster_handler.request.call_count == 0 + assert group_on_off_cluster_handler.request.await_count == 0 + assert group_color_cluster_handler.request.call_count == 1 + assert group_color_cluster_handler.request.await_count == 1 + assert group_level_cluster_handler.request.call_count == 1 + assert group_level_cluster_handler.request.await_count == 1 + + # groups are omitted from the 3 call dance for new_color_provided_while_off + assert group_color_cluster_handler.request.call_args == call( + False, + dev2_cluster_color.commands_by_name["move_to_color_temp"].id, + dev2_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=235, + transition_time=10, # sengled transition == 1 when new_color_provided_while_off + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert group_level_cluster_handler.request.call_args == call( + False, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=25, + transition_time=10, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(entity.get_state()["on"]) is True + assert entity.get_state()["brightness"] == 25 + assert entity.get_state()["color_temp"] == 235 + assert entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + group_on_off_cluster_handler.request.reset_mock() + group_color_cluster_handler.request.reset_mock() + group_level_cluster_handler.request.reset_mock() + + # turn the sengled light back on + await device_2_light_entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 1 + assert dev2_cluster_on_off.request.await_count == 1 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 0 + assert dev2_cluster_level.request.await_count == 0 + assert bool(device_2_light_entity.get_state()["on"]) is True + + dev2_cluster_on_off.request.reset_mock() + + # turn the light off with a transition + await device_2_light_entity.async_turn_off(transition=2) + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=0, + transition_time=20, # transition time + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_2_light_entity.get_state()["on"]) is False + + dev2_cluster_level.request.reset_mock() + + # turn the light back on with no args should use a transition and last known brightness + await device_2_light_entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert dev2_cluster_on_off.request.call_count == 0 + assert dev2_cluster_on_off.request.await_count == 0 + assert dev2_cluster_color.request.call_count == 0 + assert dev2_cluster_color.request.await_count == 0 + assert dev2_cluster_level.request.call_count == 1 + assert dev2_cluster_level.request.await_count == 1 + assert dev2_cluster_level.request.call_args == call( + False, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev2_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=25, + transition_time=1, # transition time - sengled light uses default minimum + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(device_2_light_entity.get_state()["on"]) is True + + dev2_cluster_level.request.reset_mock() + eWeLink_cluster_on_off.request.reset_mock() + eWeLink_cluster_level.request.reset_mock() + eWeLink_cluster_color.request.reset_mock() + + # test eWeLink color temp while turning light on from off (new_color_provided_while_off) + await eWeLink_light_entity.async_turn_on(color_temp=235) + await zha_gateway.async_block_till_done() + assert eWeLink_cluster_on_off.request.call_count == 1 + assert eWeLink_cluster_on_off.request.await_count == 1 + assert eWeLink_cluster_color.request.call_count == 1 + assert eWeLink_cluster_color.request.await_count == 1 + assert eWeLink_cluster_level.request.call_count == 0 + assert eWeLink_cluster_level.request.await_count == 0 + + # first it comes on + assert eWeLink_cluster_on_off.request.call_args_list[0] == call( + False, + eWeLink_cluster_on_off.commands_by_name["on"].id, + eWeLink_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert eWeLink_cluster_color.request.call_args == call( + False, + eWeLink_cluster_color.commands_by_name["move_to_color_temp"].id, + eWeLink_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=235, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(eWeLink_light_entity.get_state()["on"]) is True + assert eWeLink_light_entity.get_state()["color_temp"] == 235 + assert eWeLink_light_entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + assert eWeLink_light_entity.to_json()["min_mireds"] == 153 + assert eWeLink_light_entity.to_json()["max_mireds"] == 500 + + +@patch( + "zigpy.zcl.clusters.lighting.Color.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.LevelControl.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +async def test_on_with_off_color( + zha_gateway: Gateway, + device_light_1, # pylint: disable=redefined-outer-name +) -> None: + """Test turning on the light and sending color commands before on/level commands for supporting lights.""" + + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1) + dev1_cluster_on_off = device_light_1.device.endpoints[1].on_off + dev1_cluster_level = device_light_1.device.endpoints[1].level + dev1_cluster_color = device_light_1.device.endpoints[1].light_color + + entity = get_entity(device_light_1, device_1_entity_id) + assert entity is not None + + # Execute_if_off will override the "enhanced turn on from an off-state" config option that's enabled here + dev1_cluster_color.PLUGGED_ATTR_READS = { + "options": lighting.Color.Options.Execute_if_off + } + update_attribute_cache(dev1_cluster_color) + + # turn on via UI + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + await entity.async_turn_on(color_temp=235) + + assert dev1_cluster_on_off.request.call_count == 1 + assert dev1_cluster_on_off.request.await_count == 1 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 0 + assert dev1_cluster_level.request.await_count == 0 + + assert dev1_cluster_on_off.request.call_args_list[0] == call( + False, + dev1_cluster_on_off.commands_by_name["on"].id, + dev1_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + dev1_cluster_color.commands_by_name["move_to_color_temp"].id, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=235, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(entity.get_state()["on"]) is True + assert entity.get_state()["color_temp"] == 235 + assert entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + # now let's turn off the Execute_if_off option and see if the old behavior is restored + dev1_cluster_color.PLUGGED_ATTR_READS = {"options": 0} + update_attribute_cache(dev1_cluster_color) + + # turn off via UI, so the old "enhanced turn on from an off-state" behavior can do something + await async_test_off_from_client(zha_gateway, dev1_cluster_on_off, entity) + + # turn on via UI (with a different color temp, so the "enhanced turn on" does something) + dev1_cluster_on_off.request.reset_mock() + dev1_cluster_level.request.reset_mock() + dev1_cluster_color.request.reset_mock() + + await entity.async_turn_on(color_temp=240) + + assert dev1_cluster_on_off.request.call_count == 0 + assert dev1_cluster_on_off.request.await_count == 0 + assert dev1_cluster_color.request.call_count == 1 + assert dev1_cluster_color.request.await_count == 1 + assert dev1_cluster_level.request.call_count == 2 + assert dev1_cluster_level.request.await_count == 2 + + # first it comes on with no transition at 2 brightness + assert dev1_cluster_level.request.call_args_list[0] == call( + False, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].id, + dev1_cluster_level.commands_by_name["move_to_level_with_on_off"].schema, + level=2, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_color.request.call_args == call( + False, + dev1_cluster_color.commands_by_name["move_to_color_temp"].id, + dev1_cluster_color.commands_by_name["move_to_color_temp"].schema, + color_temp_mireds=240, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert dev1_cluster_level.request.call_args_list[1] == call( + False, + dev1_cluster_level.commands_by_name["move_to_level"].id, + dev1_cluster_level.commands_by_name["move_to_level"].schema, + level=254, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + assert bool(entity.get_state()["on"]) is True + assert entity.get_state()["color_temp"] == 240 + assert entity.get_state()["brightness"] == 254 + assert entity.get_state()["color_mode"] == ColorMode.COLOR_TEMP + + +@patch( + "zigpy.zcl.clusters.general.OnOff.request", + new=AsyncMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) +@patch( + "zha.application.platforms.light.const.ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY", + new=0, +) +@pytest.mark.looptime +async def test_group_member_assume_state( + zha_gateway: Gateway, + coordinator, # pylint: disable=redefined-outer-name + device_light_1, # pylint: disable=redefined-outer-name + device_light_2, # pylint: disable=redefined-outer-name +) -> None: + """Test the group members assume state function.""" + + zha_gateway.config.config_entry_data["options"][CUSTOM_CONFIGURATION][ZHA_OPTIONS][ + CONF_GROUP_MEMBERS_ASSUME_STATE + ] = True + + zha_gateway.coordinator_zha_device = coordinator + coordinator._zha_gateway = zha_gateway + device_light_1._zha_gateway = zha_gateway + device_light_2._zha_gateway = zha_gateway + + member_ieee_addresses = [device_light_1.ieee, device_light_2.ieee] + members = [ + GroupMemberReference(ieee=device_light_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_light_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.LIGHT, zha_group) + assert entity_id is not None + + entity: GroupEntity | None = get_group_entity(zha_group, entity_id) + assert entity is not None + + assert isinstance(entity, GroupEntity) + assert entity.group_id == zha_group.group_id + + device_1_entity_id = find_entity_id(Platform.LIGHT, device_light_1) + device_2_entity_id = find_entity_id(Platform.LIGHT, device_light_2) + + assert device_1_entity_id != device_2_entity_id + + device_1_light_entity = get_entity(device_light_1, device_1_entity_id) + device_2_light_entity = get_entity(device_light_2, device_2_entity_id) + + assert device_1_light_entity is not None + assert device_2_light_entity is not None + + group_cluster_on_off = zha_group.endpoint[general.OnOff.cluster_id] + + # test that the lights were created and are off + assert bool(entity.get_state()["on"]) is False + + group_cluster_on_off.request.reset_mock() + await asyncio.sleep(11) + + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + + # members also instantly assume STATE_ON + assert bool(device_1_light_entity.get_state()["on"]) is True + assert bool(device_2_light_entity.get_state()["on"]) is True + assert bool(entity.get_state()["on"]) is True + + # turn off via UI + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + + # members also instantly assume STATE_OFF + assert bool(device_1_light_entity.get_state()["on"]) is False + assert bool(device_2_light_entity.get_state()["on"]) is False + assert bool(entity.get_state()["on"]) is False diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 00000000..1b2ed20a --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,227 @@ +"""Test zha lock.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from slugify import slugify +from zigpy.device import Device as ZigpyDevice +import zigpy.profiles.zha +from zigpy.zcl.clusters import closures, general +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.zigbee.device import Device + +from .common import find_entity_id, send_attributes_report, update_attribute_cache +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +LOCK_DOOR = 0 +UNLOCK_DOOR = 1 +SET_PIN_CODE = 5 +CLEAR_PIN_CODE = 7 +SET_USER_STATUS = 9 + + +@pytest.fixture +async def lock( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> tuple[Device, closures.DoorLock]: + """Lock cluster fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [closures.DoorLock.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.DOOR_LOCK, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ) + + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].door_lock + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +async def test_lock( + lock: tuple[Device, closures.DoorLock], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zha lock platform.""" + + zha_device, cluster = lock + entity_id = find_entity_id(Platform.LOCK, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["is_locked"] is False + + # set state to locked + await send_attributes_report(zha_gateway, cluster, {1: 0, 0: 1, 2: 2}) + assert entity.get_state()["is_locked"] is True + + # set state to unlocked + await send_attributes_report(zha_gateway, cluster, {1: 0, 0: 2, 2: 3}) + assert entity.get_state()["is_locked"] is False + + # lock from HA + await async_lock(zha_gateway, cluster, entity) + + # unlock from HA + await async_unlock(zha_gateway, cluster, entity) + + # set user code + await async_set_user_code(zha_gateway, cluster, entity) + + # clear user code + await async_clear_user_code(zha_gateway, cluster, entity) + + # enable user code + await async_enable_user_code(zha_gateway, cluster, entity) + + # disable user code + await async_disable_user_code(zha_gateway, cluster, entity) + + # test updating entity state from client + cluster.read_attributes.reset_mock() + assert entity.get_state()["is_locked"] is False + cluster.PLUGGED_ATTR_READS = {"lock_state": 1} + update_attribute_cache(cluster) + await entity.async_update() + await zha_gateway.async_block_till_done() + assert cluster.read_attributes.call_count == 1 + assert entity.get_state()["is_locked"] is True + + +async def async_lock( + zha_gateway: Gateway, + cluster: closures.DoorLock, + entity: PlatformEntity, +) -> None: + """Test lock functionality from client.""" + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): + await entity.async_lock() + await zha_gateway.async_block_till_done() + assert entity.get_state()["is_locked"] is True + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == LOCK_DOOR + cluster.request.reset_mock() + + # test unlock failure + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.FAILURE]): + await entity.async_unlock() + await zha_gateway.async_block_till_done() + assert entity.get_state()["is_locked"] is True + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == UNLOCK_DOOR + cluster.request.reset_mock() + + +async def async_unlock( + zha_gateway: Gateway, + cluster: closures.DoorLock, + entity: PlatformEntity, +) -> None: + """Test lock functionality from client.""" + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): + await entity.async_unlock() + await zha_gateway.async_block_till_done() + assert entity.get_state()["is_locked"] is False + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == UNLOCK_DOOR + cluster.request.reset_mock() + + # test lock failure + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.FAILURE]): + await entity.async_lock() + await zha_gateway.async_block_till_done() + assert entity.get_state()["is_locked"] is False + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == LOCK_DOOR + cluster.request.reset_mock() + + +async def async_set_user_code( + zha_gateway: Gateway, + cluster: closures.DoorLock, + entity: PlatformEntity, +) -> None: + """Test set lock code functionality from client.""" + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): + await entity.async_set_lock_user_code(3, "13246579") + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == SET_PIN_CODE + assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2 + assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Enabled + assert ( + cluster.request.call_args[0][5] == closures.DoorLock.UserType.Unrestricted + ) + assert cluster.request.call_args[0][6] == "13246579" + + +async def async_clear_user_code( + zha_gateway: Gateway, + cluster: closures.DoorLock, + entity: PlatformEntity, +) -> None: + """Test clear lock code functionality from client.""" + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): + await entity.async_clear_lock_user_code(3) + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == CLEAR_PIN_CODE + assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2 + + +async def async_enable_user_code( + zha_gateway: Gateway, + cluster: closures.DoorLock, + entity: PlatformEntity, +) -> None: + """Test enable lock code functionality from client.""" + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): + await entity.async_enable_lock_user_code(3) + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == SET_USER_STATUS + assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2 + assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Enabled + + +async def async_disable_user_code( + zha_gateway: Gateway, + cluster: closures.DoorLock, + entity: PlatformEntity, +) -> None: + """Test disable lock code functionality from client.""" + with patch("zigpy.zcl.Cluster.request", return_value=[zcl_f.Status.SUCCESS]): + await entity.async_disable_lock_user_code(3) + await zha_gateway.async_block_till_done() + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == SET_USER_STATUS + assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2 + assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Disabled diff --git a/tests/test_number.py b/tests/test_number.py new file mode 100644 index 00000000..e7500b98 --- /dev/null +++ b/tests/test_number.py @@ -0,0 +1,387 @@ +"""Test zha analog output.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import call + +import pytest +from slugify import slugify +from zigpy.device import Device as ZigpyDevice +from zigpy.exceptions import ZigbeeException +from zigpy.profiles import zha +import zigpy.types +from zigpy.zcl.clusters import general, lighting + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.exceptions import ZHAException +from zha.zigbee.device import Device + +from .common import ( + find_entity, + find_entity_id, + send_attributes_report, + update_attribute_cache, +) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + + +@pytest.fixture +def zigpy_analog_output_device( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Zigpy analog_output device.""" + + endpoints = { + 1: { + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.LEVEL_CONTROL_SWITCH, + SIG_EP_INPUT: [general.AnalogOutput.cluster_id, general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + return zigpy_device_mock(endpoints) + + +@pytest.fixture +async def light(zigpy_device_mock: Callable[..., ZigpyDevice]) -> ZigpyDevice: + """Siren fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.OnOff.cluster_id, + general.LevelControl.cluster_id, + lighting.Color.cluster_id, + ], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + } + }, + node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + ) + + return zigpy_device + + +async def test_number( + zigpy_analog_output_device: ZigpyDevice, # pylint: disable=redefined-outer-name + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, +) -> None: + """Test zha number platform.""" + cluster: general.AnalogOutput = zigpy_analog_output_device.endpoints.get( + 1 + ).analog_output + cluster.PLUGGED_ATTR_READS = { + "max_present_value": 100.0, + "min_present_value": 1.0, + "relinquish_default": 50.0, + "resolution": 1.1, + "description": "PWM1", + "engineering_units": 98, + "application_type": 4 * 0x10000, + } + update_attribute_cache(cluster) + cluster.PLUGGED_ATTR_READS["present_value"] = 15.0 + + zha_device = await device_joined(zigpy_analog_output_device) + # one for present_value and one for the rest configuration attributes + assert cluster.read_attributes.call_count == 3 + attr_reads = set() + for call_args in cluster.read_attributes.call_args_list: + attr_reads |= set(call_args[0][0]) + assert "max_present_value" in attr_reads + assert "min_present_value" in attr_reads + assert "relinquish_default" in attr_reads + assert "resolution" in attr_reads + assert "description" in attr_reads + assert "engineering_units" in attr_reads + assert "application_type" in attr_reads + + entity: PlatformEntity = find_entity(zha_device, Platform.NUMBER) # type: ignore + assert entity is not None + assert isinstance(entity, PlatformEntity) + + assert cluster.read_attributes.call_count == 3 + + # test that the state is 15.0 + assert entity.get_state()["state"] == 15.0 + + # test attributes + assert entity.to_json()["min_value"] == 1.0 + assert entity.to_json()["max_value"] == 100.0 + assert entity.to_json()["step"] == 1.1 + + # change value from device + assert cluster.read_attributes.call_count == 3 + await send_attributes_report(zha_gateway, cluster, {0x0055: 15}) + await zha_gateway.async_block_till_done() + assert entity.get_state()["state"] == 15.0 + + # update value from device + await send_attributes_report(zha_gateway, cluster, {0x0055: 20}) + await zha_gateway.async_block_till_done() + assert entity.get_state()["state"] == 20.0 + + # change value from client + await entity.async_set_value(30.0) + await zha_gateway.async_block_till_done() + + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call( + {"present_value": 30.0}, manufacturer=None + ) + assert entity.get_state()["state"] == 30.0 + + # test updating entity state from client + cluster.read_attributes.reset_mock() + assert entity.get_state()["state"] == 30.0 + cluster.PLUGGED_ATTR_READS = {"present_value": 20} + await entity.async_update() + await zha_gateway.async_block_till_done() + assert cluster.read_attributes.await_count == 1 + assert cluster.read_attributes.await_args == call( + ["present_value"], allow_cache=False, only_cache=False, manufacturer=None + ) + assert entity.get_state()["state"] == 20.0 + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +@pytest.mark.parametrize( + ("attr", "initial_value", "new_value"), + ( + ("on_off_transition_time", 20, 5), + ("on_level", 255, 50), + ("on_transition_time", 5, 1), + ("off_transition_time", 5, 1), + ("default_move_rate", 1, 5), + ("start_up_current_level", 254, 125), + ), +) +async def test_level_control_number( + zha_gateway: Gateway, # pylint: disable=unused-argument + light: Device, # pylint: disable=redefined-outer-name + device_joined, + attr: str, + initial_value: int, + new_value: int, +) -> None: + """Test ZHA level control number entities - new join.""" + + level_control_cluster = light.endpoints[1].level + level_control_cluster.PLUGGED_ATTR_READS = { + attr: initial_value, + } + zha_device = await device_joined(light) + + entity_id = find_entity_id( + Platform.NUMBER, + zha_device, + qualifier=attr, + ) + assert entity_id is not None + + assert level_control_cluster.read_attributes.mock_calls == [ + call( + [ + "on_off_transition_time", + "on_level", + "on_transition_time", + "off_transition_time", + "default_move_rate", + ], + allow_cache=True, + only_cache=False, + manufacturer=None, + ), + call( + ["start_up_current_level"], + allow_cache=True, + only_cache=False, + manufacturer=None, + ), + call( + [ + "current_level", + ], + allow_cache=False, + only_cache=False, + manufacturer=None, + ), + ] + + entity = get_entity(zha_device, entity_id) + assert entity + assert entity.get_state()["state"] == initial_value + assert entity._attr_entity_category == EntityCategory.CONFIG + + await entity.async_set_native_value(new_value) + assert level_control_cluster.write_attributes.mock_calls == [ + call({attr: new_value}, manufacturer=None) + ] + + assert entity.get_state()["state"] == new_value + + level_control_cluster.read_attributes.reset_mock() + await entity.async_update() + # the mocking doesn't update the attr cache so this flips back to initial value + assert entity.get_state()["state"] == initial_value + assert level_control_cluster.read_attributes.mock_calls == [ + call( + [attr], + allow_cache=False, + only_cache=False, + manufacturer=None, + ) + ] + + level_control_cluster.write_attributes.reset_mock() + level_control_cluster.write_attributes.side_effect = ZigbeeException + + with pytest.raises(ZHAException): + await entity.async_set_native_value(new_value) + + assert level_control_cluster.write_attributes.mock_calls == [ + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + ] + assert entity.get_state()["state"] == initial_value + + # test updating entity state from client + level_control_cluster.read_attributes.reset_mock() + assert entity.get_state()["state"] == initial_value + level_control_cluster.PLUGGED_ATTR_READS = {attr: new_value} + await entity.async_update() + await zha_gateway.async_block_till_done() + assert level_control_cluster.read_attributes.await_count == 1 + assert level_control_cluster.read_attributes.mock_calls == [ + call( + [ + attr, + ], + allow_cache=False, + only_cache=False, + manufacturer=None, + ), + ] + assert entity.get_state()["state"] == new_value + + +@pytest.mark.parametrize( + ("attr", "initial_value", "new_value"), + (("start_up_color_temperature", 500, 350),), +) +async def test_color_number( + zha_gateway: Gateway, # pylint: disable=unused-argument + light: Device, # pylint: disable=redefined-outer-name + device_joined, + attr: str, + initial_value: int, + new_value: int, +) -> None: + """Test ZHA color number entities - new join.""" + + color_cluster = light.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + attr: initial_value, + } + zha_device = await device_joined(light) + + entity_id = find_entity_id( + Platform.NUMBER, + zha_device, + qualifier=attr, + ) + assert entity_id is not None + + assert color_cluster.read_attributes.call_count == 3 + assert ( + call( + [ + "color_temp_physical_min", + "color_temp_physical_max", + "color_capabilities", + "start_up_color_temperature", + "options", + ], + allow_cache=True, + only_cache=False, + manufacturer=None, + ) + in color_cluster.read_attributes.call_args_list + ) + + entity = get_entity(zha_device, entity_id) + assert entity + + assert entity.get_state()["state"] == initial_value + assert entity._attr_entity_category == EntityCategory.CONFIG + + await entity.async_set_native_value(new_value) + assert color_cluster.write_attributes.call_count == 1 + assert color_cluster.write_attributes.call_args[0][0] == { + attr: new_value, + } + + assert entity.get_state()["state"] == new_value + + color_cluster.read_attributes.reset_mock() + await entity.async_update() + # the mocking doesn't update the attr cache so this flips back to initial value + assert entity.get_state()["state"] == initial_value + assert color_cluster.read_attributes.call_count == 1 + assert ( + call( + [attr], + allow_cache=False, + only_cache=False, + manufacturer=None, + ) + in color_cluster.read_attributes.call_args_list + ) + + color_cluster.write_attributes.reset_mock() + color_cluster.write_attributes.side_effect = ZigbeeException + + with pytest.raises(ZHAException): + await entity.async_set_native_value(new_value) + + assert color_cluster.write_attributes.mock_calls == [ + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + call({attr: new_value}, manufacturer=None), + ] + assert entity.get_state()["state"] == initial_value + + # test updating entity state from client + color_cluster.read_attributes.reset_mock() + assert entity.get_state()["state"] == initial_value + color_cluster.PLUGGED_ATTR_READS = {attr: new_value} + await entity.async_update() + await zha_gateway.async_block_till_done() + assert color_cluster.read_attributes.await_count == 1 + assert color_cluster.read_attributes.mock_calls == [ + call( + [ + attr, + ], + allow_cache=False, + only_cache=False, + manufacturer=None, + ), + ] + assert entity.get_state()["state"] == new_value diff --git a/tests/test_registries.py b/tests/test_registries.py new file mode 100644 index 00000000..5fcdad5a --- /dev/null +++ b/tests/test_registries.py @@ -0,0 +1,587 @@ +"""Test ZHA registries.""" + +# pylint: disable=redefined-outer-name + +from __future__ import annotations + +from collections.abc import Iterable +from unittest import mock + +import pytest +import zigpy.quirks as zigpy_quirks + +from zha.application.const import ATTR_QUIRK_ID +from zha.application.platforms import PlatformEntity +from zha.application.platforms.binary_sensor import IASZone +from zha.application.registries import ( + PLATFORM_ENTITIES, + MatchRule, + PlatformEntityRegistry, +) + +MANUFACTURER = "mock manufacturer" +MODEL = "mock model" +QUIRK_CLASS = "mock.test.quirk.class" +QUIRK_ID = "quirk_id" + + +@pytest.fixture +def zha_device(): + """Return a mock of ZHA device.""" + dev = mock.MagicMock() + dev.manufacturer = MANUFACTURER + dev.model = MODEL + dev.quirk_class = QUIRK_CLASS + dev.quirk_id = QUIRK_ID + return dev + + +@pytest.fixture +def cluster_handlers(cluster_handler): + """Return a mock of cluster_handlers.""" + + return [cluster_handler("level", 8), cluster_handler("on_off", 6)] + + +@pytest.mark.parametrize( + ("rule", "matched"), + [ + (MatchRule(), False), + (MatchRule(cluster_handler_names={"level"}), True), + (MatchRule(cluster_handler_names={"level", "no match"}), False), + (MatchRule(cluster_handler_names={"on_off"}), True), + (MatchRule(cluster_handler_names={"on_off", "no match"}), False), + (MatchRule(cluster_handler_names={"on_off", "level"}), True), + ( + MatchRule(cluster_handler_names={"on_off", "level", "no match"}), + False, + ), + # test generic_id matching + (MatchRule(generic_ids={"cluster_handler_0x0006"}), True), + (MatchRule(generic_ids={"cluster_handler_0x0008"}), True), + ( + MatchRule(generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}), + True, + ), + ( + MatchRule( + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + } + ), + False, + ), + ( + MatchRule( + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, + ), + True, + ), + # manufacturer matching + (MatchRule(manufacturers="no match"), False), + (MatchRule(manufacturers=MANUFACTURER), True), + ( + MatchRule( + manufacturers="no match", aux_cluster_handlers="aux_cluster_handler" + ), + False, + ), + ( + MatchRule( + manufacturers=MANUFACTURER, aux_cluster_handlers="aux_cluster_handler" + ), + True, + ), + (MatchRule(models=MODEL), True), + (MatchRule(models="no match"), False), + ( + MatchRule(models=MODEL, aux_cluster_handlers="aux_cluster_handler"), + True, + ), + ( + MatchRule(models="no match", aux_cluster_handlers="aux_cluster_handler"), + False, + ), + (MatchRule(quirk_ids=QUIRK_ID), True), + (MatchRule(quirk_ids="no match"), False), + ( + MatchRule(quirk_ids=QUIRK_ID, aux_cluster_handlers="aux_cluster_handler"), + True, + ), + ( + MatchRule(quirk_ids="no match", aux_cluster_handlers="aux_cluster_handler"), + False, + ), + # match everything + ( + MatchRule( + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, + manufacturers=MANUFACTURER, + models=MODEL, + quirk_ids=QUIRK_ID, + ), + True, + ), + ( + MatchRule( + cluster_handler_names="on_off", + manufacturers={"random manuf", MANUFACTURER}, + ), + True, + ), + ( + MatchRule( + cluster_handler_names="on_off", + manufacturers={"random manuf", "Another manuf"}, + ), + False, + ), + ( + MatchRule( + cluster_handler_names="on_off", + manufacturers=lambda x: x == MANUFACTURER, + ), + True, + ), + ( + MatchRule( + cluster_handler_names="on_off", + manufacturers=lambda x: x != MANUFACTURER, + ), + False, + ), + ( + MatchRule(cluster_handler_names="on_off", models={"random model", MODEL}), + True, + ), + ( + MatchRule( + cluster_handler_names="on_off", models={"random model", "Another model"} + ), + False, + ), + ( + MatchRule(cluster_handler_names="on_off", models=lambda x: x == MODEL), + True, + ), + ( + MatchRule(cluster_handler_names="on_off", models=lambda x: x != MODEL), + False, + ), + ( + MatchRule( + cluster_handler_names="on_off", + quirk_ids={"random quirk", QUIRK_ID}, + ), + True, + ), + ( + MatchRule( + cluster_handler_names="on_off", + quirk_ids={"random quirk", "another quirk"}, + ), + False, + ), + ( + MatchRule( + cluster_handler_names="on_off", quirk_ids=lambda x: x == QUIRK_ID + ), + True, + ), + ( + MatchRule( + cluster_handler_names="on_off", quirk_ids=lambda x: x != QUIRK_ID + ), + False, + ), + ( + MatchRule(cluster_handler_names="on_off", quirk_ids=QUIRK_ID), + True, + ), + ], +) +def test_registry_matching(rule, matched, cluster_handlers) -> None: + """Test strict rule matching.""" + assert ( + rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched + ) + + +@pytest.mark.parametrize( + ("rule", "matched"), + [ + (MatchRule(), False), + (MatchRule(cluster_handler_names={"level"}), True), + (MatchRule(cluster_handler_names={"level", "no match"}), False), + (MatchRule(cluster_handler_names={"on_off"}), True), + (MatchRule(cluster_handler_names={"on_off", "no match"}), False), + (MatchRule(cluster_handler_names={"on_off", "level"}), True), + ( + MatchRule(cluster_handler_names={"on_off", "level", "no match"}), + False, + ), + ( + MatchRule(cluster_handler_names={"on_off", "level"}, models="no match"), + True, + ), + ( + MatchRule( + cluster_handler_names={"on_off", "level"}, + models="no match", + manufacturers="no match", + ), + True, + ), + ( + MatchRule( + cluster_handler_names={"on_off", "level"}, + models="no match", + manufacturers=MANUFACTURER, + ), + True, + ), + # test generic_id matching + (MatchRule(generic_ids={"cluster_handler_0x0006"}), True), + (MatchRule(generic_ids={"cluster_handler_0x0008"}), True), + ( + MatchRule(generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}), + True, + ), + ( + MatchRule( + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + } + ), + False, + ), + ( + MatchRule( + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + }, + models="mo match", + ), + False, + ), + ( + MatchRule( + generic_ids={ + "cluster_handler_0x0006", + "cluster_handler_0x0008", + "cluster_handler_0x0009", + }, + models=MODEL, + ), + True, + ), + ( + MatchRule( + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, + ), + True, + ), + # manufacturer matching + (MatchRule(manufacturers="no match"), False), + (MatchRule(manufacturers=MANUFACTURER), True), + (MatchRule(models=MODEL), True), + (MatchRule(models="no match"), False), + (MatchRule(quirk_ids=QUIRK_ID), True), + (MatchRule(quirk_ids="no match"), False), + # match everything + ( + MatchRule( + generic_ids={"cluster_handler_0x0006", "cluster_handler_0x0008"}, + cluster_handler_names={"on_off", "level"}, + manufacturers=MANUFACTURER, + models=MODEL, + quirk_ids=QUIRK_ID, + ), + True, + ), + ], +) +def test_registry_loose_matching(rule, matched, cluster_handlers) -> None: + """Test loose rule matching.""" + assert ( + rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched + ) + + +def test_match_rule_claim_cluster_handlers_color(cluster_handler) -> None: + """Test cluster handler claiming.""" + ch_color = cluster_handler("color", 0x300) + ch_level = cluster_handler("level", 8) + ch_onoff = cluster_handler("on_off", 6) + + rule = MatchRule( + cluster_handler_names="on_off", aux_cluster_handlers={"color", "level"} + ) + claimed = rule.claim_cluster_handlers([ch_color, ch_level, ch_onoff]) + assert {"color", "level", "on_off"} == {ch.name for ch in claimed} + + +@pytest.mark.parametrize( + ("rule", "match"), + [ + (MatchRule(cluster_handler_names={"level"}), {"level"}), + (MatchRule(cluster_handler_names={"level", "no match"}), {"level"}), + (MatchRule(cluster_handler_names={"on_off"}), {"on_off"}), + (MatchRule(generic_ids="cluster_handler_0x0000"), {"basic"}), + ( + MatchRule( + cluster_handler_names="level", generic_ids="cluster_handler_0x0000" + ), + {"basic", "level"}, + ), + ( + MatchRule(cluster_handler_names={"level", "power"}), + {"level", "power"}, + ), + ( + MatchRule( + cluster_handler_names={"level", "on_off"}, + aux_cluster_handlers={"basic", "power"}, + ), + {"basic", "level", "on_off", "power"}, + ), + (MatchRule(cluster_handler_names={"color"}), set()), + ], +) +def test_match_rule_claim_cluster_handlers( + rule, match, cluster_handler, cluster_handlers +) -> None: + """Test cluster handler claiming.""" + ch_basic = cluster_handler("basic", 0) + cluster_handlers.append(ch_basic) + ch_power = cluster_handler("power", 1) + cluster_handlers.append(ch_power) + + claimed = rule.claim_cluster_handlers(cluster_handlers) + assert match == {ch.name for ch in claimed} + + +@pytest.fixture +def entity_registry(): + """Registry fixture.""" + return PlatformEntityRegistry() + + +@pytest.mark.parametrize( + ("manufacturer", "model", "quirk_id", "match_name"), + [ + ("random manufacturer", "random model", "random.class", "OnOff"), + ("random manufacturer", MODEL, "random.class", "OnOffModel"), + (MANUFACTURER, "random model", "random.class", "OnOffManufacturer"), + ("random manufacturer", "random model", QUIRK_ID, "OnOffQuirk"), + (MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"), + (MANUFACTURER, "some model", "random.class", "OnOffMultimodel"), + ], +) +def test_weighted_match( + cluster_handler, + entity_registry: PlatformEntityRegistry, + manufacturer, + model, + quirk_id, + match_name, +) -> None: + """Test weightedd match.""" + + s = mock.sentinel + + @entity_registry.strict_match( + s.component, + cluster_handler_names="on_off", + models={MODEL, "another model", "some model"}, + ) + class OnOffMultimodel: # pylint: disable=unused-variable + """OnOff multimodel cluster handler.""" + + @entity_registry.strict_match(s.component, cluster_handler_names="on_off") + class OnOff: # pylint: disable=unused-variable + """OnOff cluster handler.""" + + @entity_registry.strict_match( + s.component, cluster_handler_names="on_off", manufacturers=MANUFACTURER + ) + class OnOffManufacturer: # pylint: disable=unused-variable + """OnOff manufacturer cluster handler.""" + + @entity_registry.strict_match( + s.component, cluster_handler_names="on_off", models=MODEL + ) + class OnOffModel: # pylint: disable=unused-variable + """OnOff model cluster handler.""" + + @entity_registry.strict_match( + s.component, + cluster_handler_names="on_off", + models=MODEL, + manufacturers=MANUFACTURER, + ) + class OnOffModelManufacturer: # pylint: disable=unused-variable + """OnOff model and manufacturer cluster handler.""" + + @entity_registry.strict_match( + s.component, cluster_handler_names="on_off", quirk_ids=QUIRK_ID + ) + class OnOffQuirk: # pylint: disable=unused-variable + """OnOff quirk cluster handler.""" + + ch_on_off = cluster_handler("on_off", 6) + ch_level = cluster_handler("level", 8) + + match, claimed = entity_registry.get_entity( + s.component, manufacturer, model, [ch_on_off, ch_level], quirk_id + ) + + assert match.__name__ == match_name + assert claimed == [ch_on_off] + + +def test_multi_sensor_match( + cluster_handler, entity_registry: PlatformEntityRegistry +) -> None: + """Test multi-entity match.""" + + s = mock.sentinel + + @entity_registry.multipass_match( + s.binary_sensor, + cluster_handler_names="smartenergy_metering", + ) + class SmartEnergySensor2: + """SmartEnergySensor2.""" + + ch_se = cluster_handler("smartenergy_metering", 0x0702) + ch_illuminati = cluster_handler("illuminance", 0x0401) + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + cluster_handlers=[ch_se, ch_illuminati], + quirk_id="quirk_id", + ) + + assert s.binary_sensor in match + assert s.component not in match + assert set(claimed) == {ch_se} + assert {cls.entity_class.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__ + } + + @entity_registry.multipass_match( + s.component, + cluster_handler_names="smartenergy_metering", + aux_cluster_handlers="illuminance", + ) + class SmartEnergySensor1: + """SmartEnergySensor1.""" + + @entity_registry.multipass_match( + s.binary_sensor, + cluster_handler_names="smartenergy_metering", + aux_cluster_handlers="illuminance", + ) + class SmartEnergySensor3: + """SmartEnergySensor3.""" + + match, claimed = entity_registry.get_multi_entity( + "manufacturer", + "model", + cluster_handlers={ch_se, ch_illuminati}, + quirk_id="quirk_id", + ) + + assert s.binary_sensor in match + assert s.component in match + assert set(claimed) == {ch_se, ch_illuminati} + assert {cls.entity_class.__name__ for cls in match[s.binary_sensor]} == { + SmartEnergySensor2.__name__, + SmartEnergySensor3.__name__, + } + assert {cls.entity_class.__name__ for cls in match[s.component]} == { + SmartEnergySensor1.__name__ + } + + +def iter_all_rules() -> Iterable[tuple[MatchRule, list[type[PlatformEntity]]]]: + """Iterate over all match rules and their corresponding entities.""" + + for rules in PLATFORM_ENTITIES._strict_registry.values(): + for rule, entity in rules.items(): + yield rule, [entity] + + for rules in PLATFORM_ENTITIES._multi_entity_registry.values(): + for multi in rules.values(): + for rule, entities in multi.items(): + yield rule, entities + + for rules in PLATFORM_ENTITIES._config_diagnostic_entity_registry.values(): + for multi in rules.values(): + for rule, entities in multi.items(): + yield rule, entities + + +def test_quirk_classes() -> None: + """Make sure that all quirk IDs in components matches exist.""" + + def quirk_class_validator(value): + """Validate quirk IDs during self test.""" + if callable(value): + # Callables cannot be tested + return + + if isinstance(value, (frozenset, set, list)): + for v in value: + # Unpack the value if needed + quirk_class_validator(v) + return + + if value not in all_quirk_ids: + raise ValueError(f"Quirk ID '{value}' does not exist.") + + # get all quirk ID from zigpy quirks registry + all_quirk_ids = [] + for manufacturer in zigpy_quirks._DEVICE_REGISTRY._registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, ATTR_QUIRK_ID, None) + if quirk_id is not None and quirk_id not in all_quirk_ids: + all_quirk_ids.append(quirk_id) + # TODO make sure this is needed + del quirk, model_quirk_list, manufacturer # pylint: disable=undefined-loop-variable + + # validate all quirk IDs used in component match rules + for rule, _ in iter_all_rules(): + quirk_class_validator(rule.quirk_ids) + + +def test_entity_names() -> None: + """Make sure that all handlers expose entities with valid names.""" + + for _, entity_classes in iter_all_rules(): + for entity_class in entity_classes: + if hasattr(entity_class, "_attr_name"): + # The entity has a name + assert (name := entity_class._attr_name) and isinstance(name, str) + elif hasattr(entity_class, "_attr_translation_key"): + assert ( + isinstance(entity_class._attr_translation_key, str) + and entity_class._attr_translation_key + ) + elif hasattr(entity_class, "_attr_device_class"): + assert entity_class._attr_device_class + else: + # The only exception (for now) is IASZone + assert entity_class is IASZone diff --git a/tests/test_select.py b/tests/test_select.py new file mode 100644 index 00000000..30a93748 --- /dev/null +++ b/tests/test_select.py @@ -0,0 +1,269 @@ +"""Test ZHA select entities.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import call + +import pytest +from slugify import slugify +from zhaquirks import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + OUTPUT_CLUSTERS, + PROFILE_ID, +) +from zigpy.const import SIG_EP_PROFILE +from zigpy.device import Device as ZigpyDevice +from zigpy.profiles import zha +from zigpy.quirks import CustomCluster, CustomDevice, get_device +from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 +import zigpy.types as t +from zigpy.zcl.clusters import general, security +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.platforms.select import AqaraMotionSensitivities +from zha.zigbee.device import Device + +from .common import find_entity_id, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + + +@pytest.fixture +async def siren( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> tuple[Device, security.IasWd]: + """Siren fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].ias_wd + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +async def test_select( + siren: tuple[Device, security.IasWd], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zha select platform.""" + zha_device, cluster = siren + assert cluster is not None + select_name = security.IasWd.Warning.WarningMode.__name__ + entity_id = find_entity_id( + Platform.SELECT, + zha_device, + qualifier=select_name.lower(), + ) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.get_state()["state"] is None # unknown in HA + assert entity.to_json()["options"] == [ + "Stop", + "Burglar", + "Fire", + "Emergency", + "Police Panic", + "Fire Panic", + "Emergency Panic", + ] + assert entity._enum == security.IasWd.Warning.WarningMode + + # change value from client + await entity.async_select_option(security.IasWd.Warning.WarningMode.Burglar.name) + await zha_gateway.async_block_till_done() + assert ( + entity.get_state()["state"] == security.IasWd.Warning.WarningMode.Burglar.name + ) + + +class MotionSensitivityQuirk(CustomDevice): + """Quirk with motion sensitivity attribute.""" + + class OppleCluster(CustomCluster, ManufacturerSpecificCluster): + """Aqara manufacturer specific cluster.""" + + cluster_id = 0xFCC0 + ep_attribute = "opple_cluster" + attributes = { + 0x010C: ("motion_sensitivity", t.uint8_t, True), + 0x020C: ("motion_sensitivity_disabled", t.uint8_t, True), + } + + def __init__(self, *args, **kwargs): + """Initialize.""" + super().__init__(*args, **kwargs) + # populate cache to create config entity + self._attr_cache.update( + { + 0x010C: AqaraMotionSensitivities.Medium, + 0x020C: AqaraMotionSensitivities.Medium, + } + ) + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [general.Basic.cluster_id, OppleCluster], + OUTPUT_CLUSTERS: [], + }, + } + } + + +@pytest.fixture +async def zigpy_device_aqara_sensor( + zha_gateway: Gateway, zigpy_device_mock, device_joined +): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="LUMI", + model="lumi.motion.ac02", + quirk=MotionSensitivityQuirk, + ) + + zigpy_device = get_device(zigpy_device) + zha_device = await device_joined(zigpy_device) + zha_device.available = True + await zha_gateway.async_block_till_done() + return zigpy_device + + +async def test_on_off_select_attribute_report( + zha_gateway: Gateway, + device_joined, + zigpy_device_aqara_sensor, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA attribute report parsing for select platform.""" + + zha_device = await device_joined(zigpy_device_aqara_sensor) + cluster = zigpy_device_aqara_sensor.endpoints.get(1).opple_cluster + entity_id = find_entity_id(Platform.SELECT, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["state"] == AqaraMotionSensitivities.Medium.name + + # send attribute report from device + await send_attributes_report( + zha_gateway, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low} + ) + assert entity.get_state()["state"] == AqaraMotionSensitivities.Low.name + + +( + add_to_registry_v2("Fake_Manufacturer", "Fake_Model") + .replaces(MotionSensitivityQuirk.OppleCluster) + .enum( + "motion_sensitivity", + AqaraMotionSensitivities, + MotionSensitivityQuirk.OppleCluster.cluster_id, + ) + .enum( + "motion_sensitivity_disabled", + AqaraMotionSensitivities, + MotionSensitivityQuirk.OppleCluster.cluster_id, + translation_key="motion_sensitivity", + initially_disabled=True, + ) +) + + +@pytest.fixture +async def zigpy_device_aqara_sensor_v2( + zha_gateway: Gateway, # pylint: disable=unused-argument + zigpy_device_mock, + device_joined, +): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + MotionSensitivityQuirk.OppleCluster.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="Fake_Manufacturer", + model="Fake_Model", + ) + zigpy_device = get_device(zigpy_device) + + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].opple_cluster + + +async def test_on_off_select_attribute_report_v2( + zha_gateway: Gateway, + zigpy_device_aqara_sensor_v2, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA attribute report parsing for select platform.""" + + zha_device, cluster = zigpy_device_aqara_sensor_v2 + assert isinstance(zha_device.device, CustomDeviceV2) + entity_id = find_entity_id( + Platform.SELECT, zha_device, qualifier="motion_sensitivity" + ) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test that the state is in default medium state + assert entity.get_state()["state"] == AqaraMotionSensitivities.Medium.name + + # send attribute report from device + await send_attributes_report( + zha_gateway, cluster, {"motion_sensitivity": AqaraMotionSensitivities.Low} + ) + assert entity.get_state()["state"] == AqaraMotionSensitivities.Low.name + + assert entity._attr_entity_category == EntityCategory.CONFIG + # TODO assert entity._attr_entity_registry_enabled_default is True + assert entity._attr_translation_key == "motion_sensitivity" + + await entity.async_select_option(AqaraMotionSensitivities.Medium.name) + await zha_gateway.async_block_till_done() + assert entity.get_state()["state"] == AqaraMotionSensitivities.Medium.name + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args == call( + {"motion_sensitivity": AqaraMotionSensitivities.Medium}, manufacturer=None + ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 00000000..94f5e6a5 --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,1187 @@ +"""Test zha sensor.""" + +import asyncio +from collections.abc import Awaitable, Callable +import math +from typing import Any, Optional + +import pytest +from slugify import slugify +from zigpy.device import Device as ZigpyDevice +import zigpy.profiles.zha +from zigpy.quirks import CustomCluster, get_device +from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 +import zigpy.types as t +from zigpy.zcl import Cluster +from zigpy.zcl.clusters import general, homeautomation, hvac, measurement, smartenergy +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster + +from zha.application import Platform +from zha.application.const import ATTR_DEVICE_CLASS, ZHA_CLUSTER_HANDLER_READS_PER_REQ +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.platforms.sensor import UnitOfMass +from zha.application.platforms.sensor.const import SensorDeviceClass +from zha.units import PERCENTAGE, UnitOfEnergy, UnitOfPressure, UnitOfVolume +from zha.zigbee.device import Device + +from .common import find_entity_id, find_entity_ids, send_attributes_report +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_e769900a_{}" + +EMAttrs = homeautomation.ElectricalMeasurement.AttributeDefs + + +@pytest.fixture +async def elec_measurement_zigpy_dev( + zigpy_device_mock: Callable[..., ZigpyDevice], +) -> ZigpyDevice: + """Electric Measurement zigpy device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + homeautomation.ElectricalMeasurement.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.SIMPLE_SENSOR, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + }, + ) + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + zigpy_device.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS = { + "ac_current_divisor": 10, + "ac_current_multiplier": 1, + "ac_power_divisor": 10, + "ac_power_multiplier": 1, + "ac_voltage_divisor": 10, + "ac_voltage_multiplier": 1, + "measurement_type": 8, + "power_divisor": 10, + "power_multiplier": 1, + } + return zigpy_device + + +@pytest.fixture +async def elec_measurement_zha_dev( + elec_measurement_zigpy_dev: ZigpyDevice, # pylint: disable=redefined-outer-name + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Electric Measurement ZHA device.""" + + zha_dev = await device_joined(elec_measurement_zigpy_dev) + zha_dev.available = True + return zha_dev + + +async def async_test_humidity( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test humidity sensor.""" + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 1000, 2: 100}) + assert_state(entity, 10.0, "%") + + +async def async_test_temperature( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test temperature sensor.""" + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 2900, 2: 100}) + assert_state(entity, 29.0, "°C") + + +async def async_test_pressure( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test pressure sensor.""" + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 1000, 2: 10000}) + assert_state(entity, 1000, "hPa") + + await send_attributes_report(zha_gateway, cluster, {0: 1000, 20: -1, 16: 10000}) + assert_state(entity, 1000, "hPa") + + +async def async_test_illuminance( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test illuminance sensor.""" + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 10, 2: 20}) + assert_state(entity, 1.0, "lx") + + await send_attributes_report(zha_gateway, cluster, {0: 0xFFFF}) + assert_state(entity, None, "lx") + + +async def async_test_metering( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test Smart Energy metering sensor.""" + await send_attributes_report( + zha_gateway, cluster, {1025: 1, 1024: 12345, 1026: 100} + ) + assert_state(entity, 12345.0, None) + assert entity.get_state()["status"] == "NO_ALARMS" + assert entity.get_state()["device_type"] == "Electric Metering" + + await send_attributes_report(zha_gateway, cluster, {1024: 12346, "status": 64 + 8}) + assert_state(entity, 12346.0, None) + assert entity.get_state()["status"] in ( + "SERVICE_DISCONNECT|POWER_FAILURE", + "POWER_FAILURE|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + zha_gateway, cluster, {"status": 64 + 8, "metering_device_type": 1} + ) + assert entity.get_state()["status"] in ( + "SERVICE_DISCONNECT|NOT_DEFINED", + "NOT_DEFINED|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + zha_gateway, cluster, {"status": 64 + 8, "metering_device_type": 2} + ) + assert entity.get_state()["status"] in ( + "SERVICE_DISCONNECT|PIPE_EMPTY", + "PIPE_EMPTY|SERVICE_DISCONNECT", + ) + + await send_attributes_report( + zha_gateway, cluster, {"status": 64 + 8, "metering_device_type": 5} + ) + assert entity.get_state()["status"] in ( + "SERVICE_DISCONNECT|TEMPERATURE_SENSOR", + "TEMPERATURE_SENSOR|SERVICE_DISCONNECT", + ) + + # Status for other meter types + await send_attributes_report( + zha_gateway, cluster, {"status": 32, "metering_device_type": 4} + ) + assert entity.get_state()["status"] in ("", "32") + + +async def async_test_smart_energy_summation_delivered( + zha_gateway: Gateway, cluster, entity +): + """Test SmartEnergy Summation delivered sensor.""" + + await send_attributes_report( + zha_gateway, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} + ) + assert_state(entity, 12.321, UnitOfEnergy.KILO_WATT_HOUR) + assert entity.get_state()["status"] == "NO_ALARMS" + assert entity.get_state()["device_type"] == "Electric Metering" + assert entity.to_json()[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY + + +async def async_test_smart_energy_summation_received( + zha_gateway: Gateway, cluster, entity +): + """Test SmartEnergy Summation received sensor.""" + + await send_attributes_report( + zha_gateway, cluster, {1025: 1, "current_summ_received": 12321, 1026: 100} + ) + assert_state(entity, 12.321, UnitOfEnergy.KILO_WATT_HOUR) + assert entity.get_state()["status"] == "NO_ALARMS" + assert entity.get_state()["device_type"] == "Electric Metering" + assert entity.to_json()[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY + + +async def async_test_smart_energy_summation( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test SmartEnergy Summation delivered sensro.""" + + await send_attributes_report( + zha_gateway, cluster, {1025: 1, "current_summ_delivered": 12321, 1026: 100} + ) + assert_state(entity, 12.32, "m³") + assert entity.get_state()["status"] == "NO_ALARMS" + assert entity.get_state()["device_type"] == "Electric Metering" + + +async def async_test_electrical_measurement( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test electrical measurement sensor.""" + # update divisor cached value + await send_attributes_report(zha_gateway, cluster, {"ac_power_divisor": 1}) + await send_attributes_report( + zha_gateway, cluster, {0: 1, EMAttrs.active_power.id: 100} + ) + assert_state(entity, 100, "W") + + await send_attributes_report( + zha_gateway, cluster, {0: 1, EMAttrs.active_power.id: 99} + ) + assert_state(entity, 99, "W") + + await send_attributes_report(zha_gateway, cluster, {"ac_power_divisor": 10}) + await send_attributes_report( + zha_gateway, cluster, {0: 1, EMAttrs.active_power.id: 1000} + ) + assert_state(entity, 100, "W") + + await send_attributes_report( + zha_gateway, cluster, {0: 1, EMAttrs.active_power.id: 99} + ) + assert_state(entity, 9.9, "W") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x050D: 88}) + assert entity.get_state()["active_power_max"] == 8.8 + + +async def async_test_em_apparent_power( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test electrical measurement Apparent Power sensor.""" + # update divisor cached value + await send_attributes_report(zha_gateway, cluster, {"ac_power_divisor": 1}) + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x050F: 100}) + assert_state(entity, 100, "VA") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x050F: 99}) + assert_state(entity, 99, "VA") + + await send_attributes_report(zha_gateway, cluster, {"ac_power_divisor": 10}) + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x050F: 1000}) + assert_state(entity, 100, "VA") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x050F: 99}) + assert_state(entity, 9.9, "VA") + + +async def async_test_em_power_factor( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +): + """Test electrical measurement Power Factor sensor.""" + # update divisor cached value + await send_attributes_report(zha_gateway, cluster, {"ac_power_divisor": 1}) + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0510: 100, 10: 1000}) + assert_state(entity, 100, PERCENTAGE) + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0510: 99, 10: 1000}) + assert_state(entity, 99, PERCENTAGE) + + await send_attributes_report(zha_gateway, cluster, {"ac_power_divisor": 10}) + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0510: 100, 10: 5000}) + assert_state(entity, 100, PERCENTAGE) + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0510: 99, 10: 5000}) + assert_state(entity, 99, PERCENTAGE) + + +async def async_test_em_rms_current( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test electrical measurement RMS Current sensor.""" + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0508: 1234}) + assert_state(entity, 1.2, "A") + + await send_attributes_report(zha_gateway, cluster, {"ac_current_divisor": 10}) + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0508: 236}) + assert_state(entity, 23.6, "A") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0508: 1236}) + assert_state(entity, 124, "A") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x050A: 88}) + assert entity.get_state()["rms_current_max"] == 8.8 + + +async def async_test_em_rms_voltage( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test electrical measurement RMS Voltage sensor.""" + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0505: 1234}) + assert_state(entity, 123, "V") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0505: 234}) + assert_state(entity, 23.4, "V") + + await send_attributes_report(zha_gateway, cluster, {"ac_voltage_divisor": 100}) + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0505: 2236}) + assert_state(entity, 22.4, "V") + + await send_attributes_report(zha_gateway, cluster, {0: 1, 0x0507: 888}) + assert entity.get_state()["rms_voltage_max"] == 8.9 + + +async def async_test_powerconfiguration( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test powerconfiguration/battery sensor.""" + await send_attributes_report(zha_gateway, cluster, {33: 98}) + assert_state(entity, 49, "%") + assert entity.get_state()["battery_voltage"] == 2.9 + assert entity.get_state()["battery_quantity"] == 3 + assert entity.get_state()["battery_size"] == "AAA" + await send_attributes_report(zha_gateway, cluster, {32: 20}) + assert entity.get_state()["battery_voltage"] == 2.0 + + +async def async_test_powerconfiguration2( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +): + """Test powerconfiguration/battery sensor.""" + await send_attributes_report(zha_gateway, cluster, {33: -1}) + assert_state(entity, None, "%") + + await send_attributes_report(zha_gateway, cluster, {33: 255}) + assert_state(entity, None, "%") + + await send_attributes_report(zha_gateway, cluster, {33: 98}) + assert_state(entity, 49, "%") + + +async def async_test_device_temperature( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +) -> None: + """Test temperature sensor.""" + await send_attributes_report(zha_gateway, cluster, {0: 2900}) + assert_state(entity, 29.0, "°C") + + +async def async_test_setpoint_change_source( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +): + """Test the translation of numerical state into enum text.""" + await send_attributes_report( + zha_gateway, + cluster, + {hvac.Thermostat.AttributeDefs.setpoint_change_source.id: 0x01}, + ) + assert entity.get_state()["state"] == "Schedule" + + +async def async_test_pi_heating_demand( + zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity +): + """Test pi heating demand is correctly returned.""" + await send_attributes_report( + zha_gateway, cluster, {hvac.Thermostat.AttributeDefs.pi_heating_demand.id: 1} + ) + assert_state(entity, 1, "%") + + +@pytest.mark.parametrize( + "cluster_id, entity_suffix, test_func, read_plug, unsupported_attrs", + ( + ( + measurement.RelativeHumidity.cluster_id, + "humidity", + async_test_humidity, + None, + None, + ), + ( + measurement.TemperatureMeasurement.cluster_id, + "temperature", + async_test_temperature, + None, + None, + ), + ( + measurement.PressureMeasurement.cluster_id, + "pressure", + async_test_pressure, + None, + None, + ), + ( + measurement.IlluminanceMeasurement.cluster_id, + "illuminance", + async_test_illuminance, + None, + None, + ), + ( + smartenergy.Metering.cluster_id, + "smartenergy_metering", + async_test_metering, + { + "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, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summation_formatting": 0b1_0111_010, + "unit_of_measure": 0x01, + }, + {"instaneneous_demand"}, + ), + ( + smartenergy.Metering.cluster_id, + "smartenergy_metering_summation_received", + async_test_smart_energy_summation_received, + { + "demand_formatting": 0xF9, + "divisor": 1000, + "metering_device_type": 0x00, + "multiplier": 1, + "status": 0x00, + "summation_formatting": 0b1_0111_010, + "unit_of_measure": 0x00, + "current_summ_received": 0, + }, + {"instaneneous_demand", "current_summ_delivered"}, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement", + async_test_electrical_measurement, + {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, + {"apparent_power", "rms_current", "rms_voltage"}, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement_apparent_power", + async_test_em_apparent_power, + {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, + {"active_power", "rms_current", "rms_voltage"}, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement_power_factor", + async_test_em_power_factor, + {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, + {"active_power", "apparent_power", "rms_current", "rms_voltage"}, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement_rms_current", + async_test_em_rms_current, + {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, + {"active_power", "apparent_power", "rms_voltage"}, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + "electrical_measurement_rms_voltage", + async_test_em_rms_voltage, + {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, + {"active_power", "apparent_power", "rms_current"}, + ), + ( + general.PowerConfiguration.cluster_id, + "power", + async_test_powerconfiguration, + { + "battery_size": 4, # AAA + "battery_voltage": 29, + "battery_quantity": 3, + }, + None, + ), + ( + general.PowerConfiguration.cluster_id, + "power", + async_test_powerconfiguration2, + { + "battery_size": 4, # AAA + "battery_voltage": 29, + "battery_quantity": 3, + }, + None, + ), + ( + general.DeviceTemperature.cluster_id, + "device_temperature", + async_test_device_temperature, + None, + None, + ), + ( + hvac.Thermostat.cluster_id, + "thermostat_setpoint_change_source", + async_test_setpoint_change_source, + None, + None, + ), + ( + hvac.Thermostat.cluster_id, + "thermostat_pi_heating_demand", + async_test_pi_heating_demand, + None, + None, + ), + ), +) +async def test_sensor( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, + cluster_id: int, + entity_suffix: str, + test_func: Callable[[Cluster, PlatformEntity], Awaitable[None]], + read_plug: Optional[dict], + unsupported_attrs: Optional[set], +) -> None: + """Test zha sensor platform.""" + + 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, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + 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 in ( + smartenergy.Metering.cluster_id, + homeautomation.ElectricalMeasurement.cluster_id, + ): + # this one is mains powered + zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 + cluster.PLUGGED_ATTR_READS = read_plug or {} + + zha_device = await device_joined(zigpy_device) + + entity_id = ENTITY_ID_PREFIX.format(entity_suffix) + entity = get_entity(zha_device, entity_id) + + await zha_gateway.async_block_till_done() + # test sensor associated logic + await test_func(zha_gateway, cluster, entity) + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def assert_state(entity: PlatformEntity, state: Any, unit_of_measurement: str) -> None: + """Check that the state is what is expected. + + This is used to ensure that the logic in each sensor class handled the + attribute report it received correctly. + """ + assert entity.get_state()["state"] == state + # TODO assert entity._attr_native_unit_of_measurement == unit_of_measurement + + +@pytest.mark.looptime +async def test_electrical_measurement_init( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test proper initialization of the electrical measurement cluster.""" + + cluster_id = homeautomation.ElectricalMeasurement.cluster_id + 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, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + + cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] + zha_device = await device_joined(zigpy_device) + + entity_id = "sensor.fakemanufacturer_fakemodel_e769900a_electrical_measurement" + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + await send_attributes_report( + zha_gateway, + cluster, + {EMAttrs.active_power.id: 100}, + ) + assert entity.get_state()["state"] == 100 + + cluster_handler = list(zha_device._endpoints.values())[0].all_cluster_handlers[ + "1:0x0b04" + ] + assert cluster_handler.ac_power_divisor == 1 + assert cluster_handler.ac_power_multiplier == 1 + + # update power divisor + await send_attributes_report( + zha_gateway, + cluster, + {EMAttrs.active_power.id: 20, EMAttrs.power_divisor.id: 5}, + ) + assert cluster_handler.ac_power_divisor == 5 + assert cluster_handler.ac_power_multiplier == 1 + assert entity.get_state()["state"] == 4.0 + + zha_device.available = False + + await asyncio.sleep(70) + assert ( + "1-2820: skipping polling for updated state, available: False, allow polled requests: True" + in caplog.text + ) + + zha_device.available = True + + await send_attributes_report( + zha_gateway, + cluster, + {EMAttrs.active_power.id: 30, EMAttrs.ac_power_divisor.id: 10}, + ) + assert cluster_handler.ac_power_divisor == 10 + assert cluster_handler.ac_power_multiplier == 1 + assert entity.get_state()["state"] == 3.0 + + # update power multiplier + await send_attributes_report( + zha_gateway, + cluster, + {EMAttrs.active_power.id: 20, EMAttrs.power_multiplier.id: 6}, + ) + assert cluster_handler.ac_power_divisor == 10 + assert cluster_handler.ac_power_multiplier == 6 + assert entity.get_state()["state"] == 12.0 + + await send_attributes_report( + zha_gateway, + cluster, + {EMAttrs.active_power.id: 30, EMAttrs.ac_power_multiplier.id: 20}, + ) + assert cluster_handler.ac_power_divisor == 10 + assert cluster_handler.ac_power_multiplier == 20 + assert entity.get_state()["state"] == 60.0 + + +@pytest.mark.parametrize( + ("cluster_id", "unsupported_attributes", "entity_ids", "missing_entity_ids"), + ( + ( + homeautomation.ElectricalMeasurement.cluster_id, + {"apparent_power", "rms_voltage", "rms_current"}, + { + "electrical_measurement", + "electrical_measurement_ac_frequency", + "electrical_measurement_power_factor", + }, + { + "electrical_measurement_apparent_power", + "electrical_measurement_rms_voltage", + "electrical_measurement_rms_current", + }, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + {"apparent_power", "rms_current", "ac_frequency", "power_factor"}, + {"electrical_measurement_rms_voltage", "electrical_measurement"}, + { + "electrical_measurement_apparent_power", + "electrical_measurement_current", + "electrical_measurement_ac_frequency", + "electrical_measurement_power_factor", + }, + ), + ( + homeautomation.ElectricalMeasurement.cluster_id, + set(), + { + "electrical_measurement_rms_voltage", + "electrical_measurement", + "electrical_measurement_apparent_power", + "electrical_measurement_rms_current", + "electrical_measurement_ac_frequency", + "electrical_measurement_power_factor", + }, + set(), + ), + ( + smartenergy.Metering.cluster_id, + { + "instantaneous_demand", + }, + { + "smartenergy_metering_summation_delivered", + }, + { + "instantaneous_demand", + }, + ), + ( + smartenergy.Metering.cluster_id, + {"instantaneous_demand", "current_summ_delivered"}, + {}, + { + "smartenergy_metering", + "smartenergy_metering_summation_delivered", + }, + ), + ( + smartenergy.Metering.cluster_id, + {}, + { + "smartenergy_metering", + "smartenergy_metering_summation_delivered", + }, + {}, + ), + ), +) +async def test_unsupported_attributes_sensor( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + cluster_id: int, + unsupported_attributes: set, + entity_ids: set, + missing_entity_ids: set, +) -> None: + """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, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + 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 device_joined(zigpy_device) + + present_entity_ids = set( + find_entity_ids(Platform.SENSOR, zha_device, omit=["lqi", "rssi"]) + ) + assert present_entity_ids == entity_ids + assert missing_entity_ids not in present_entity_ids # type: ignore[comparison-overlap] + + +@pytest.mark.parametrize( + "raw_uom, raw_value, expected_state, expected_uom", + ( + ( + 1, + 12320, + 1.23, + UnitOfVolume.CUBIC_METERS, + ), + ( + 1, + 1232000, + 123.2, + UnitOfVolume.CUBIC_METERS, + ), + ( + 3, + 2340, + 0.23, + UnitOfVolume.CUBIC_METERS, + ), + ( + 3, + 2360, + 0.24, + UnitOfVolume.CUBIC_METERS, + ), + ( + 8, + 23660, + 2.37, + UnitOfPressure.KPA, + ), + ( + 0, + 9366, + 0.937, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 0, + 999, + 0.1, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 0, + 10091, + 1.009, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 0, + 10099, + 1.01, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 0, + 100999, + 10.1, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 0, + 100023, + 10.002, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 0, + 102456, + 10.246, + UnitOfEnergy.KILO_WATT_HOUR, + ), + ( + 5, + 102456, + 10.25, + "IMP gal", + ), + ( + 7, + 50124, + 5.01, + UnitOfVolume.LITERS, + ), + ), +) +async def test_se_summation_uom( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + raw_uom: int, + raw_value: int, + expected_state: str, + expected_uom: str, +) -> None: + """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, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + 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, + "summation_formatting": 0b1_0111_010, + "unit_of_measure": raw_uom, + } + zha_device = await device_joined(zigpy_device) + + entity = get_entity(zha_device, entity_id) + + assert_state(entity, expected_state, expected_uom) + + +@pytest.mark.parametrize( + "raw_measurement_type, expected_type", + ( + (1, "ACTIVE_MEASUREMENT"), + (8, "PHASE_A_MEASUREMENT"), + (9, "ACTIVE_MEASUREMENT, PHASE_A_MEASUREMENT"), + ( + 15, + "ACTIVE_MEASUREMENT, REACTIVE_MEASUREMENT, APPARENT_MEASUREMENT, PHASE_A_MEASUREMENT", + ), + ), +) +async def test_elec_measurement_sensor_type( + elec_measurement_zigpy_dev: ZigpyDevice, # pylint: disable=redefined-outer-name + raw_measurement_type: int, + expected_type: str, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zha_gateway: Gateway, # pylint: disable=unused-argument +) -> None: + """Test zha electrical measurement sensor type.""" + + entity_id = ENTITY_ID_PREFIX.format("electrical_measurement") + zigpy_dev = elec_measurement_zigpy_dev + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "measurement_type" + ] = raw_measurement_type + + zha_dev = await device_joined(zigpy_dev) + + entity = get_entity(zha_dev, entity_id) + assert entity is not None + assert entity.get_state()["measurement_type"] == expected_type + + +@pytest.mark.looptime +async def test_elec_measurement_sensor_polling( # pylint: disable=redefined-outer-name + zha_gateway: Gateway, + elec_measurement_zigpy_dev: ZigpyDevice, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> None: + """Test ZHA electrical measurement sensor polling.""" + + entity_id = ENTITY_ID_PREFIX.format("electrical_measurement") + zigpy_dev = elec_measurement_zigpy_dev + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS["active_power"] = ( + 20 + ) + + zha_dev = await device_joined(zigpy_dev) + + # test that the sensor has an initial state of 2.0 + entity = get_entity(zha_dev, entity_id) + assert entity.get_state()["state"] == 2.0 + + # update the value for the power reading + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS["active_power"] = ( + 60 + ) + + # ensure the state is still 2.0 + assert entity.get_state()["state"] == 2.0 + + # let the polling happen + await asyncio.sleep(90) + await zha_gateway.async_block_till_done(wait_background_tasks=True) + + # ensure the state has been updated to 6.0 + assert entity.get_state()["state"] == 6.0 + + +@pytest.mark.parametrize( + "supported_attributes", + ( + set(), + { + "active_power", + "active_power_max", + "rms_current", + "rms_current_max", + "rms_voltage", + "rms_voltage_max", + }, + { + "active_power", + }, + { + "active_power", + "active_power_max", + }, + { + "rms_current", + "rms_current_max", + }, + { + "rms_voltage", + "rms_voltage_max", + }, + ), +) +async def test_elec_measurement_skip_unsupported_attribute( + elec_measurement_zha_dev: Device, # pylint: disable=redefined-outer-name + supported_attributes: set[str], +) -> None: + """Test zha electrical measurement skipping update of unsupported attributes.""" + + entity_id = ENTITY_ID_PREFIX.format("electrical_measurement") + zha_dev = elec_measurement_zha_dev + + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + entity = entities[entity_id] + + cluster = zha_dev.device.endpoints[1].electrical_measurement + + all_attrs = { + "active_power", + "active_power_max", + "apparent_power", + "rms_current", + "rms_current_max", + "rms_voltage", + "rms_voltage_max", + "power_factor", + "ac_frequency", + "ac_frequency_max", + } + for attr in all_attrs - supported_attributes: + cluster.add_unsupported_attribute(attr) + cluster.read_attributes.reset_mock() + + await entity.async_update() + await zha_dev.gateway.async_block_till_done() + assert cluster.read_attributes.call_count == math.ceil( + len(supported_attributes) / ZHA_CLUSTER_HANDLER_READS_PER_REQ + ) + read_attrs = { + a for call in cluster.read_attributes.call_args_list for a in call[0][0] + } + assert read_attrs == supported_attributes + + +class OppleCluster(CustomCluster, ManufacturerSpecificCluster): + """Aqara manufacturer specific cluster.""" + + cluster_id = 0xFCC0 + ep_attribute = "opple_cluster" + attributes = { + 0x010C: ("last_feeding_size", t.uint16_t, True), + } + + def __init__(self, *args, **kwargs) -> None: + """Initialize.""" + super().__init__(*args, **kwargs) + # populate cache to create config entity + self._attr_cache.update({0x010C: 10}) + + +( + add_to_registry_v2("Fake_Manufacturer_sensor", "Fake_Model_sensor") + .replaces(OppleCluster) + .sensor( + "last_feeding_size", + OppleCluster.cluster_id, + divisor=1, + multiplier=1, + unit=UnitOfMass.GRAMS, + ) +) + + +@pytest.fixture +async def zigpy_device_aqara_sensor_v2( + zha_gateway: Gateway, # pylint: disable=unused-argument + zigpy_device_mock, + device_joined, +): + """Device tracker zigpy Aqara motion sensor device.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + OppleCluster.cluster_id, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.OCCUPANCY_SENSOR, + } + }, + manufacturer="Fake_Manufacturer_sensor", + model="Fake_Model_sensor", + ) + + zigpy_device = get_device(zigpy_device) + + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].opple_cluster + + +async def test_last_feeding_size_sensor_v2( + zha_gateway: Gateway, + zigpy_device_aqara_sensor_v2, # pylint: disable=redefined-outer-name +) -> None: + """Test quirks defined sensor.""" + + zha_device, cluster = zigpy_device_aqara_sensor_v2 + assert isinstance(zha_device.device, CustomDeviceV2) + entity_id = find_entity_id( + Platform.SENSOR, zha_device, qualifier="last_feeding_size" + ) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + await send_attributes_report(zha_gateway, cluster, {0x010C: 1}) + assert_state(entity, 1.0, "g") + + await send_attributes_report(zha_gateway, cluster, {0x010C: 5}) + assert_state(entity, 5.0, "g") + + +@pytest.mark.looptime +async def test_device_counter_sensors( + zha_gateway: Gateway, caplog: pytest.LogCaptureFixture +) -> None: + """Test quirks defined sensor.""" + + coordinator = zha_gateway.coordinator_zha_device + assert coordinator.is_coordinator + entity_id = ( + "sensor.coordinator_manufacturer_coordinator_model_ezsp_counters_counter_1" + ) + entity = get_entity(coordinator, entity_id) + assert entity is not None + + assert entity.get_state()["state"] == 1 + + # simulate counter increment on application + coordinator.device.application.state.counters["ezsp_counters"][ + "counter_1" + ].increment() + + await entity.async_update() + await zha_gateway.async_block_till_done() + + assert entity.get_state()["state"] == 2 + + coordinator.available = False + await asyncio.sleep(120) + + assert ( + "counter_1: skipping polling for updated state, available: False, allow polled requests: True" + in caplog.text + ) diff --git a/tests/test_siren.py b/tests/test_siren.py new file mode 100644 index 00000000..a090dab0 --- /dev/null +++ b/tests/test_siren.py @@ -0,0 +1,168 @@ +"""Test zha siren.""" + +import asyncio +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from slugify import slugify +from zigpy.const import SIG_EP_PROFILE +from zigpy.device import Device as ZigpyDevice +from zigpy.profiles import zha +from zigpy.zcl.clusters import general, security +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.zigbee.device import Device + +from .common import find_entity_id, mock_coro +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE + + +@pytest.fixture +async def siren( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> tuple[Device, security.IasWd]: + """Siren fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, security.IasWd.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.IAS_WARNING_DEVICE, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ) + + zha_device = await device_joined(zigpy_device) + return zha_device, zigpy_device.endpoints[1].ias_wd + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +async def test_siren( + siren: tuple[Device, security.IasWd], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zha siren platform.""" + + zha_device, cluster = siren + assert cluster is not None + entity_id = find_entity_id(Platform.SIREN, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["state"] is False + + # turn on from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 50 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to on + assert entity.get_state()["state"] is True + + # turn off from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 2 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to off + assert entity.get_state()["state"] is False + + # turn on from client with options + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await entity.async_turn_on(duration=100, volume_level=3, tone=3) + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 51 # bitmask for specified args + assert cluster.request.call_args[0][4] == 100 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to on + assert entity.get_state()["state"] is True + + +@pytest.mark.looptime +async def test_siren_timed_off( + siren: tuple[Device, security.IasWd], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zha siren platform.""" + zha_device, cluster = siren + assert cluster is not None + + entity_id = find_entity_id(Platform.SIREN, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert entity.get_state()["state"] is False + + # turn on from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 50 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to on + assert entity.get_state()["state"] is True + + await asyncio.sleep(6) + + # test that the state has changed to off from the timer + assert entity.get_state()["state"] is False diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 00000000..10327e67 --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,898 @@ +"""Test zha switch.""" + +from collections.abc import Awaitable, Callable +import logging +from typing import Optional +from unittest.mock import call, patch + +import pytest +from slugify import slugify +from zhaquirks.const import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + OUTPUT_CLUSTERS, + PROFILE_ID, +) +from zigpy.device import Device as ZigpyDevice +from zigpy.exceptions import ZigbeeException +from zigpy.profiles import zha +from zigpy.quirks import _DEVICE_REGISTRY, CustomCluster, CustomDevice +from zigpy.quirks.v2 import CustomDeviceV2, add_to_registry_v2 +import zigpy.types as t +from zigpy.zcl.clusters import closures, general +from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster +import zigpy.zcl.foundation as zcl_f + +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import GroupEntity, PlatformEntity +from zha.exceptions import ZHAException +from zha.zigbee.device import Device +from zha.zigbee.group import Group, GroupMemberReference + +from .common import ( + async_find_group_entity_id, + find_entity_id, + send_attributes_report, + update_attribute_cache, +) +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +ON = 1 +OFF = 0 +IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture +def zigpy_device(zigpy_device_mock: Callable[..., ZigpyDevice]) -> ZigpyDevice: + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + } + zigpy_dev: ZigpyDevice = zigpy_device_mock(endpoints) + # this one is mains powered + zigpy_dev.node_desc.mac_capability_flags |= 0b_0000_0100 + return zigpy_dev + + +@pytest.fixture +def zigpy_cover_device(zigpy_device_mock): + """Zigpy cover device.""" + + endpoints = { + 1: { + SIG_EP_PROFILE: zha.PROFILE_ID, + SIG_EP_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + SIG_EP_INPUT: [ + general.Basic.cluster_id, + closures.WindowCovering.cluster_id, + ], + SIG_EP_OUTPUT: [], + } + } + return zigpy_device_mock(endpoints) + + +@pytest.fixture +async def device_switch_1( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha switch platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE, + ) + zha_device = await device_joined(zigpy_dev) + zha_device.available = True + return zha_device + + +@pytest.fixture +async def device_switch_2( + zigpy_device_mock: Callable[..., ZigpyDevice], + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], +) -> Device: + """Test zha switch platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.OnOff.cluster_id, general.Groups.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + ieee=IEEE_GROUPABLE_DEVICE2, + ) + zha_device = await device_joined(zigpy_dev) + zha_device.available = True + return zha_device + + +async def test_switch( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test zha switch platform.""" + zha_device = await device_joined(zigpy_device) + cluster = zigpy_device.endpoints.get(1).on_off + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is not None + + entity: PlatformEntity = get_entity(zha_device, entity_id) + assert entity is not None + + assert bool(bool(entity.get_state()["state"])) is False + + # turn on at switch + await send_attributes_report(zha_gateway, cluster, {1: 0, 0: 1, 2: 2}) + assert bool(entity.get_state()["state"]) is True + + # turn off at switch + await send_attributes_report(zha_gateway, cluster, {1: 1, 0: 0, 2: 2}) + assert bool(entity.get_state()["state"]) is False + + # turn on from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["state"]) is True + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args == call( + False, + ON, + cluster.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + # Fail turn off from client + with ( + patch( + "zigpy.zcl.Cluster.request", + return_value=[0x01, zcl_f.Status.FAILURE], + ), + pytest.raises(ZHAException, match="Failed to turn off"), + ): + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["state"]) is True + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args == call( + False, + OFF, + cluster.commands_by_name["off"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + # turn off from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x01, zcl_f.Status.SUCCESS], + ): + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["state"]) is False + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args == call( + False, + OFF, + cluster.commands_by_name["off"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + # Fail turn on from client + with ( + patch( + "zigpy.zcl.Cluster.request", + return_value=[0x01, zcl_f.Status.FAILURE], + ), + pytest.raises(ZHAException, match="Failed to turn on"), + ): + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert bool(entity.get_state()["state"]) is False + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args == call( + False, + ON, + cluster.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + # test updating entity state from client + cluster.read_attributes.reset_mock() + assert bool(entity.get_state()["state"]) is False + cluster.PLUGGED_ATTR_READS = {"on_off": True} + await entity.async_update() + await zha_gateway.async_block_till_done() + assert cluster.read_attributes.await_count == 1 + assert cluster.read_attributes.await_args == call( + ["on_off"], allow_cache=False, only_cache=False, manufacturer=None + ) + assert bool(entity.get_state()["state"]) is True + + +async def test_zha_group_switch_entity( + device_switch_1: Device, # pylint: disable=redefined-outer-name + device_switch_2: Device, # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test the switch entity for a ZHA group.""" + member_ieee_addresses = [device_switch_1.ieee, device_switch_2.ieee] + members = [ + GroupMemberReference(ieee=device_switch_1.ieee, endpoint_id=1), + GroupMemberReference(ieee=device_switch_2.ieee, endpoint_id=1), + ] + + # test creating a group with 2 members + zha_group: Group = await zha_gateway.async_create_zigpy_group("Test Group", members) + await zha_gateway.async_block_till_done() + + assert zha_group is not None + assert len(zha_group.members) == 2 + for member in zha_group.members: + assert member.device.ieee in member_ieee_addresses + assert member.group == zha_group + assert member.endpoint is not None + + entity_id = async_find_group_entity_id(Platform.SWITCH, zha_group) + assert entity_id is not None + + entity: GroupEntity = get_group_entity(zha_group, entity_id) # type: ignore + assert entity is not None + + assert isinstance(entity, GroupEntity) + assert entity.group_id == zha_group.group_id + + group_cluster_on_off = zha_group.zigpy_group.endpoint[general.OnOff.cluster_id] + dev1_cluster_on_off = device_switch_1.device.endpoints[1].on_off + dev2_cluster_on_off = device_switch_2.device.endpoints[1].on_off + + # test that the lights were created and are off + assert bool(entity.get_state()["state"]) is False + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert len(group_cluster_on_off.request.mock_calls) == 1 + assert group_cluster_on_off.request.call_args == call( + False, + ON, + group_cluster_on_off.commands_by_name["on"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert bool(entity.get_state()["state"]) is True + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x01, zcl_f.Status.SUCCESS], + ): + # turn off via UI + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert len(group_cluster_on_off.request.mock_calls) == 1 + assert group_cluster_on_off.request.call_args == call( + False, + OFF, + group_cluster_on_off.commands_by_name["off"].schema, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + assert bool(entity.get_state()["state"]) is False + + # test some of the group logic to make sure we key off states correctly + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1}) + await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + + # test that group light is on + assert bool(entity.get_state()["state"]) is True + + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 0}) + await zha_gateway.async_block_till_done() + + # test that group light is still on + assert bool(entity.get_state()["state"]) is True + + await send_attributes_report(zha_gateway, dev2_cluster_on_off, {0: 0}) + await zha_gateway.async_block_till_done() + + # test that group light is now off + assert bool(entity.get_state()["state"]) is False + + await send_attributes_report(zha_gateway, dev1_cluster_on_off, {0: 1}) + await zha_gateway.async_block_till_done() + + # test that group light is now back on + assert bool(entity.get_state()["state"]) is True + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +def get_group_entity(group: Group, entity_id: str) -> Optional[GroupEntity]: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in group.group_entities.values() + } + + return entities.get(entity_id) + + +class WindowDetectionFunctionQuirk(CustomDevice): + """Quirk with window detection function attribute.""" + + class TuyaManufCluster(CustomCluster, ManufacturerSpecificCluster): + """Tuya manufacturer specific cluster.""" + + cluster_id = 0xEF00 + ep_attribute = "tuya_manufacturer" + + attributes = { + 0xEF01: ("window_detection_function", t.Bool), + 0xEF02: ("window_detection_function_inverter", t.Bool), + } + + def __init__(self, *args, **kwargs): + """Initialize with task.""" + super().__init__(*args, **kwargs) + self._attr_cache.update( + {0xEF01: False} + ) # entity won't be created without this + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, + INPUT_CLUSTERS: [general.Basic.cluster_id, TuyaManufCluster], + OUTPUT_CLUSTERS: [], + }, + } + } + + +@pytest.fixture +async def zigpy_device_tuya(zigpy_device_mock, device_joined): + """Device tracker zigpy tuya device.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="_TZE200_b6wax7g0", + quirk=WindowDetectionFunctionQuirk, + ) + + zha_device = await device_joined(zigpy_dev) + zha_device.available = True + return zigpy_dev + + +async def test_switch_configurable( + zha_gateway: Gateway, + device_joined, + zigpy_device_tuya, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA configurable switch platform.""" + + zha_device = await device_joined(zigpy_device_tuya) + cluster = zigpy_device_tuya.endpoints[1].tuya_manufacturer + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test that the state has changed from unavailable to off + assert bool(entity.get_state()["state"]) is False + + # turn on at switch + await send_attributes_report( + zha_gateway, cluster, {"window_detection_function": True} + ) + assert bool(entity.get_state()["state"]) is True + + # turn off at switch + await send_attributes_report( + zha_gateway, cluster, {"window_detection_function": False} + ) + assert bool(entity.get_state()["state"]) is False + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS], + ): + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": True}, manufacturer=None) + ] + + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.Status.SUCCESS, zcl_f.Status.SUCCESS], + ): + # turn off via UI + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": False}, manufacturer=None) + ] + + cluster.read_attributes.reset_mock() + await entity.async_update() + await zha_gateway.async_block_till_done() + # the mocking doesn't update the attr cache so this flips back to initial value + assert cluster.read_attributes.call_count == 1 + assert [ + call( + [ + "window_detection_function", + "window_detection_function_inverter", + ], + allow_cache=False, + only_cache=False, + manufacturer=None, + ) + ] == cluster.read_attributes.call_args_list + + cluster.write_attributes.reset_mock() + cluster.write_attributes.side_effect = ZigbeeException + + with pytest.raises(ZHAException): + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": False}, manufacturer=None), + call({"window_detection_function": False}, manufacturer=None), + call({"window_detection_function": False}, manufacturer=None), + ] + + cluster.write_attributes.side_effect = None + + # test inverter + cluster.write_attributes.reset_mock() + cluster._attr_cache.update({0xEF02: True}) + + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": True}, manufacturer=None) + ] + + cluster.write_attributes.reset_mock() + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": False}, manufacturer=None) + ] + + +async def test_switch_configurable_custom_on_off_values( + zha_gateway: Gateway, device_joined, zigpy_device_mock +) -> None: + """Test ZHA configurable switch platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="manufacturer", + model="model", + ) + + ( + add_to_registry_v2(zigpy_dev.manufacturer, zigpy_dev.model) + .adds(WindowDetectionFunctionQuirk.TuyaManufCluster) + .switch( + "window_detection_function", + WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id, + on_value=3, + off_value=5, + ) + ) + + zigpy_device_ = _DEVICE_REGISTRY.get_device(zigpy_dev) + + assert isinstance(zigpy_device_, CustomDeviceV2) + cluster = zigpy_device_.endpoints[1].tuya_manufacturer + cluster.PLUGGED_ATTR_READS = {"window_detection_function": 5} + update_attribute_cache(cluster) + + zha_device = await device_joined(zigpy_device_) + + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert bool(entity.get_state()["state"]) is False + + # turn on at switch + await send_attributes_report(zha_gateway, cluster, {"window_detection_function": 3}) + assert bool(entity.get_state()["state"]) is True + + # turn off at switch + await send_attributes_report(zha_gateway, cluster, {"window_detection_function": 5}) + assert bool(entity.get_state()["state"]) is False + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 3}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn off via UI + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 5}, manufacturer=None) + ] + + +async def test_switch_configurable_custom_on_off_values_force_inverted( + zha_gateway: Gateway, device_joined, zigpy_device_mock +) -> None: + """Test ZHA configurable switch platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="manufacturer2", + model="model2", + ) + + ( + add_to_registry_v2(zigpy_dev.manufacturer, zigpy_dev.model) + .adds(WindowDetectionFunctionQuirk.TuyaManufCluster) + .switch( + "window_detection_function", + WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id, + on_value=3, + off_value=5, + force_inverted=True, + ) + ) + + zigpy_device_ = _DEVICE_REGISTRY.get_device(zigpy_dev) + + assert isinstance(zigpy_device_, CustomDeviceV2) + cluster = zigpy_device_.endpoints[1].tuya_manufacturer + cluster.PLUGGED_ATTR_READS = {"window_detection_function": 5} + update_attribute_cache(cluster) + + zha_device = await device_joined(zigpy_device_) + + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert bool(entity.get_state()["state"]) is True + + # turn on at switch + await send_attributes_report(zha_gateway, cluster, {"window_detection_function": 3}) + assert bool(entity.get_state()["state"]) is False + + # turn off at switch + await send_attributes_report(zha_gateway, cluster, {"window_detection_function": 5}) + assert bool(entity.get_state()["state"]) is True + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 5}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn off via UI + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 3}, manufacturer=None) + ] + + +async def test_switch_configurable_custom_on_off_values_inverter_attribute( + zha_gateway: Gateway, device_joined, zigpy_device_mock +) -> None: + """Test ZHA configurable switch platform.""" + + zigpy_dev = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + }, + manufacturer="manufacturer3", + model="model3", + ) + + ( + add_to_registry_v2(zigpy_dev.manufacturer, zigpy_dev.model) + .adds(WindowDetectionFunctionQuirk.TuyaManufCluster) + .switch( + "window_detection_function", + WindowDetectionFunctionQuirk.TuyaManufCluster.cluster_id, + on_value=3, + off_value=5, + invert_attribute_name="window_detection_function_inverter", + ) + ) + + zigpy_device_ = _DEVICE_REGISTRY.get_device(zigpy_dev) + + assert isinstance(zigpy_device_, CustomDeviceV2) + cluster = zigpy_device_.endpoints[1].tuya_manufacturer + cluster.PLUGGED_ATTR_READS = { + "window_detection_function": 5, + "window_detection_function_inverter": t.Bool(True), + } + update_attribute_cache(cluster) + + zha_device = await device_joined(zigpy_device_) + + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + assert bool(entity.get_state()["state"]) is True + + # turn on at switch + await send_attributes_report(zha_gateway, cluster, {"window_detection_function": 3}) + assert bool(entity.get_state()["state"]) is False + + # turn off at switch + await send_attributes_report(zha_gateway, cluster, {"window_detection_function": 5}) + assert bool(entity.get_state()["state"]) is True + + # turn on from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn on via UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 5}, manufacturer=None) + ] + cluster.write_attributes.reset_mock() + + # turn off from HA + with patch( + "zigpy.zcl.Cluster.write_attributes", + return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]], + ): + # turn off via UI + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.mock_calls == [ + call({"window_detection_function": 3}, manufacturer=None) + ] + + +WCAttrs = closures.WindowCovering.AttributeDefs +WCT = closures.WindowCovering.WindowCoveringType +WCCS = closures.WindowCovering.ConfigStatus +WCM = closures.WindowCovering.WindowCoveringMode + + +async def test_cover_inversion_switch( + zha_gateway: Gateway, + device_joined, + zigpy_cover_device, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 65, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.window_covering_type.name: WCT.Tilt_blind_tilt_and_lift, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + WCAttrs.window_covering_mode.name: WCM(WCM.LEDs_display_feedback), + } + update_attribute_cache(cluster) + zha_device = await device_joined(zigpy_cover_device) + assert ( + not zha_device.endpoints[1] + .all_cluster_handlers[f"1:0x{cluster.cluster_id:04x}"] + .inverted + ) + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is not None + entity = get_entity(zha_device, entity_id) + assert entity is not None + + # test update + prev_call_count = cluster.read_attributes.call_count + await entity.async_update() + await zha_gateway.async_block_till_done() + assert cluster.read_attributes.call_count == prev_call_count + 1 + assert bool(entity.get_state()["state"]) is False + + # test to see the state remains after tilting to 0% + await send_attributes_report( + zha_gateway, cluster, {WCAttrs.current_position_tilt_percentage.id: 0} + ) + assert bool(entity.get_state()["state"]) is False + + with patch( + "zigpy.zcl.Cluster.write_attributes", return_value=[0x1, zcl_f.Status.SUCCESS] + ): + cluster.PLUGGED_ATTR_READS = { + WCAttrs.config_status.name: WCCS.Operational + | WCCS.Open_up_commands_reversed, + } + # turn on from UI + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args_list[0] == call( + { + WCAttrs.window_covering_mode.name: WCM.Motor_direction_reversed + | WCM.LEDs_display_feedback + }, + manufacturer=None, + ) + + assert bool(entity.get_state()["state"]) is True + + cluster.write_attributes.reset_mock() + + # turn off from UI + cluster.PLUGGED_ATTR_READS = { + WCAttrs.config_status.name: WCCS.Operational, + } + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.call_count == 1 + assert cluster.write_attributes.call_args_list[0] == call( + {WCAttrs.window_covering_mode.name: WCM.LEDs_display_feedback}, + manufacturer=None, + ) + + assert bool(entity.get_state()["state"]) is False + + cluster.write_attributes.reset_mock() + + # test that sending the command again does not result in a write + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert cluster.write_attributes.call_count == 0 + + assert bool(entity.get_state()["state"]) is False + + +async def test_cover_inversion_switch_not_created( + device_joined, + zigpy_cover_device, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA cover platform.""" + + # load up cover domain + cluster = zigpy_cover_device.endpoints[1].window_covering + cluster.PLUGGED_ATTR_READS = { + WCAttrs.current_position_lift_percentage.name: 65, + WCAttrs.current_position_tilt_percentage.name: 42, + WCAttrs.config_status.name: WCCS(~WCCS.Open_up_commands_reversed), + } + update_attribute_cache(cluster) + zha_device = await device_joined(zigpy_cover_device) + + assert cluster.read_attributes.call_count == 3 + assert ( + WCAttrs.current_position_lift_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + assert ( + WCAttrs.current_position_tilt_percentage.name + in cluster.read_attributes.call_args[0][0] + ) + + # entity should not be created when mode or config status aren't present + entity_id = find_entity_id(Platform.SWITCH, zha_device) + assert entity_id is None diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 00000000..45e7a77f --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,30 @@ +"""Tests for units.""" + +import enum + +import pytest +from zigpy.quirks.v2.homeassistant import UnitOfPower as QuirksUnitOfPower + +from zha.units import UnitOfPower, validate_unit + + +def test_unit_validation() -> None: + """Test unit validation.""" + + assert validate_unit(QuirksUnitOfPower.WATT) == UnitOfPower.WATT + + class FooUnit(enum.Enum): + """Foo unit.""" + + BAR = "bar" + + class UnitOfMass(enum.Enum): + """UnitOfMass.""" + + BAR = "bar" + + with pytest.raises(KeyError): + validate_unit(FooUnit.BAR) + + with pytest.raises(ValueError): + validate_unit(UnitOfMass.BAR) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 00000000..55202aa2 --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,577 @@ +"""Test ZHA firmware updates.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock, patch + +import pytest +from slugify import slugify +from zigpy.device import Device as ZigpyDevice +from zigpy.exceptions import DeliveryError +from zigpy.ota import OtaImageWithMetadata +import zigpy.ota.image as firmware +from zigpy.ota.providers import BaseOtaImageMetadata +from zigpy.profiles import zha +import zigpy.types as t +from zigpy.zcl import Cluster, foundation +from zigpy.zcl.clusters import general + +from tests.common import find_entity_id, update_attribute_cache +from tests.conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.platforms import PlatformEntity +from zha.application.platforms.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, +) +from zha.exceptions import ZHAException +from zha.zigbee.device import Device + + +@pytest.fixture +def zigpy_device(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + } + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00" + ) + + +def get_entity(zha_dev: Device, entity_id: str) -> PlatformEntity: + """Get entity.""" + entities = { + entity.PLATFORM + "." + slugify(entity.name, separator="_"): entity + for entity in zha_dev.platform_entities.values() + } + return entities[entity_id] + + +async def setup_test_data( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name + skip_attribute_plugs=False, + file_not_found=False, +): + """Set up test data for the tests.""" + fw_version = 0x12345678 + installed_fw_version = fw_version - 10 + cluster = zigpy_device.endpoints[1].out_clusters[general.Ota.cluster_id] + if not skip_attribute_plugs: + cluster.PLUGGED_ATTR_READS = { + general.Ota.AttributeDefs.current_file_version.name: installed_fw_version + } + update_attribute_cache(cluster) + + # set up firmware image + fw_image = OtaImageWithMetadata( + metadata=BaseOtaImageMetadata( + file_version=fw_version, + manufacturer_id=0x1234, + image_type=0x90, + changelog="This is a test firmware image!", + ), + firmware=firmware.OTAImage( + header=firmware.OTAImageHeader( + upgrade_file_id=firmware.OTAImageHeader.MAGIC_VALUE, + file_version=fw_version, + image_type=0x90, + manufacturer_id=0x1234, + header_version=256, + header_length=56, + field_control=0, + stack_version=2, + header_string="This is a test header!", + image_size=56 + 2 + 4 + 8, + ), + subelements=[firmware.SubElement(tag_id=0x0000, data=b"fw_image")], + ), + ) + + cluster.endpoint.device.application.ota.get_ota_image = AsyncMock( + return_value=None if file_not_found else fw_image + ) + + zha_device = await device_joined(zigpy_device) + zha_device.async_update_sw_build_id(installed_fw_version) + + return zha_device, cluster, fw_image, installed_fw_version + + +async def test_firmware_update_notification_from_zigpy( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA update platform - firmware update notification.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + device_joined, + zigpy_device, + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.state is False + + # simulate an image available notification + await cluster._handle_query_next_image( + foundation.ZCLHeader.cluster( + tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id + ), + general.QueryNextImageCommand( + fw_image.firmware.header.field_control, + zha_device.manufacturer_code, + fw_image.firmware.header.image_type, + installed_fw_version, + fw_image.firmware.header.header_version, + ), + ) + + await zha_gateway.async_block_till_done() + assert entity.state is True + assert entity.get_state()[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" # type: ignore[unreachable] + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + + +async def test_firmware_update_notification_from_service_call( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA update platform - firmware update manual check.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + device_joined, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.state is False + + # pylint: disable=pointless-string-statement + """TODO + async def _async_image_notify_side_effect(*args, **kwargs): + await cluster._handle_query_next_image( + foundation.ZCLHeader.cluster( + tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id + ), + general.QueryNextImageCommand( + fw_image.firmware.header.field_control, + zha_device.manufacturer_code, + fw_image.firmware.header.image_type, + installed_fw_version, + fw_image.firmware.header.header_version, + ), + ) + + await _async_image_notify_side_effect() + + assert cluster.endpoint.device.application.ota.broadcast_notify.await_count == 1 + assert cluster.endpoint.device.application.ota.broadcast_notify.call_args_list[ + 0 + ] == call( + jitter=100, + ) + + await zha_gateway.async_block_till_done() + assert entity.state is True + assert entity.get_state()[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + """ + + +def make_packet( + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name + cluster: Cluster, + cmd_name: str, + **kwargs, +): + """Make a zigpy packet.""" + req_hdr, req_cmd = cluster._create_request( + general=False, + command_id=cluster.commands_by_name[cmd_name].id, + schema=cluster.commands_by_name[cmd_name].schema, + disable_default_response=False, + direction=foundation.Direction.Client_to_Server, + args=(), + kwargs=kwargs, + ) + + ota_packet = t.ZigbeePacket( + src=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=zigpy_device.nwk), + src_ep=1, + dst=t.AddrModeAddress(addr_mode=t.AddrMode.NWK, address=0x0000), + dst_ep=1, + tsn=req_hdr.tsn, + profile_id=260, + cluster_id=cluster.cluster_id, + data=t.SerializableBytes(req_hdr.serialize() + req_cmd.serialize()), + lqi=255, + rssi=-30, + ) + + return ota_packet + + +@patch("zigpy.device.AFTER_OTA_ATTR_READ_DELAY", 0.01) +async def test_firmware_update_success( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA update platform - firmware update success.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + device_joined, zigpy_device + ) + + assert installed_fw_version < fw_image.firmware.header.file_version + + entity_id = find_entity_id(Platform.UPDATE, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.state is False + + # simulate an image available notification + await cluster._handle_query_next_image( + foundation.ZCLHeader.cluster( + tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id + ), + general.QueryNextImageCommand( + field_control=fw_image.firmware.header.field_control, + manufacturer_code=zha_device.manufacturer_code, + image_type=fw_image.firmware.header.image_type, + current_file_version=installed_fw_version, + ), + ) + + await zha_gateway.async_block_till_done() + assert entity.state is True + assert entity.get_state()[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" # type: ignore[unreachable] + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.firmware.header.manufacturer_id, + image_type=fw_image.firmware.header.image_type, + current_file_version=fw_image.firmware.header.file_version - 10, + hardware_version=1, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema + ): + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id + assert cmd.image_type == fw_image.firmware.header.image_type + assert cmd.file_version == fw_image.firmware.header.file_version + assert cmd.image_size == fw_image.firmware.header.image_size + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.image_block.name, + field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr, + manufacturer_code=fw_image.firmware.header.manufacturer_id, + image_type=fw_image.firmware.header.image_type, + file_version=fw_image.firmware.header.file_version, + file_offset=0, + maximum_data_size=40, + request_node_addr=zigpy_device.ieee, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.image_block_response.schema + ): + if cmd.file_offset == 0: + assert cmd.status == foundation.Status.SUCCESS + assert ( + cmd.manufacturer_code + == fw_image.firmware.header.manufacturer_id + ) + assert cmd.image_type == fw_image.firmware.header.image_type + assert cmd.file_version == fw_image.firmware.header.file_version + assert cmd.file_offset == 0 + assert cmd.image_data == fw_image.firmware.serialize()[0:40] + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.image_block.name, + field_control=general.Ota.ImageBlockCommand.FieldControl.RequestNodeAddr, + manufacturer_code=fw_image.firmware.header.manufacturer_id, + image_type=fw_image.firmware.header.image_type, + file_version=fw_image.firmware.header.file_version, + file_offset=40, + maximum_data_size=40, + request_node_addr=zigpy_device.ieee, + ) + ) + elif cmd.file_offset == 40: + assert cmd.status == foundation.Status.SUCCESS + assert ( + cmd.manufacturer_code + == fw_image.firmware.header.manufacturer_id + ) + assert cmd.image_type == fw_image.firmware.header.image_type + assert cmd.file_version == fw_image.firmware.header.file_version + assert cmd.file_offset == 40 + assert cmd.image_data == fw_image.firmware.serialize()[40:70] + + # make sure the state machine gets progress reports + assert entity.state is True + assert ( + entity.get_state()[ATTR_INSTALLED_VERSION] + == f"0x{installed_fw_version:08x}" + ) + assert entity.get_state()[ATTR_IN_PROGRESS] == 58 + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.upgrade_end.name, + status=foundation.Status.SUCCESS, + manufacturer_code=fw_image.firmware.header.manufacturer_id, + image_type=fw_image.firmware.header.image_type, + file_version=fw_image.firmware.header.file_version, + ) + ) + + elif isinstance( + cmd, general.Ota.ClientCommandDefs.upgrade_end_response.schema + ): + assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id + assert cmd.image_type == fw_image.firmware.header.image_type + assert cmd.file_version == fw_image.firmware.header.file_version + assert cmd.current_time == 0 + assert cmd.upgrade_time == 0 + + def read_new_fw_version(*args, **kwargs): + cluster.update_attribute( + attrid=general.Ota.AttributeDefs.current_file_version.id, + value=fw_image.firmware.header.file_version, + ) + return { + general.Ota.AttributeDefs.current_file_version.id: ( + fw_image.firmware.header.file_version + ) + }, {} + + cluster.read_attributes.side_effect = read_new_fw_version + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + + entity = get_entity(zha_device, entity_id) + assert entity is not None + + await entity.async_install(fw_image.firmware.header.file_version, False) + await zha_gateway.async_block_till_done() + + assert entity.state is False + + assert ( + entity.get_state()[ATTR_INSTALLED_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == entity.get_state()[ATTR_INSTALLED_VERSION] + ) + + # If we send a progress notification incorrectly, it won't be handled + entity._update_progress(50, 100, 0.50) + + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert entity.state is False + + +async def test_firmware_update_raises( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA update platform - firmware update raises.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + device_joined, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.state is False + + # simulate an image available notification + await cluster._handle_query_next_image( + foundation.ZCLHeader.cluster( + tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id + ), + general.QueryNextImageCommand( + fw_image.firmware.header.field_control, + zha_device.manufacturer_code, + fw_image.firmware.header.image_type, + installed_fw_version, + fw_image.firmware.header.header_version, + ), + ) + + await zha_gateway.async_block_till_done() + assert entity.state is True + assert entity.get_state()[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" # type: ignore[unreachable] + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.firmware.header.manufacturer_id, + image_type=fw_image.firmware.header.image_type, + current_file_version=fw_image.firmware.header.file_version - 10, + hardware_version=1, + ) + ) + elif isinstance( + cmd, general.Ota.ClientCommandDefs.query_next_image_response.schema + ): + assert cmd.status == foundation.Status.SUCCESS + assert cmd.manufacturer_code == fw_image.firmware.header.manufacturer_id + assert cmd.image_type == fw_image.firmware.header.image_type + assert cmd.file_version == fw_image.firmware.header.file_version + assert cmd.image_size == fw_image.firmware.header.image_size + raise DeliveryError("failed to deliver") + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + with pytest.raises(ZHAException): + await entity.async_install(fw_image.firmware.header.file_version, False) + await zha_gateway.async_block_till_done() + + with ( + patch( + "zigpy.device.Device.update_firmware", + AsyncMock(side_effect=DeliveryError("failed to deliver")), + ), + pytest.raises(ZHAException), + ): + await entity.async_install(fw_image.firmware.header.file_version, False) + await zha_gateway.async_block_till_done() + + +async def test_firmware_update_no_longer_compatible( + zha_gateway: Gateway, + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_device: ZigpyDevice, # pylint: disable=redefined-outer-name +) -> None: + """Test ZHA update platform - firmware update is no longer valid.""" + zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( + device_joined, zigpy_device + ) + + entity_id = find_entity_id(Platform.UPDATE, zha_device) + assert entity_id is not None + + entity = get_entity(zha_device, entity_id) + assert entity is not None + assert entity.state is False + + # simulate an image available notification + await cluster._handle_query_next_image( + foundation.ZCLHeader.cluster( + tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id + ), + general.QueryNextImageCommand( + fw_image.firmware.header.field_control, + zha_device.manufacturer_code, + fw_image.firmware.header.image_type, + installed_fw_version, + fw_image.firmware.header.header_version, + ), + ) + + await zha_gateway.async_block_till_done() + assert entity.state is True + assert entity.get_state()[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" # type: ignore[unreachable] + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert ( + entity.get_state()[ATTR_LATEST_VERSION] + == f"0x{fw_image.firmware.header.file_version:08x}" + ) + + new_version = 0x99999999 + + async def endpoint_reply(cluster_id, tsn, data, command_id): + if cluster_id == general.Ota.cluster_id: + hdr, cmd = cluster.deserialize(data) + if isinstance(cmd, general.Ota.ImageNotifyCommand): + zigpy_device.packet_received( + make_packet( + zigpy_device, + cluster, + general.Ota.ServerCommandDefs.query_next_image.name, + field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, + manufacturer_code=fw_image.firmware.header.manufacturer_id, + image_type=fw_image.firmware.header.image_type, + # The device reports that it is no longer compatible! + current_file_version=new_version, + hardware_version=1, + ) + ) + + cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) + with pytest.raises(ZHAException): + await entity.async_install(fw_image.firmware.header.file_version, False) + await zha_gateway.async_block_till_done() + + # We updated the currently installed firmware version, as it is no longer valid + assert entity.state is False + assert entity.get_state()[ATTR_INSTALLED_VERSION] == f"0x{new_version:08x}" + assert not entity.get_state()[ATTR_IN_PROGRESS] + assert entity.get_state()[ATTR_LATEST_VERSION] == f"0x{new_version:08x}" diff --git a/tests/zha_devices_list.py b/tests/zha_devices_list.py new file mode 100644 index 00000000..1fd10e97 --- /dev/null +++ b/tests/zha_devices_list.py @@ -0,0 +1,5922 @@ +"""Example Zigbee Devices.""" + +from zigpy.const import ( + SIG_ENDPOINTS, + SIG_EP_INPUT, + SIG_EP_OUTPUT, + SIG_EP_PROFILE, + SIG_EP_TYPE, + SIG_MANUFACTURER, + SIG_MODEL, + SIG_NODE_DESC, +) +from zigpy.profiles import zha, zll +from zigpy.types import Bool, uint8_t +from zigpy.zcl.clusters.closures import DoorLock +from zigpy.zcl.clusters.general import ( + Basic, + Groups, + Identify, + LevelControl, + MultistateInput, + OnOff, + Ota, + PowerConfiguration, + Scenes, +) +from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.clusters.measurement import ( + IlluminanceMeasurement, + OccupancySensing, + TemperatureMeasurement, +) + +DEV_SIG_CLUSTER_HANDLERS = "cluster_handlers" +DEV_SIG_DEV_NO = "device_no" +DEV_SIG_ENT_MAP = "entity_map" +DEV_SIG_ENT_MAP_CLASS = "entity_class" +DEV_SIG_ENT_MAP_ID = "entity_id" +DEV_SIG_EP_ID = "endpoint_id" +DEV_SIG_EVT_CLUSTER_HANDLERS = "event_cluster_handlers" +DEV_SIG_ZHA_QUIRK = "zha_quirk" +DEV_SIG_ATTRIBUTES = "attributes" + + +PROFILE_ID = SIG_EP_PROFILE +DEVICE_TYPE = SIG_EP_TYPE +INPUT_CLUSTERS = SIG_EP_INPUT +OUTPUT_CLUSTERS = SIG_EP_OUTPUT + +DEVICES = [ + { + DEV_SIG_DEV_NO: 0, + SIG_MANUFACTURER: "ADUROLIGHT", + SIG_MODEL: "Adurolight_NCC", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2080, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4096, 64716], + SIG_EP_OUTPUT: [3, 4, 6, 8, 4096, 64716], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.adurolight_adurolight_ncc_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.adurolight_adurolight_ncc_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.adurolight_adurolight_ncc_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 1, + SIG_MANUFACTURER: "Bosch", + SIG_MODEL: "ISW-ZPR1-WP13", + SIG_NODE_DESC: b"\x02@\x08\x00\x00l\x00\x00\x00\x00\x00\x00\x00", + SIG_ENDPOINTS: { + 5: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["5:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-5-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.bosch_isw_zpr1_wp13_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-5-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-5-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-5-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-5-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.bosch_isw_zpr1_wp13_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-5-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.bosch_isw_zpr1_wp13_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 2, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3130", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 2821], + SIG_EP_OUTPUT: [3, 6, 8, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3130_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3130_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3130_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 3, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3210-L", + SIG_NODE_DESC: b"\x01@\x8eN\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2820, 2821, 64515], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("switch", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.centralite_3210_l_switch", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3210_l_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_apparent_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3210_l_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 4, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3310-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 770, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 2821, 64581], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3310_s_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-64581"): { + DEV_SIG_CLUSTER_HANDLERS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3310_s_humidity", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3310_s_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 5, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3315-S", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64527], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3315_s_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3315_s_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3315_s_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 6, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3320-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64527], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3320_l_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3320_l_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3320_l_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 7, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "3326-L", + SIG_NODE_DESC: b"\x02@\x80\xdf\xc2RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821, 64582], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 49887, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_3326_l_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3326_l_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_3326_l_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 8, + SIG_MANUFACTURER: "CentraLite", + SIG_MODEL: "Motion Sensor-A", + SIG_NODE_DESC: b"\x02@\x80N\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 1030, 2821], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.centralite_motion_sensor_a_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_motion_sensor_a_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { + DEV_SIG_CLUSTER_HANDLERS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: ( + "binary_sensor.centralite_motion_sensor_a_occupancy" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.centralite_motion_sensor_a_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 9, + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "PSMP5_00.00.02.02TC", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, + }, + 4: { + SIG_EP_TYPE: 9, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["4:0x0019"], + DEV_SIG_ENT_MAP: { + ("switch", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: ( + "switch.climaxtechnology_psmp5_00_00_02_02tc_switch" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.climaxtechnology_psmp5_00_00_02_02tc_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: ( + "sensor.climaxtechnology_psmp5_00_00_02_02tc_instantaneous_demand" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: ( + "sensor.climaxtechnology_psmp5_00_00_02_02tc_summation_delivered" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_psmp5_00_00_02_02tc_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-4-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.climaxtechnology_psmp5_00_00_02_02tc_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 10, + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "SD8SC_00.00.03.12TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 1280, 1282], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: ( + "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_ias_zone" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.climaxtechnology_sd8sc_00_00_03_12tc_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_sd8sc_00_00_03_12tc_lqi", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultToneSelectEntity", + DEV_SIG_ENT_MAP_ID: ( + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_tone" + ), + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultSirenLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: ( + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_siren_level" + ), + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultStrobeLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: ( + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe_level" + ), + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultStrobeSelectEntity", + DEV_SIG_ENT_MAP_ID: ( + "select.climaxtechnology_sd8sc_00_00_03_12tc_default_strobe" + ), + }, + ("siren", "00:11:22:33:44:55:66:77-1-1282"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "Siren", + DEV_SIG_ENT_MAP_ID: "siren.climaxtechnology_sd8sc_00_00_03_12tc_siren", + }, + }, + }, + { + DEV_SIG_DEV_NO: 11, + SIG_MANUFACTURER: "ClimaxTechnology", + SIG_MODEL: "WS15_00.00.03.03TC", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 1280], + SIG_EP_OUTPUT: [0], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: ( + "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_ias_zone" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.climaxtechnology_ws15_00_00_03_03tc_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_ws15_00_00_03_03tc_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.climaxtechnology_ws15_00_00_03_03tc_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 12, + SIG_MANUFACTURER: "Feibit Inc co.", + SIG_MODEL: "FB56-ZCW08KU1.1", + SIG_NODE_DESC: b"\x01@\x8e\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 11: { + SIG_EP_TYPE: 528, + DEV_SIG_EP_ID: 11, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49246, + }, + 13: { + SIG_EP_TYPE: 57694, + DEV_SIG_EP_ID: 13, + SIG_EP_INPUT: [4096], + SIG_EP_OUTPUT: [4096], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-11"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.feibit_inc_co_fb56_zcw08ku1_1_light", + }, + ("button", "00:11:22:33:44:55:66:77-11-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.feibit_inc_co_fb56_zcw08ku1_1_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-11-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.feibit_inc_co_fb56_zcw08ku1_1_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-11-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.feibit_inc_co_fb56_zcw08ku1_1_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 13, + SIG_MANUFACTURER: "HEIMAN", + SIG_MODEL: "SmokeSensor-EM", + SIG_NODE_DESC: b"\x02@\x80\x0b\x12RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1280, 1282], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.heiman_smokesensor_em_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_smokesensor_em_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_smokesensor_em_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 14, + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "CO_V16", + SIG_NODE_DESC: b"\x02@\x84\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.heiman_co_v16_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_co_v16_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_co_v16_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 15, + SIG_MANUFACTURER: "Heiman", + SIG_MODEL: "WarningDevice", + SIG_NODE_DESC: b"\x01@\x8e\x0b\x12RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1027, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 9, 1280, 1282], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("select", "00:11:22:33:44:55:66:77-1-1282-WarningMode"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultToneSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_tone", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-SirenLevel"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultSirenLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_siren_level", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-StrobeLevel"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultStrobeLevelSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe_level", + }, + ("select", "00:11:22:33:44:55:66:77-1-1282-Strobe"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "DefaultStrobeSelectEntity", + DEV_SIG_ENT_MAP_ID: "select.heiman_warningdevice_default_strobe", + }, + ("siren", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_wd"], + DEV_SIG_ENT_MAP_CLASS: "Siren", + DEV_SIG_ENT_MAP_ID: "siren.heiman_warningdevice_siren", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.heiman_warningdevice_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.heiman_warningdevice_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.heiman_warningdevice_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 16, + SIG_MANUFACTURER: "HiveHome.com", + SIG_MODEL: "MOT003", + SIG_NODE_DESC: b"\x02@\x809\x10PP\x00\x00\x00P\x00\x00", + SIG_ENDPOINTS: { + 6: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["6:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-6-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.hivehome_com_mot003_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-6-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-6-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_illuminance", + }, + ("sensor", "00:11:22:33:44:55:66:77-6-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-6-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-6-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.hivehome_com_mot003_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-6-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.hivehome_com_mot003_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 17, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E12 WS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 268, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 4096, 64636], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 260, + }, + 242: { + SIG_EP_TYPE: 97, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [33], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: ( + "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_light" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_lqi" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 18, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 CWS opal 600lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 512, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: ( + "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_light" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_lqi" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 19, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 W opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: ( + "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_light" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_lqi" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 20, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 WS opal 980lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 544, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: ( + "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_light" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_lqi" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 21, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI bulb E26 opal 1000lm", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 2821, 4096], + SIG_EP_OUTPUT: [5, 25, 32, 4096], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: ( + "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_light" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_lqi" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 22, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI control outlet", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 266, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 64636], + SIG_EP_OUTPUT: [5, 25, 32], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("switch", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: ( + "switch.ikea_of_sweden_tradfri_control_outlet_switch" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_control_outlet_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_control_outlet_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_control_outlet_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 23, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI motion sensor", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2128, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 6, 25, 4096], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_motion_sensor_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_motion_sensor_battery" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_motion_sensor_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Motion", + DEV_SIG_ENT_MAP_ID: ( + "binary_sensor.ikea_of_sweden_tradfri_motion_sensor_motion" + ), + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_motion_sensor_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 24, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI on/off switch", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2080, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 32, 4096, 64636], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 258, 4096], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0102"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_on_off_switch_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_on_off_switch_battery" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_on_off_switch_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_on_off_switch_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 25, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI remote control", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2096, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 4096], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_remote_control_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_remote_control_battery" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_remote_control_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_remote_control_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 26, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI signal repeater", + SIG_NODE_DESC: b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 8, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 9, 2821, 4096, 64636], + SIG_EP_OUTPUT: [25, 32, 4096], + SIG_EP_PROFILE: 260, + }, + 242: { + SIG_EP_TYPE: 97, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [33], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_signal_repeater_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_signal_repeater_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_signal_repeater_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_signal_repeater_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 27, + SIG_MANUFACTURER: "IKEA of Sweden", + SIG_MODEL: "TRADFRI wireless dimmer", + SIG_NODE_DESC: b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 9, 2821, 4096], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 4096], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.ikea_of_sweden_tradfri_wireless_dimmer_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_battery" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.ikea_of_sweden_tradfri_wireless_dimmer_rssi" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ikea_of_sweden_tradfri_wireless_dimmer_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ikea_of_sweden_tradfri_wireless_dimmer_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 28, + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45852", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 260, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6, 8], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006", "2:0x0008"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45852_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45852_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45852_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45852_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 29, + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45856", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45856_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45856_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45856_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45856_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 30, + SIG_MANUFACTURER: "Jasco Products", + SIG_MODEL: "45857", + SIG_NODE_DESC: b"\x01@\x8e$\x11R\xff\x00\x00\x00\xff\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 260, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 3, 2821], + SIG_EP_OUTPUT: [3, 6, 8], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006", "2:0x0008"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "ForceOnLight", + DEV_SIG_ENT_MAP_ID: "light.jasco_products_45857_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.jasco_products_45857_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.jasco_products_45857_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.jasco_products_45857_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 31, + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-610-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 8, 32, 1026, 1027, 2821, 64513, 64514], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_610_mp_1_3_identify", + }, + ("cover", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_610_mp_1_3_keen_vent", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_pressure", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_610_mp_1_3_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_610_mp_1_3_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 32, + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.2", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 8, 32, 1026, 1027, 2821, 64513, 64514], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_2_identify", + }, + ("cover", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_2_keen_vent", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_pressure", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_2_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_612_mp_1_2_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 33, + SIG_MANUFACTURER: "Keen Home Inc", + SIG_MODEL: "SV02-612-MP-1.3", + SIG_NODE_DESC: b"\x02@\x80[\x11RR\x00\x00*R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 3, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 8, 32, 1026, 1027, 2821, 64513, 64514], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.keen_home_inc_sv02_612_mp_1_3_identify", + }, + ("cover", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off"], + DEV_SIG_ENT_MAP_CLASS: "KeenVent", + DEV_SIG_ENT_MAP_ID: "cover.keen_home_inc_sv02_612_mp_1_3_keen_vent", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_pressure", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.keen_home_inc_sv02_612_mp_1_3_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.keen_home_inc_sv02_612_mp_1_3_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 34, + SIG_MANUFACTURER: "King Of Fans, Inc.", + SIG_MODEL: "HBUniversalCFRemote", + SIG_NODE_DESC: b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 514], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.king_of_fans_inc_hbuniversalcfremote_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: ( + "button.king_of_fans_inc_hbuniversalcfremote_identify" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.king_of_fans_inc_hbuniversalcfremote_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.king_of_fans_inc_hbuniversalcfremote_lqi", + }, + ("fan", "00:11:22:33:44:55:66:77-1-514"): { + DEV_SIG_CLUSTER_HANDLERS: ["fan"], + DEV_SIG_ENT_MAP_CLASS: "KofFan", + DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.king_of_fans_inc_hbuniversalcfremote_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 35, + SIG_MANUFACTURER: "LDS", + SIG_MODEL: "ZBT-CCTSwitch-D0001", + SIG_NODE_DESC: b"\x02@\x80h\x11RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2048, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4096, 64769], + SIG_EP_OUTPUT: [3, 4, 6, 8, 25, 768, 4096], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019", "1:0x0300"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lds_zbt_cctswitch_d0001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lds_zbt_cctswitch_d0001_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lds_zbt_cctswitch_d0001_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 36, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_a19_rgbw_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_a19_rgbw_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_a19_rgbw_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_a19_rgbw_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 37, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "FLEX RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_flex_rgbw_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_flex_rgbw_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_flex_rgbw_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_flex_rgbw_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 38, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "PLUG", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2821, 64513, 64520], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("switch", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.ledvance_plug_switch", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_plug_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_plug_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_plug_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 39, + SIG_MANUFACTURER: "LEDVANCE", + SIG_MODEL: "RT RGBW", + SIG_NODE_DESC: b"\x01@\x8e\x89\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.ledvance_rt_rgbw_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.ledvance_rt_rgbw_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.ledvance_rt_rgbw_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.ledvance_rt_rgbw_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 40, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.plug.maus01", + SIG_NODE_DESC: b"\x01@\x8e_\x11\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 81, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 4, 5, 6, 10, 16, 2820], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 9, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [12], + SIG_EP_OUTPUT: [4, 12], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 83, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [12], + SIG_EP_OUTPUT: [12], + SIG_EP_PROFILE: 260, + }, + 100: { + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 100, + SIG_EP_INPUT: [15], + SIG_EP_OUTPUT: [4, 15], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("switch", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.lumi_lumi_plug_maus01_switch", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_device_temperature", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_plug_maus01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-100-15"): { + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_plug_maus01_binary_input", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_summation_delivered", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_plug_maus01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 41, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.relay.c2acn01", + SIG_NODE_DESC: b"\x01@\x8e7\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 4, 5, 6, 10, 12, 16, 2820], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [4, 5, 6, 16], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_light", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_device_temperature", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_relay_c2acn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_apparent_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_lqi", + }, + ("light", "00:11:22:33:44:55:66:77-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_relay_c2acn01_light_2", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_relay_c2acn01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 42, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b186acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b186acn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b186acn01_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_remote_b186acn01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 43, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286acn01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286acn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286acn01_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_remote_b286acn01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 44, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b286opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 3: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 4: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 5: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 6: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b286opcn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b286opcn01_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 45, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b486opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 4: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 5: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + 6: { + SIG_EP_TYPE: -1, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: -1, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b486opcn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b486opcn01_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 46, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 47, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.remote.b686opcn01", + SIG_NODE_DESC: b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 261, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6, 8, 768], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, + }, + 4: { + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, + }, + 5: { + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, + }, + 6: { + SIG_EP_TYPE: None, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: None, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0300", "2:0x0006"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_remote_b686opcn01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_remote_b686opcn01_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 48, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 8: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["8:0x0006"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-8"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_light", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_opening", + }, + }, + }, + { + DEV_SIG_DEV_NO: 49, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 8: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6, 11, 17], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["8:0x0006"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-8"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_light", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_opening", + }, + }, + }, + { + DEV_SIG_DEV_NO: 50, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.router", + SIG_NODE_DESC: b"\x01@\x8e_\x11P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 8: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 8, + SIG_EP_INPUT: [0, 6, 17], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["8:0x0006"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-8"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.lumi_lumi_router_light", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-8-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_router_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_router_opening", + }, + }, + }, + { + DEV_SIG_DEV_NO: 51, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sen_ill.mgl01", + SIG_NODE_DESC: b"\x02@\x84n\x12\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 262, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1024], + SIG_EP_OUTPUT: [3], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_battery", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sen_ill_mgl01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_illuminance", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sen_ill_mgl01_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 52, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_86sw1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 18, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 12, 18], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_86sw1_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_86sw1_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_86sw1_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 53, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_cube.aqgl01", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 28417, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 28418, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3, 18], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 28419, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3, 12], + SIG_EP_OUTPUT: [3, 4, 5, 12], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_cube_aqgl01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_cube_aqgl01_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_cube_aqgl01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 54, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_ht", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 1026, 1029, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 18, 25, 65535], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 24322, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 18], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 24323, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [3], + SIG_EP_OUTPUT: [3, 4, 5, 12], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005", "3:0x0005"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_ht_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { + DEV_SIG_CLUSTER_HANDLERS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_ht_humidity", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_ht_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 55, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2128, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 25, 65535], + SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_opening", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_magnet_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 56, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_magnet.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 65535], + SIG_EP_OUTPUT: [0, 4, 6, 65535], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_magnet_aq2_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_magnet_aq2_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Opening", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_magnet_aq2_opening", + }, + }, + }, + { + DEV_SIG_DEV_NO: 57, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_motion.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 263, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1024, 1030, 1280, 65535], + SIG_EP_OUTPUT: [0, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1030"): { + DEV_SIG_CLUSTER_HANDLERS: ["occupancy"], + DEV_SIG_ENT_MAP_CLASS: "Occupancy", + DEV_SIG_ENT_MAP_ID: ( + "binary_sensor.lumi_lumi_sensor_motion_aq2_occupancy" + ), + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_motion_aq2_motion", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_motion_aq2_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_illuminance", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: ( + "sensor.lumi_lumi_sensor_motion_aq2_device_temperature" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_motion_aq2_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 58, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_smoke", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 12, 18, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_smoke_smoke", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_smoke_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: ( + "sensor.lumi_lumi_sensor_smoke_device_temperature" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_smoke_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_smoke_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 59, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [0, 4, 5, 6, 8, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_switch_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_switch_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 60, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq2", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 65535], + SIG_EP_OUTPUT: [0, 4, 6, 65535], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006"], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq2_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 61, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_switch.aq3", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 6, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 18], + SIG_EP_OUTPUT: [0, 6], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006"], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_switch_aq3_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 62, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.sensor_wleak.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 2, 3, 1280], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_ias_zone", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: ( + "sensor.lumi_lumi_sensor_wleak_aq1_device_temperature" + ), + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_sensor_wleak_aq1_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_wleak_aq1_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_sensor_wleak_aq1_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 63, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.vibration.aq1", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.DOOR_LOCK, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Ota.cluster_id, + DoorLock.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + Ota.cluster_id, + DoorLock.cluster_id, + ], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: 0x5F02, + INPUT_CLUSTERS: [Identify.cluster_id, MultistateInput.cluster_id], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + MultistateInput.cluster_id, + ], + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0019", "2:0x0005"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_vibration_aq1_vibration", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_vibration_aq1_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], + DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_vibration_aq1_device_temperature", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.lumi_lumi_vibration_aq1_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 64, + SIG_MANUFACTURER: "LUMI", + SIG_MODEL: "lumi.weather", + SIG_NODE_DESC: b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 24321, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 1026, 1027, 1029, 65535], + SIG_EP_OUTPUT: [0, 4, 65535], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.lumi_lumi_weather_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1027"): { + DEV_SIG_CLUSTER_HANDLERS: ["pressure"], + DEV_SIG_ENT_MAP_CLASS: "Pressure", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_pressure", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1029"): { + DEV_SIG_CLUSTER_HANDLERS: ["humidity"], + DEV_SIG_ENT_MAP_CLASS: "Humidity", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_weather_humidity", + }, + }, + }, + { + DEV_SIG_DEV_NO: 65, + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3010", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1280], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3010_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3010_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 66, + SIG_MANUFACTURER: "NYCE", + SIG_MODEL: "3014", + SIG_NODE_DESC: b"\x02@\x80\xb9\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1280], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.nyce_3014_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.nyce_3014_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 67, + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x10@\x0f5\x11Y=\x00@\x00=\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 5, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [10, 25], + SIG_EP_OUTPUT: [1280], + SIG_EP_PROFILE: 260, + }, + 242: { + SIG_EP_TYPE: 100, + DEV_SIG_EP_ID: 242, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [33], + SIG_EP_PROFILE: 41440, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: {}, + }, + { + DEV_SIG_DEV_NO: 68, + SIG_MANUFACTURER: None, + SIG_MODEL: None, + SIG_NODE_DESC: b"\x00@\x8f\xcd\xabR\x80\x00\x00\x00\x80\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 48879, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [], + SIG_EP_OUTPUT: [1280], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: {}, + }, + { + DEV_SIG_DEV_NO: 69, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY A19 RGBW", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + SIG_ENDPOINTS: { + 3: { + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_a19_rgbw_light", + }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_a19_rgbw_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_a19_rgbw_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_a19_rgbw_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 70, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Dimming Switch", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 2821], + SIG_EP_OUTPUT: [3, 6, 8, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0006", "1:0x0008", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_dimming_switch_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_dimming_switch_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_dimming_switch_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 71, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY Flex RGBW", + SIG_NODE_DESC: b"\x19@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + SIG_ENDPOINTS: { + 3: { + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_flex_rgbw_light", + }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_flex_rgbw_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_flex_rgbw_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_flex_rgbw_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 72, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "LIGHTIFY RT Tunable White", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + SIG_ENDPOINTS: { + 3: { + SIG_EP_TYPE: 258, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2820, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "light_color", "level"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.osram_lightify_rt_tunable_white_light", + }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_lightify_rt_tunable_white_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_power"), + }, + ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: ( + "sensor.osram_lightify_rt_tunable_white_apparent_power" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_current"), + }, + ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_voltage"), + }, + ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: ( + "sensor.osram_lightify_rt_tunable_white_ac_frequency" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-3-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: ( + "sensor.osram_lightify_rt_tunable_white_power_factor" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_lightify_rt_tunable_white_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_lightify_rt_tunable_white_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 73, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Plug 01", + SIG_NODE_DESC: b"\x01@\x8e\xaa\xbb@\x00\x00\x00\x00\x00\x00\x03", + SIG_ENDPOINTS: { + 3: { + SIG_EP_TYPE: 16, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 4096, 64527], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 49246, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["3:0x0019"], + DEV_SIG_ENT_MAP: { + ("switch", "00:11:22:33:44:55:66:77-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.osram_plug_01_switch", + }, + ("button", "00:11:22:33:44:55:66:77-3-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.osram_plug_01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-3-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_plug_01_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-3-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_plug_01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 74, + SIG_MANUFACTURER: "OSRAM", + SIG_MODEL: "Switch 4x-LIGHTIFY", + SIG_NODE_DESC: b"\x02@\x80\x0c\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 32, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 25, 768, 4096], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, + }, + 4: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, + }, + 5: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 5, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, + }, + 6: { + SIG_EP_TYPE: 2064, + DEV_SIG_EP_ID: 6, + SIG_EP_INPUT: [0, 4096, 64768], + SIG_EP_OUTPUT: [3, 4, 5, 6, 8, 768, 4096], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [ + "1:0x0005", + "1:0x0006", + "1:0x0008", + "1:0x0019", + "1:0x0300", + "2:0x0005", + "2:0x0006", + "2:0x0008", + "2:0x0300", + "3:0x0005", + "3:0x0006", + "3:0x0008", + "3:0x0300", + "4:0x0005", + "4:0x0006", + "4:0x0008", + "4:0x0300", + "5:0x0005", + "5:0x0006", + "5:0x0008", + "5:0x0300", + "6:0x0005", + "6:0x0006", + "6:0x0008", + "6:0x0300", + ], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.osram_switch_4x_lightify_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.osram_switch_4x_lightify_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 75, + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "RWL020", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2096, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0], + SIG_EP_OUTPUT: [0, 3, 4, 5, 6, 8], + SIG_EP_PROFILE: 49246, + }, + 2: { + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 1, 3, 15, 64512], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0008", "2:0x0019"], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-15"): { + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_rwl020_binary_input", + }, + ("button", "00:11:22:33:44:55:66:77-2-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.philips_rwl020_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_rwl020_battery", + }, + ("update", "00:11:22:33:44:55:66:77-2-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.philips_rwl020_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 76, + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "button", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.samjin_button_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_button_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_button_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 77, + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "multi", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 64514], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.samjin_multi_identify", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { + DEV_SIG_CLUSTER_HANDLERS: ["accelerometer"], + DEV_SIG_ENT_MAP_CLASS: "Accelerometer", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_accelerometer", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_multi_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_multi_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 78, + SIG_MANUFACTURER: "Samjin", + SIG_MODEL: "water", + SIG_NODE_DESC: b"\x02@\x80A\x12RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.samjin_water_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.samjin_water_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.samjin_water_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 79, + SIG_MANUFACTURER: "Securifi Ltd.", + SIG_MODEL: None, + SIG_NODE_DESC: b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 0, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 2820, 2821], + SIG_EP_OUTPUT: [0, 1, 3, 4, 5, 6, 25, 2820, 2821], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0005", "1:0x0006", "1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.securifi_ltd_unk_model_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_apparent_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.securifi_ltd_unk_model_switch", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.securifi_ltd_unk_model_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 80, + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-DWS04N_SF", + SIG_NODE_DESC: b"\x02@\x801\x11R\xff\x00\x00\x00\xff\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_dws04n_sf_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_dws04n_sf_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_dws04n_sf_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 81, + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-ESW01", + SIG_NODE_DESC: b"\x01@\x8e1\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 1794, 2820, 2821], + SIG_EP_OUTPUT: [3, 10, 25, 2821], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 259, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [0, 1, 3], + SIG_EP_OUTPUT: [3, 6], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019", "2:0x0006"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.sercomm_corp_sz_esw01_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_esw01_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_apparent_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_esw01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 82, + SIG_MANUFACTURER: "Sercomm Corp.", + SIG_MODEL: "SZ-PIR04", + SIG_NODE_DESC: b"\x02@\x801\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1024, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sercomm_corp_sz_pir04_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_illuminance", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_pir04_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sercomm_corp_sz_pir04_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 83, + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "RM3250ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00*+\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 2820, 2821, 65281], + SIG_EP_OUTPUT: [3, 4, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_rm3250zb_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: ( + "sensor.sinope_technologies_rm3250zb_apparent_power" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.sinope_technologies_rm3250zb_switch", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_rm3250zb_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 84, + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1123ZB", + SIG_NODE_DESC: b"\x12@\x8c\x9c\x11G+\x00\x00\x00+\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], + SIG_EP_OUTPUT: [25, 65281], + SIG_EP_PROFILE: 260, + }, + 196: { + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 196, + SIG_EP_INPUT: [1], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49757, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1123zb_identify", + }, + ("climate", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: [ + "thermostat", + "sinope_manufacturer_specific", + ], + DEV_SIG_ENT_MAP_CLASS: "SinopeTechnologiesThermostat", + DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1123zb_thermostat", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: ( + "sensor.sinope_technologies_th1123zb_apparent_power" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_hvac_action", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_setpoint_change_source", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_th1123zb_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 85, + SIG_MANUFACTURER: "Sinope Technologies", + SIG_MODEL: "TH1124ZB", + SIG_NODE_DESC: b"\x11@\x8e\x9c\x11G+\x00\x00\x00+\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 513, 516, 1026, 2820, 2821, 65281], + SIG_EP_OUTPUT: [25, 65281], + SIG_EP_PROFILE: 260, + }, + 196: { + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 196, + SIG_EP_INPUT: [1], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49757, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sinope_technologies_th1124zb_identify", + }, + ("climate", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: [ + "thermostat", + "sinope_manufacturer_specific", + ], + DEV_SIG_ENT_MAP_CLASS: "SinopeTechnologiesThermostat", + DEV_SIG_ENT_MAP_ID: "climate.sinope_technologies_th1124zb_thermostat", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: ( + "sensor.sinope_technologies_th1124zb_apparent_power" + ), + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SinopeHVACAction", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_hvac_action", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_setpoint_change_source", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sinope_technologies_th1124zb_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 86, + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "outletv4", + SIG_NODE_DESC: b"\x01@\x8e\n\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 9, 15, 2820], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_outletv4_binary_input", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_outletv4_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementApparentPower", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_apparent_power", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_current", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_voltage", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementFrequency", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_ac_frequency", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { + DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], + DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power_factor", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.smartthings_outletv4_switch", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.smartthings_outletv4_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 87, + SIG_MANUFACTURER: "SmartThings", + SIG_MODEL: "tagv4", + SIG_NODE_DESC: b"\x02@\x80\n\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 32768, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 15, 32], + SIG_EP_OUTPUT: [3, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("device_tracker", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "DeviceScannerEntity", + DEV_SIG_ENT_MAP_ID: "device_tracker.smartthings_tagv4_device_scanner", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-15"): { + DEV_SIG_CLUSTER_HANDLERS: ["binary_input"], + DEV_SIG_ENT_MAP_CLASS: "BinaryInput", + DEV_SIG_ENT_MAP_ID: "binary_sensor.smartthings_tagv4_binary_input", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.smartthings_tagv4_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_tagv4_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.smartthings_tagv4_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 88, + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS007Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 25], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss007z_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss007z_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss007z_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss007z_switch", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.third_reality_inc_3rss007z_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 89, + SIG_MANUFACTURER: "Third Reality, Inc", + SIG_MODEL: "3RSS008Z", + SIG_NODE_DESC: b"\x02@\x803\x12\x7fd\x00\x00,d\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 2, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 6, 25], + SIG_EP_OUTPUT: [1], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.third_reality_inc_3rss008z_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.third_reality_inc_3rss008z_lqi", + }, + ("switch", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.third_reality_inc_3rss008z_switch", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.third_reality_inc_3rss008z_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 90, + SIG_MANUFACTURER: "Visonic", + SIG_MODEL: "MCT-340 E", + SIG_NODE_DESC: b"\x02@\x80\x11\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 32, 1026, 1280, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.visonic_mct_340_e_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.visonic_mct_340_e_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.visonic_mct_340_e_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 91, + SIG_MANUFACTURER: "Zen Within", + SIG_MODEL: "Zen-01", + SIG_NODE_DESC: b"\x02@\x80X\x11R\x80\x00\x00\x00\x80\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 769, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 4, 5, 32, 513, 514, 516, 2821], + SIG_EP_OUTPUT: [10, 25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.zen_within_zen_01_identify", + }, + ("climate", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat", "fan"], + DEV_SIG_ENT_MAP_CLASS: "ZenWithinThermostat", + DEV_SIG_ENT_MAP_ID: "climate.zen_within_zen_01_thermostat", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_lqi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-hvac_action"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "ThermostatHVACAction", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_hvac_action", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-pi_heating_demand"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "PiHeatingDemand", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_pi_heating_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-513-setpoint_change_source"): { + DEV_SIG_CLUSTER_HANDLERS: ["thermostat"], + DEV_SIG_ENT_MAP_CLASS: "SetpointChangeSource", + DEV_SIG_ENT_MAP_ID: "sensor.zen_within_zen_01_setpoint_change_source", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.zen_within_zen_01_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 92, + SIG_MANUFACTURER: "_TYZB01_ns1ndbww", + SIG_MODEL: "TS0004", + SIG_NODE_DESC: b"\x01@\x8e\x02\x10R\x00\x02\x00,\x00\x02\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 4, 5, 6, 10], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + 2: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 2, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + 3: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 3, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + 4: { + SIG_EP_TYPE: 256, + DEV_SIG_EP_ID: 4, + SIG_EP_INPUT: [4, 5, 6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.tyzb01_ns1ndbww_ts0004_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.tyzb01_ns1ndbww_ts0004_lqi", + }, + ("light", "00:11:22:33:44:55:66:77-2"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_2", + }, + ("light", "00:11:22:33:44:55:66:77-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_3", + }, + ("light", "00:11:22:33:44:55:66:77-4"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Light", + DEV_SIG_ENT_MAP_ID: "light.tyzb01_ns1ndbww_ts0004_light_4", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.tyzb01_ns1ndbww_ts0004_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 93, + SIG_MANUFACTURER: "netvox", + SIG_MODEL: "Z308E3ED", + SIG_NODE_DESC: b"\x02@\x80\x9f\x10RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 1026, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 3, 21, 32, 1280, 2821], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { + DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], + DEV_SIG_ENT_MAP_CLASS: "IASZone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_ias_zone", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.netvox_z308e3ed_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.netvox_z308e3ed_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 94, + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E11-G13", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "MinTransitionLight", + DEV_SIG_ENT_MAP_ID: "light.sengled_e11_g13_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e11_g13_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e11_g13_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_e11_g13_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 95, + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "E12-N14", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level"], + DEV_SIG_ENT_MAP_CLASS: "MinTransitionLight", + DEV_SIG_ENT_MAP_ID: "light.sengled_e12_n14_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sengled_e12_n14_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_e12_n14_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_e12_n14_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 96, + SIG_MANUFACTURER: "sengled", + SIG_MODEL: "Z01-A19NAE26", + SIG_NODE_DESC: b"\x02@\x8c`\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 257, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 1794, 2821], + SIG_EP_OUTPUT: [25], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["1:0x0019"], + DEV_SIG_ENT_MAP: { + ("light", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off", "level", "light_color"], + DEV_SIG_ENT_MAP_CLASS: "MinTransitionLight", + DEV_SIG_ENT_MAP_ID: "light.sengled_z01_a19nae26_light", + }, + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.sengled_z01_a19nae26_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergyMetering", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_instantaneous_demand", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1794-summation_delivered"): { + DEV_SIG_CLUSTER_HANDLERS: ["smartenergy_metering"], + DEV_SIG_ENT_MAP_CLASS: "SmartEnergySummation", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_summation_delivered", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.sengled_z01_a19nae26_lqi", + }, + ("update", "00:11:22:33:44:55:66:77-1-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.sengled_z01_a19nae26_firmware", + }, + }, + }, + { + DEV_SIG_DEV_NO: 97, + SIG_MANUFACTURER: "unk_manufacturer", + SIG_MODEL: "unk_model", + SIG_NODE_DESC: b"\x01@\x8e\x10\x11RR\x00\x00\x00R\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 512, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 10, 21, 256, 64544, 64545], + SIG_EP_OUTPUT: [3, 64544], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.unk_manufacturer_unk_model_identify", + }, + ("cover", "00:11:22:33:44:55:66:77-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["level", "on_off", "shade"], + DEV_SIG_ENT_MAP_CLASS: "Shade", + DEV_SIG_ENT_MAP_ID: "cover.unk_manufacturer_unk_model_shade", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.unk_manufacturer_unk_model_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.unk_manufacturer_unk_model_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 98, + SIG_MANUFACTURER: "Digi", + SIG_MODEL: "XBee3", + SIG_NODE_DESC: b"\x01@\x8e\x1e\x10R\xff\x00\x00,\xff\x00\x00", + SIG_ENDPOINTS: { + 208: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 208, + SIG_EP_INPUT: [6, 12], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 209: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 209, + SIG_EP_INPUT: [6, 12], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 210: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 210, + SIG_EP_INPUT: [6, 12], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 211: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 211, + SIG_EP_INPUT: [6, 12], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 212: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 212, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 213: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 213, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 214: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 214, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 215: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 215, + SIG_EP_INPUT: [6, 12], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 216: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 216, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 217: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 217, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 218: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 218, + SIG_EP_INPUT: [6, 13], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 219: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 219, + SIG_EP_INPUT: [6, 13], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 220: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 220, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 221: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 221, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 222: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 222, + SIG_EP_INPUT: [6], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 49413, + }, + 232: { + SIG_EP_TYPE: 1, + DEV_SIG_EP_ID: 232, + SIG_EP_INPUT: [17, 146], + SIG_EP_OUTPUT: [8, 17], + SIG_EP_PROFILE: 49413, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: ["232:0x0008"], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-208-12"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input", + }, + ("switch", "00:11:22:33:44:55:66:77-208-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch", + }, + ("sensor", "00:11:22:33:44:55:66:77-209-12"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_2", + }, + ("switch", "00:11:22:33:44:55:66:77-209-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_2", + }, + ("sensor", "00:11:22:33:44:55:66:77-210-12"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_3", + }, + ("switch", "00:11:22:33:44:55:66:77-210-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_3", + }, + ("sensor", "00:11:22:33:44:55:66:77-211-12"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_4", + }, + ("switch", "00:11:22:33:44:55:66:77-211-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_4", + }, + ("switch", "00:11:22:33:44:55:66:77-212-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_5", + }, + ("switch", "00:11:22:33:44:55:66:77-213-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_6", + }, + ("switch", "00:11:22:33:44:55:66:77-214-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_7", + }, + ("sensor", "00:11:22:33:44:55:66:77-215-12"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_input"], + DEV_SIG_ENT_MAP_CLASS: "AnalogInput", + DEV_SIG_ENT_MAP_ID: "sensor.digi_xbee3_analog_input_5", + }, + ("switch", "00:11:22:33:44:55:66:77-215-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_8", + }, + ("switch", "00:11:22:33:44:55:66:77-216-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_9", + }, + ("switch", "00:11:22:33:44:55:66:77-217-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_10", + }, + ("number", "00:11:22:33:44:55:66:77-218-13"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "Number", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_number", + }, + ("switch", "00:11:22:33:44:55:66:77-218-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_11", + }, + ("switch", "00:11:22:33:44:55:66:77-219-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_12", + }, + ("number", "00:11:22:33:44:55:66:77-219-13"): { + DEV_SIG_CLUSTER_HANDLERS: ["analog_output"], + DEV_SIG_ENT_MAP_CLASS: "Number", + DEV_SIG_ENT_MAP_ID: "number.digi_xbee3_number_2", + }, + ("switch", "00:11:22:33:44:55:66:77-220-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_13", + }, + ("switch", "00:11:22:33:44:55:66:77-221-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_14", + }, + ("switch", "00:11:22:33:44:55:66:77-222-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Switch", + DEV_SIG_ENT_MAP_ID: "switch.digi_xbee3_switch_15", + }, + }, + }, + { + DEV_SIG_DEV_NO: 99, + SIG_MANUFACTURER: "efektalab.ru", + SIG_MODEL: "EFEKTA_PWS", + SIG_NODE_DESC: b"\x02@\x80\x00\x00P\xa0\x00\x00\x00\xa0\x00\x00", + SIG_ENDPOINTS: { + 1: { + SIG_EP_TYPE: 12, + DEV_SIG_EP_ID: 1, + SIG_EP_INPUT: [0, 1, 1026, 1032], + SIG_EP_OUTPUT: [], + SIG_EP_PROFILE: 260, + }, + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1032"): { + DEV_SIG_CLUSTER_HANDLERS: ["soil_moisture"], + DEV_SIG_ENT_MAP_CLASS: "SoilMoisture", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_soil_moisture", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_temperature", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.efektalab_ru_efekta_pws_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 100, + SIG_MANUFACTURER: "Konke", + SIG_MODEL: "3AFE170100510001", + SIG_NODE_DESC: b"\x02@\x80\x02\x10RR\x00\x00,R\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: 260, + DEVICE_TYPE: zha.DeviceType.ON_OFF_OUTPUT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + ], + } + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-1-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.konke_3afe170100510001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.konke_3afe170100510001_lqi", + }, + }, + }, + { + DEV_SIG_DEV_NO: 101, + SIG_MANUFACTURER: "Philips", + SIG_MODEL: "SML001", + SIG_NODE_DESC: b"\x02@\x80\x0b\x10Y?\x00\x00\x00?\x00\x00", + SIG_ENDPOINTS: { + 1: { + PROFILE_ID: zll.PROFILE_ID, + DEVICE_TYPE: zll.DeviceType.ON_OFF_SENSOR, + INPUT_CLUSTERS: [Basic.cluster_id], + OUTPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Color.cluster_id, + ], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + IlluminanceMeasurement.cluster_id, + TemperatureMeasurement.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Ota.cluster_id, + ], + }, + }, + DEV_SIG_ATTRIBUTES: { + 2: { + "basic": { + "trigger_indicator": Bool(False), + }, + "philips_occupancy": { + "sensitivity": uint8_t(1), + }, + } + }, + DEV_SIG_EVT_CLUSTER_HANDLERS: [ + "1:0x0005", + "1:0x0006", + "1:0x0008", + "1:0x0300", + "2:0x0019", + ], + DEV_SIG_ENT_MAP: { + ("button", "00:11:22:33:44:55:66:77-2-3"): { + DEV_SIG_CLUSTER_HANDLERS: ["identify"], + DEV_SIG_ENT_MAP_CLASS: "IdentifyButton", + DEV_SIG_ENT_MAP_ID: "button.philips_sml001_identify", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1"): { + DEV_SIG_CLUSTER_HANDLERS: ["power"], + DEV_SIG_ENT_MAP_CLASS: "Battery", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_battery", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "RSSISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_rssi", + }, + ("sensor", "00:11:22:33:44:55:66:77-1-0-lqi"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "LQISensor", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_lqi", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-1-6"): { + DEV_SIG_CLUSTER_HANDLERS: ["on_off"], + DEV_SIG_ENT_MAP_CLASS: "Motion", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_sml001_motion", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1024"): { + DEV_SIG_CLUSTER_HANDLERS: ["illuminance"], + DEV_SIG_ENT_MAP_CLASS: "Illuminance", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_illuminance", + }, + ("binary_sensor", "00:11:22:33:44:55:66:77-2-1030"): { + DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], + DEV_SIG_ENT_MAP_CLASS: "HueOccupancy", + DEV_SIG_ENT_MAP_ID: "binary_sensor.philips_sml001_occupancy", + }, + ("sensor", "00:11:22:33:44:55:66:77-2-1026"): { + DEV_SIG_CLUSTER_HANDLERS: ["temperature"], + DEV_SIG_ENT_MAP_CLASS: "Temperature", + DEV_SIG_ENT_MAP_ID: "sensor.philips_sml001_temperature", + }, + ("switch", "00:11:22:33:44:55:66:77-2-0-trigger_indicator"): { + DEV_SIG_CLUSTER_HANDLERS: ["basic"], + DEV_SIG_ENT_MAP_CLASS: "HueMotionTriggerIndicatorSwitch", + DEV_SIG_ENT_MAP_ID: "switch.philips_sml001_led_trigger_indicator", + }, + ("select", "00:11:22:33:44:55:66:77-2-1030-motion_sensitivity"): { + DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], + DEV_SIG_ENT_MAP_CLASS: "HueV1MotionSensitivity", + DEV_SIG_ENT_MAP_ID: "select.philips_sml001_motion_sensitivity", + }, + ("update", "00:11:22:33:44:55:66:77-2-25-firmware_update"): { + DEV_SIG_CLUSTER_HANDLERS: ["ota"], + DEV_SIG_ENT_MAP_CLASS: "FirmwareUpdateEntity", + DEV_SIG_ENT_MAP_ID: "update.philips_sml001_firmware", + }, + }, + }, +] diff --git a/zha/__init__.py b/zha/__init__.py index 68b75cc9..04324b55 100644 --- a/zha/__init__.py +++ b/zha/__init__.py @@ -1,292 +1 @@ -"""Support for Zigbee Home Automation devices.""" - -import asyncio -import contextlib -import copy -import logging -import re - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType -import voluptuous as vol -from zhaquirks import setup as setup_quirks -from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH -from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError - -from . import repairs, websocket_api -from .core import ZHAGateway -from .core.const import ( - BAUD_RATES, - CONF_BAUDRATE, - CONF_CUSTOM_QUIRKS_PATH, - CONF_DEVICE_CONFIG, - CONF_ENABLE_QUIRKS, - CONF_FLOW_CONTROL, - CONF_RADIO_TYPE, - CONF_USB_PATH, - CONF_ZIGPY, - DATA_ZHA, - DOMAIN, - PLATFORMS, - SIGNAL_ADD_ENTITIES, - RadioType, -) -from .core.device import get_device_automation_triggers -from .core.discovery import GROUP_PROBE -from .core.helpers import ZHAData, get_zha_data -from .radio_manager import ZhaRadioManager -from .repairs.network_settings_inconsistent import warn_on_inconsistent_network_settings -from .repairs.wrong_silabs_firmware import ( - AlreadyRunningEZSP, - warn_on_wrong_silabs_firmware, -) - -DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) -ZHA_CONFIG_SCHEMA = { - vol.Optional(CONF_BAUDRATE): cv.positive_int, - vol.Optional(CONF_DATABASE): cv.string, - vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema( - {cv.string: DEVICE_CONFIG_SCHEMA_ENTRY} - ), - vol.Optional(CONF_ENABLE_QUIRKS, default=True): cv.boolean, - vol.Optional(CONF_ZIGPY): dict, - vol.Optional(CONF_RADIO_TYPE): cv.enum(RadioType), - vol.Optional(CONF_USB_PATH): cv.string, - vol.Optional(CONF_CUSTOM_QUIRKS_PATH): cv.isdir, -} -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - vol.All( - cv.deprecated(CONF_USB_PATH), - cv.deprecated(CONF_BAUDRATE), - cv.deprecated(CONF_RADIO_TYPE), - ZHA_CONFIG_SCHEMA, - ), - ), - }, - extra=vol.ALLOW_EXTRA, -) - -# Zigbee definitions -CENTICELSIUS = "C-100" - -# Internal definitions -_LOGGER = logging.getLogger(__name__) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up ZHA from config.""" - zha_data = ZHAData() - zha_data.yaml_config = config.get(DOMAIN, {}) - hass.data[DATA_ZHA] = zha_data - - return True - - -def _clean_serial_port_path(path: str) -> str: - """Clean the serial port path, applying corrections where necessary.""" - - if path.startswith("socket://"): - path = path.strip() - - # Removes extraneous brackets from IP addresses (they don't parse in CPython 3.11.4) - if re.match(r"^socket://\[\d+\.\d+\.\d+\.\d+\]:\d+$", path): - path = path.replace("[", "").replace("]", "") - - return path - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Set up ZHA. - - Will automatically load components to support devices found on the network. - """ - - # Remove brackets around IP addresses, this no longer works in CPython 3.11.4 - # This will be removed in 2023.11.0 - path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - cleaned_path = _clean_serial_port_path(path) - data = copy.deepcopy(dict(config_entry.data)) - - if path != cleaned_path: - _LOGGER.debug("Cleaned serial port path %r -> %r", path, cleaned_path) - data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path - hass.config_entries.async_update_entry(config_entry, data=data) - - zha_data = get_zha_data(hass) - - if zha_data.yaml_config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks( - custom_quirks_path=zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) - ) - - # Load and cache device trigger information early - device_registry = dr.async_get(hass) - radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - - async with radio_mgr.connect_zigpy_app() as app: - for dev in app.devices.values(): - dev_entry = device_registry.async_get_device( - identifiers={(DOMAIN, str(dev.ieee))}, - connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))}, - ) - - if dev_entry is None: - continue - - zha_data.device_trigger_cache[dev_entry.id] = ( - str(dev.ieee), - get_device_automation_triggers(dev), - ) - - _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - - try: - zha_gateway = await ZHAGateway.async_from_config( - hass=hass, - config=zha_data.yaml_config, - config_entry=config_entry, - ) - except NetworkSettingsInconsistent as exc: - await warn_on_inconsistent_network_settings( - hass, - config_entry=config_entry, - old_state=exc.old_state, - new_state=exc.new_state, - ) - raise ConfigEntryError( - "Network settings do not match most recent backup" - ) from exc - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except Exception as exc: - _LOGGER.debug("Failed to set up ZHA", exc_info=exc) - device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] - - if ( - not device_path.startswith("socket://") - and RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp - ): - try: - # Ignore all exceptions during probing, they shouldn't halt setup - if await warn_on_wrong_silabs_firmware(hass, device_path): - raise ConfigEntryError("Incorrect firmware installed") from exc - except AlreadyRunningEZSP as ezsp_exc: - raise ConfigEntryNotReady from ezsp_exc - - raise ConfigEntryNotReady from exc - - repairs.async_delete_blocking_issues(hass) - - manufacturer = zha_gateway.state.node_info.manufacturer - model = zha_gateway.state.node_info.model - - if manufacturer is None and model is None: - manufacturer = "Unknown" - model = "Unknown" - - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.state.node_info.ieee))}, - identifiers={(DOMAIN, str(zha_gateway.state.node_info.ieee))}, - name="Zigbee Coordinator", - manufacturer=manufacturer, - model=model, - sw_version=zha_gateway.state.node_info.version, - ) - - websocket_api.async_load_api(hass) - - async def async_shutdown(_: Event) -> None: - await zha_gateway.shutdown() - - config_entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown) - ) - - await zha_gateway.async_initialize_devices_and_entities() - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) - return True - - -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Unload ZHA config entry.""" - zha_data = get_zha_data(hass) - - if zha_data.gateway is not None: - await zha_data.gateway.shutdown() - zha_data.gateway = None - - # clean up any remaining entity metadata - # (entities that have been discovered but not yet added to HA) - # suppress KeyError because we don't know what state we may - # be in when we get here in failure cases - with contextlib.suppress(KeyError): - for platform in PLATFORMS: - del zha_data.platforms[platform] - - GROUP_PROBE.cleanup() - websocket_api.async_unload_api(hass) - - # our components don't have unload methods so no need to look at return values - await asyncio.gather( - *( - hass.config_entries.async_forward_entry_unload(config_entry, platform) - for platform in PLATFORMS - ) - ) - - return True - - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) - - if config_entry.version == 1: - data = { - CONF_RADIO_TYPE: config_entry.data[CONF_RADIO_TYPE], - CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, - } - - baudrate = get_zha_data(hass).yaml_config.get(CONF_BAUDRATE) - if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: - data[CONF_DEVICE][CONF_BAUDRATE] = baudrate - - hass.config_entries.async_update_entry(config_entry, data=data, version=2) - - if config_entry.version == 2: - data = {**config_entry.data} - - if data[CONF_RADIO_TYPE] == "ti_cc": - data[CONF_RADIO_TYPE] = "znp" - - hass.config_entries.async_update_entry(config_entry, data=data, version=3) - - if config_entry.version == 3: - data = {**config_entry.data} - - if not data[CONF_DEVICE].get(CONF_BAUDRATE): - data[CONF_DEVICE][CONF_BAUDRATE] = { - "deconz": 38400, - "xbee": 57600, - "ezsp": 57600, - "znp": 115200, - "zigate": 115200, - }[data[CONF_RADIO_TYPE]] - - if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): - data[CONF_DEVICE][CONF_FLOW_CONTROL] = None - - hass.config_entries.async_update_entry(config_entry, data=data, version=4) - - _LOGGER.info("Migration to version %s successful", config_entry.version) - return True +"""Zigbee Home Automation.""" diff --git a/zha/application/__init__.py b/zha/application/__init__.py index e69de29b..bf2d34d8 100644 --- a/zha/application/__init__.py +++ b/zha/application/__init__.py @@ -0,0 +1,50 @@ +"""Application module for Zigbee Home Automation.""" + +from enum import StrEnum + + +class Platform(StrEnum): + """Available entity platforms.""" + + AIR_QUALITY = "air_quality" + ALARM_CONTROL_PANEL = "alarm_control_panel" + BINARY_SENSOR = "binary_sensor" + BUTTON = "button" + CALENDAR = "calendar" + CAMERA = "camera" + CLIMATE = "climate" + COVER = "cover" + DATE = "date" + DATETIME = "datetime" + DEVICE_TRACKER = "device_tracker" + EVENT = "event" + FAN = "fan" + GEO_LOCATION = "geo_location" + HUMIDIFIER = "humidifier" + IMAGE = "image" + IMAGE_PROCESSING = "image_processing" + LAWN_MOWER = "lawn_mower" + LIGHT = "light" + LOCK = "lock" + MAILBOX = "mailbox" + MEDIA_PLAYER = "media_player" + NOTIFY = "notify" + NUMBER = "number" + REMOTE = "remote" + SCENE = "scene" + SELECT = "select" + SENSOR = "sensor" + SIREN = "siren" + STT = "stt" + SWITCH = "switch" + TEXT = "text" + TIME = "time" + TODO = "todo" + TTS = "tts" + VACUUM = "vacuum" + VALVE = "valve" + UNKNOWN = "unknown" + UPDATE = "update" + WAKE_WORD = "wake_word" + WATER_HEATER = "water_heater" + WEATHER = "weather" diff --git a/zha/application/const.py b/zha/application/const.py index e927f615..6d39c0c8 100644 --- a/zha/application/const.py +++ b/zha/application/const.py @@ -4,11 +4,12 @@ import enum import logging +from numbers import Number +from typing import Any, Final import bellows.zigbee.application -from homeassistant.const import Platform -import homeassistant.helpers.config_validation as cv import voluptuous as vol +from voluptuous.schema_builder import _compile_scalar import zigpy.application import zigpy.types as t import zigpy_deconz.zigbee.application @@ -24,6 +25,7 @@ ATTR_AVAILABLE = "available" ATTR_CLUSTER_ID = "cluster_id" ATTR_CLUSTER_TYPE = "cluster_type" +ATTR_COMMAND: Final = "command" ATTR_COMMAND_TYPE = "command_type" ATTR_DEVICE_IEEE = "device_ieee" ATTR_DEVICE_TYPE = "device_type" @@ -39,6 +41,7 @@ ATTR_MANUFACTURER_CODE = "manufacturer_code" ATTR_MEMBERS = "members" ATTR_MODEL = "model" +ATTR_NAME = "name" ATTR_NEIGHBORS = "neighbors" ATTR_NODE_DESCRIPTOR = "node_descriptor" ATTR_NWK = "nwk" @@ -61,71 +64,19 @@ ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE = "duty_cycle" ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" +# Class of device within its domain +ATTR_DEVICE_CLASS: Final = "device_class" + BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] -BINDINGS = "bindings" CLUSTER_DETAILS = "cluster_details" -CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" -CLUSTER_HANDLER_BINARY_INPUT = "binary_input" -CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" -CLUSTER_HANDLER_ANALOG_OUTPUT = "analog_output" -CLUSTER_HANDLER_ATTRIBUTE = "attribute" -CLUSTER_HANDLER_BASIC = "basic" -CLUSTER_HANDLER_COLOR = "light_color" -CLUSTER_HANDLER_COVER = "window_covering" -CLUSTER_HANDLER_DEVICE_TEMPERATURE = "device_temperature" -CLUSTER_HANDLER_DOORLOCK = "door_lock" -CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement" -CLUSTER_HANDLER_EVENT_RELAY = "event_relay" -CLUSTER_HANDLER_FAN = "fan" -CLUSTER_HANDLER_HUMIDITY = "humidity" -CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy" -CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture" -CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness" -CLUSTER_HANDLER_IAS_ACE = "ias_ace" -CLUSTER_HANDLER_IAS_WD = "ias_wd" -CLUSTER_HANDLER_IDENTIFY = "identify" -CLUSTER_HANDLER_ILLUMINANCE = "illuminance" -CLUSTER_HANDLER_LEVEL = ATTR_LEVEL -CLUSTER_HANDLER_MULTISTATE_INPUT = "multistate_input" -CLUSTER_HANDLER_OCCUPANCY = "occupancy" -CLUSTER_HANDLER_ON_OFF = "on_off" -CLUSTER_HANDLER_OTA = "ota" -CLUSTER_HANDLER_POWER_CONFIGURATION = "power" -CLUSTER_HANDLER_PRESSURE = "pressure" -CLUSTER_HANDLER_SHADE = "shade" -CLUSTER_HANDLER_SMARTENERGY_METERING = "smartenergy_metering" -CLUSTER_HANDLER_TEMPERATURE = "temperature" -CLUSTER_HANDLER_THERMOSTAT = "thermostat" -CLUSTER_HANDLER_ZDO = "zdo" -CLUSTER_HANDLER_ZONE = ZONE = "ias_zone" -CLUSTER_HANDLER_INOVELLI = "inovelli_vzm31sn_cluster" - CLUSTER_COMMAND_SERVER = "server" CLUSTER_COMMANDS_CLIENT = "client_commands" CLUSTER_COMMANDS_SERVER = "server_commands" CLUSTER_TYPE_IN = "in" CLUSTER_TYPE_OUT = "out" -PLATFORMS = ( - Platform.ALARM_CONTROL_PANEL, - Platform.BINARY_SENSOR, - Platform.BUTTON, - Platform.CLIMATE, - Platform.COVER, - Platform.DEVICE_TRACKER, - Platform.FAN, - Platform.LIGHT, - Platform.LOCK, - Platform.NUMBER, - Platform.SELECT, - Platform.SENSOR, - Platform.SIREN, - Platform.SWITCH, - Platform.UPDATE, -) - CONF_ALARM_MASTER_CODE = "alarm_master_code" CONF_ALARM_FAILED_TRIES = "alarm_failed_tries" CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" @@ -151,32 +102,80 @@ CONF_CONSIDER_UNAVAILABLE_BATTERY = "consider_unavailable_battery" CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours + +def boolean(value: Any) -> bool: + """Validate and coerce a boolean value.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + value = value.lower().strip() + if value in ("1", "true", "yes", "on", "enable"): + return True + if value in ("0", "false", "no", "off", "disable"): + return False + elif isinstance(value, Number): + # type ignore: https://github.com/python/mypy/issues/3186 + return value != 0 # type: ignore[comparison-overlap] + raise vol.Invalid(f"invalid boolean value {value}") + + +positive_int = vol.All(vol.Coerce(int), vol.Range(min=0)) + + +def string(value: Any) -> str: + """Coerce value to string, except for None.""" + if value is None: + raise vol.Invalid("string value is None") + + # This is expected to be the most common case, so check it first. + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): + return value + + elif isinstance(value, (list, dict)): + raise vol.Invalid("value should be a string") + + return str(value) + + +class NodeStrClass(str): + """Wrapper class to be able to add attributes on a string.""" + + def __voluptuous_compile__(self, schema: vol.Schema) -> Any: # pylint: disable=unused-argument + """Needed because vol.Schema.compile does not handle str subclasses.""" + return _compile_scalar(self) + + +# TODO make these dataclasses CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( { vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All( vol.Coerce(float), vol.Range(min=0, max=2**16 / 10) ), - vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean, - vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean, - vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean, - vol.Required(CONF_GROUP_MEMBERS_ASSUME_STATE, default=True): cv.boolean, - vol.Required(CONF_ENABLE_IDENTIFY_ON_JOIN, default=True): cv.boolean, + vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): boolean, + vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): boolean, + vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): boolean, + vol.Required(CONF_GROUP_MEMBERS_ASSUME_STATE, default=True): boolean, + vol.Required(CONF_ENABLE_IDENTIFY_ON_JOIN, default=True): boolean, vol.Optional( CONF_CONSIDER_UNAVAILABLE_MAINS, default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, - ): cv.positive_int, + ): positive_int, vol.Optional( CONF_CONSIDER_UNAVAILABLE_BATTERY, default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, - ): cv.positive_int, + ): positive_int, } ) CONF_ZHA_ALARM_SCHEMA = vol.Schema( { - vol.Required(CONF_ALARM_MASTER_CODE, default="1234"): cv.string, - vol.Required(CONF_ALARM_FAILED_TRIES, default=3): cv.positive_int, - vol.Required(CONF_ALARM_ARM_REQUIRES_CODE, default=False): cv.boolean, + vol.Required(CONF_ALARM_MASTER_CODE, default="1234"): string, + vol.Required(CONF_ALARM_FAILED_TRIES, default=3): positive_int, + vol.Required(CONF_ALARM_ARM_REQUIRES_CODE, default=False): boolean, } ) @@ -228,22 +227,23 @@ POWER_MAINS_POWERED = "Mains" POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown" +# Device is in away mode +PRESET_AWAY = "away" PRESET_SCHEDULE = "Schedule" PRESET_COMPLEX = "Complex" PRESET_TEMP_MANUAL = "Temporary manual" +# Device turn all valve full up +PRESET_BOOST = "boost" +# No preset is active +PRESET_NONE = "none" -QUIRK_METADATA = "quirk_metadata" +ENTITY_METADATA = "entity_metadata" ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS" ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_OPTIONS = "zha_options" -ZHA_CONFIG_SCHEMAS = { - ZHA_OPTIONS: CONF_ZHA_OPTIONS_SCHEMA, - ZHA_ALARM_OPTIONS: CONF_ZHA_ALARM_SCHEMA, -} - _ControllerClsType = type[zigpy.application.ControllerApplication] @@ -300,65 +300,6 @@ def description(self) -> str: return self._desc -REPORT_CONFIG_ATTR_PER_REQ = 3 -REPORT_CONFIG_MAX_INT = 900 -REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 -REPORT_CONFIG_MIN_INT = 30 -REPORT_CONFIG_MIN_INT_ASAP = 1 -REPORT_CONFIG_MIN_INT_IMMEDIATE = 0 -REPORT_CONFIG_MIN_INT_OP = 5 -REPORT_CONFIG_MIN_INT_BATTERY_SAVE = 3600 -REPORT_CONFIG_RPT_CHANGE = 1 -REPORT_CONFIG_DEFAULT = ( - REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE, -) -REPORT_CONFIG_ASAP = ( - REPORT_CONFIG_MIN_INT_ASAP, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE, -) -REPORT_CONFIG_BATTERY_SAVE = ( - REPORT_CONFIG_MIN_INT_BATTERY_SAVE, - REPORT_CONFIG_MAX_INT_BATTERY_SAVE, - REPORT_CONFIG_RPT_CHANGE, -) -REPORT_CONFIG_IMMEDIATE = ( - REPORT_CONFIG_MIN_INT_IMMEDIATE, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE, -) -REPORT_CONFIG_OP = ( - REPORT_CONFIG_MIN_INT_OP, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE, -) - -SENSOR_ACCELERATION = "acceleration" -SENSOR_BATTERY = "battery" -SENSOR_ELECTRICAL_MEASUREMENT = CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT -SENSOR_GENERIC = "generic" -SENSOR_HUMIDITY = CLUSTER_HANDLER_HUMIDITY -SENSOR_ILLUMINANCE = CLUSTER_HANDLER_ILLUMINANCE -SENSOR_METERING = "metering" -SENSOR_OCCUPANCY = CLUSTER_HANDLER_OCCUPANCY -SENSOR_OPENING = "opening" -SENSOR_PRESSURE = CLUSTER_HANDLER_PRESSURE -SENSOR_TEMPERATURE = CLUSTER_HANDLER_TEMPERATURE -SENSOR_TYPE = "sensor_type" - -SIGNAL_ADD_ENTITIES = "zha_add_new_entities" -SIGNAL_ATTR_UPDATED = "attribute_updated" -SIGNAL_AVAILABLE = "available" -SIGNAL_MOVE_LEVEL = "move_level" -SIGNAL_REMOVE = "remove" -SIGNAL_SET_LEVEL = "set_level" -SIGNAL_STATE_ATTR = "update_state_attribute" -SIGNAL_UPDATE_DEVICE = "{}_zha_update_device" -SIGNAL_GROUP_ENTITY_REMOVED = "group_entity_removed" -SIGNAL_GROUP_MEMBERSHIP_CHANGE = "group_membership_change" - UNKNOWN = "unknown" UNKNOWN_MANUFACTURER = "unk_manufacturer" UNKNOWN_MODEL = "unk_model" @@ -415,8 +356,3 @@ class Strobe(t.enum8): No_Strobe = 0x00 Strobe = 0x01 - - -EZSP_OVERWRITE_EUI64 = ( - "i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it" -) diff --git a/zha/application/decorators.py b/zha/application/decorators.py deleted file mode 100644 index b8e15024..00000000 --- a/zha/application/decorators.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Decorators for ZHA core registries.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any, TypeVar - -_TypeT = TypeVar("_TypeT", bound=type[Any]) - - -class DictRegistry(dict[int | str, _TypeT]): - """Dict Registry of items.""" - - def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: - """Return decorator to register item with a specific name.""" - - def decorator(cluster_handler: _TypeT) -> _TypeT: - """Register decorated cluster handler or item.""" - self[name] = cluster_handler - return cluster_handler - - return decorator - - -class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): - """Dict Registry of multiple items per key.""" - - def register( - self, name: int | str, sub_name: int | str | None = None - ) -> Callable[[_TypeT], _TypeT]: - """Return decorator to register item with a specific and a quirk name.""" - - def decorator(cluster_handler: _TypeT) -> _TypeT: - """Register decorated cluster handler or item.""" - if name not in self: - self[name] = {} - self[name][sub_name] = cluster_handler - return cluster_handler - - return decorator - - -class SetRegistry(set[int | str]): - """Set Registry of items.""" - - def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: - """Return decorator to register item with a specific name.""" - - def decorator(cluster_handler: _TypeT) -> _TypeT: - """Register decorated cluster handler or item.""" - self.add(name) - return cluster_handler - - return decorator diff --git a/zha/application/discovery.py b/zha/application/discovery.py index 33b1cb14..290d20b1 100644 --- a/zha/application/discovery.py +++ b/zha/application/discovery.py @@ -3,20 +3,9 @@ from __future__ import annotations from collections import Counter -from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any, cast -from homeassistant.const import CONF_TYPE, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.helpers.typing import ConfigType from slugify import slugify from zigpy.quirks.v2 import ( BinarySensorMetadata, @@ -33,7 +22,8 @@ from zigpy.zcl import ClusterType from zigpy.zcl.clusters.general import Ota -from .. import ( # noqa: F401 +from zha.application import Platform, const as zha_const +from zha.application.platforms import ( # noqa: F401 pylint: disable=unused-import alarm_control_panel, binary_sensor, button, @@ -50,10 +40,16 @@ switch, update, ) -from . import const as zha_const, registries as zha_regs +from zha.application.registries import ( + DEVICE_CLASS, + PLATFORM_ENTITIES, + REMOTE_DEVICE_TYPES, + SINGLE_INPUT_CLUSTER_DEVICE_CLASS, + SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, +) # importing cluster handlers updates registries -from .cluster_handlers import ( # noqa: F401 +from zha.zigbee.cluster_handlers import ( # noqa: F401 pylint: disable=unused-import ClusterHandler, closures, general, @@ -67,29 +63,61 @@ security, smartenergy, ) -from .helpers import get_zha_data, get_zha_gateway +from zha.zigbee.cluster_handlers.registries import ( + CLUSTER_HANDLER_ONLY_CLUSTERS, + CLUSTER_HANDLER_REGISTRY, +) +from zha.zigbee.group import Group if TYPE_CHECKING: - from ..application.entity import ZhaEntity - from .device import ZHADevice - from .endpoint import Endpoint - from .group import ZHAGroup + from zha.application.gateway import Gateway + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint _LOGGER = logging.getLogger(__name__) +PLATFORMS = ( + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.FAN, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.UPDATE, +) + +GROUP_PLATFORMS = ( + Platform.FAN, + Platform.LIGHT, + Platform.SWITCH, +) QUIRKS_ENTITY_META_TO_ENTITY_CLASS = { ( Platform.BUTTON, WriteAttributeButtonMetadata, EntityType.CONFIG, - ): button.ZHAAttributeButton, - (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton, + ): button.WriteAttributeButton, + ( + Platform.BUTTON, + WriteAttributeButtonMetadata, + EntityType.STANDARD, + ): button.WriteAttributeButton, + (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.Button, ( Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.DIAGNOSTIC, - ): button.ZHAButton, + ): button.Button, + (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.STANDARD): button.Button, ( Platform.BINARY_SENSOR, BinarySensorMetadata, @@ -110,6 +138,7 @@ (Platform.SENSOR, ZCLSensorMetadata, EntityType.DIAGNOSTIC): sensor.Sensor, (Platform.SENSOR, ZCLSensorMetadata, EntityType.STANDARD): sensor.Sensor, (Platform.SELECT, ZCLEnumMetadata, EntityType.CONFIG): select.ZCLEnumSelectEntity, + (Platform.SELECT, ZCLEnumMetadata, EntityType.STANDARD): select.ZCLEnumSelectEntity, ( Platform.SELECT, ZCLEnumMetadata, @@ -119,66 +148,30 @@ Platform.NUMBER, NumberMetadata, EntityType.CONFIG, - ): number.ZHANumberConfigurationEntity, - (Platform.NUMBER, NumberMetadata, EntityType.DIAGNOSTIC): number.ZhaNumber, - (Platform.NUMBER, NumberMetadata, EntityType.STANDARD): number.ZhaNumber, + ): number.NumberConfigurationEntity, + (Platform.NUMBER, NumberMetadata, EntityType.DIAGNOSTIC): number.Number, + (Platform.NUMBER, NumberMetadata, EntityType.STANDARD): number.Number, ( Platform.SWITCH, SwitchMetadata, EntityType.CONFIG, - ): switch.ZHASwitchConfigurationEntity, + ): switch.SwitchConfigurationEntity, (Platform.SWITCH, SwitchMetadata, EntityType.STANDARD): switch.Switch, } -@callback -async def async_add_entities( - _async_add_entities: AddEntitiesCallback, - entities: list[ - tuple[ - type[ZhaEntity], - tuple[str, ZHADevice, list[ClusterHandler]], - dict[str, Any], - ] - ], - **kwargs, -) -> None: - """Add entities helper.""" - if not entities: - return - - to_add = [ - ent_cls.create_entity(*args, **{**kwargs, **kw_args}) - for ent_cls, args, kw_args in entities - ] - entities_to_add = [entity for entity in to_add if entity is not None] - _async_add_entities(entities_to_add, update_before_add=False) - entities.clear() - - -class ProbeEndpoint: - """All discovered cluster handlers and entities of an endpoint.""" +class DeviceProbe: + """Probe to discover entities for a device.""" def __init__(self) -> None: """Initialize instance.""" - self._device_configs: ConfigType = {} + self._gateway: Gateway - @callback - def discover_entities(self, endpoint: Endpoint) -> None: - """Process an endpoint on a zigpy device.""" - _LOGGER.debug( - "Discovering entities for endpoint: %s-%s", - str(endpoint.device.ieee), - endpoint.id, - ) - self.discover_by_device_type(endpoint) - self.discover_multi_entities(endpoint) - self.discover_by_cluster_id(endpoint) - self.discover_multi_entities(endpoint, config_diagnostic_entities=True) - zha_regs.ZHA_ENTITIES.clean_up() + def initialize(self, gateway: Gateway) -> None: + """Initialize the group probe.""" + self._gateway = gateway - @callback - def discover_device_entities(self, device: ZHADevice) -> None: + def discover_device_entities(self, device: Device) -> None: """Discover entities for a ZHA device.""" _LOGGER.debug( "Discovering entities for device: %s-%s", @@ -191,10 +184,9 @@ def discover_device_entities(self, device: ZHADevice) -> None: return self.discover_quirks_v2_entities(device) - zha_regs.ZHA_ENTITIES.clean_up() + PLATFORM_ENTITIES.clean_up() - @callback - def discover_quirks_v2_entities(self, device: ZHADevice) -> None: + def discover_quirks_v2_entities(self, device: Device) -> None: """Discover entities for a ZHA device exposed by quirks v2.""" _LOGGER.debug( "Attempting to discover quirks v2 entities for device: %s-%s", @@ -223,7 +215,7 @@ def discover_quirks_v2_entities(self, device: ZHADevice) -> None: for ( cluster_details, - quirk_metadata_list, + entity_metadata_list, ) in zigpy_device.exposes_metadata.items(): endpoint_id, cluster_id, cluster_type = cluster_details @@ -264,11 +256,11 @@ def discover_quirks_v2_entities(self, device: ZHADevice) -> None: ) assert cluster_handler - for quirk_metadata in quirk_metadata_list: - platform = Platform(quirk_metadata.entity_platform.value) - metadata_type = type(quirk_metadata.entity_metadata) + for entity_metadata in entity_metadata_list: + platform = Platform(entity_metadata.entity_platform.value) + metadata_type = type(entity_metadata) entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get( - (platform, metadata_type, quirk_metadata.entity_type) + (platform, metadata_type, entity_metadata.entity_type) ) if entity_class is None: @@ -279,7 +271,7 @@ def discover_quirks_v2_entities(self, device: ZHADevice) -> None: device.name, { zha_const.CLUSTER_DETAILS: cluster_details, - zha_const.QUIRK_METADATA: quirk_metadata, + zha_const.ENTITY_METADATA: entity_metadata, }, ) continue @@ -287,13 +279,13 @@ def discover_quirks_v2_entities(self, device: ZHADevice) -> None: # automatically add the attribute to ZCL_INIT_ATTRS for the cluster # handler if it is not already in the list if ( - hasattr(quirk_metadata.entity_metadata, "attribute_name") - and quirk_metadata.entity_metadata.attribute_name + hasattr(entity_metadata, "attribute_name") + and entity_metadata.attribute_name not in cluster_handler.ZCL_INIT_ATTRS ): init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy() - init_attrs[quirk_metadata.entity_metadata.attribute_name] = ( - quirk_metadata.attribute_initialized_from_cache + init_attrs[entity_metadata.attribute_name] = ( + entity_metadata.attribute_initialized_from_cache ) cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs @@ -302,7 +294,7 @@ def discover_quirks_v2_entities(self, device: ZHADevice) -> None: entity_class, endpoint.unique_id, [cluster_handler], - quirk_metadata=quirk_metadata, + entity_metadata=entity_metadata, ) _LOGGER.debug( @@ -312,8 +304,7 @@ def discover_quirks_v2_entities(self, device: ZHADevice) -> None: [cluster_handler.name], ) - @callback - def discover_coordinator_device_entities(self, device: ZHADevice) -> None: + def discover_coordinator_device_entities(self, device: Device) -> None: """Discover entities for the coordinator device.""" _LOGGER.debug( "Discovering entities for coordinator device: %s-%s", @@ -321,9 +312,8 @@ def discover_coordinator_device_entities(self, device: ZHADevice) -> None: device.name, ) state: State = device.gateway.application_controller.state - platforms: dict[Platform, list] = get_zha_data(device.hass).platforms + platforms: dict[Platform, list] = self._gateway.config.platforms - @callback def process_counters(counter_groups: str) -> None: for counter_group, counters in getattr(state, counter_groups).items(): for counter in counters: @@ -352,23 +342,43 @@ def process_counters(counter_groups: str) -> None: process_counters("device_counters") process_counters("group_counters") - @callback + +class EndpointProbe: + """All discovered cluster handlers and entities of an endpoint.""" + + def __init__(self) -> None: + """Initialize instance.""" + self._device_configs: dict[str, Any] = {} + + def discover_entities(self, endpoint: Endpoint) -> None: + """Process an endpoint on a zigpy device.""" + _LOGGER.debug( + "Discovering entities for endpoint: %s-%s", + str(endpoint.device.ieee), + endpoint.id, + ) + self.discover_by_device_type(endpoint) + self.discover_multi_entities(endpoint) + self.discover_by_cluster_id(endpoint) + self.discover_multi_entities(endpoint, config_diagnostic_entities=True) + PLATFORM_ENTITIES.clean_up() + def discover_by_device_type(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" unique_id = endpoint.unique_id - platform: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) + platform: str | None = self._device_configs.get(unique_id, {}).get("type") if platform is None: ep_profile_id = endpoint.zigpy_endpoint.profile_id ep_device_type = endpoint.zigpy_endpoint.device_type - platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + platform = DEVICE_CLASS[ep_profile_id].get(ep_device_type) - if platform and platform in zha_const.PLATFORMS: + if platform and platform in PLATFORMS: platform = cast(Platform, platform) cluster_handlers = endpoint.unclaimed_cluster_handlers() - platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + platform_entity_class, claimed = PLATFORM_ENTITIES.get_entity( platform, endpoint.device.manufacturer, endpoint.device.model, @@ -382,11 +392,10 @@ def discover_by_device_type(self, endpoint: Endpoint) -> None: platform, platform_entity_class, unique_id, claimed ) - @callback def discover_by_cluster_id(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" - items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() + items = SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() single_input_clusters = { cluster_class: match for cluster_class, match in items @@ -394,14 +403,11 @@ def discover_by_cluster_id(self, endpoint: Endpoint) -> None: } remaining_cluster_handlers = endpoint.unclaimed_cluster_handlers() for cluster_handler in remaining_cluster_handlers: - if ( - cluster_handler.cluster.cluster_id - in zha_regs.CLUSTER_HANDLER_ONLY_CLUSTERS - ): + if cluster_handler.cluster.cluster_id in CLUSTER_HANDLER_ONLY_CLUSTERS: endpoint.claim_cluster_handlers([cluster_handler]) continue - platform = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( + platform = SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( cluster_handler.cluster.cluster_id ) if platform is None: @@ -422,12 +428,12 @@ def probe_single_cluster( endpoint: Endpoint, ) -> None: """Probe specified cluster for specific component.""" - if platform is None or platform not in zha_const.PLATFORMS: + if platform is None or platform not in PLATFORMS: return cluster_handler_list = [cluster_handler] unique_id = f"{endpoint.unique_id}-{cluster_handler.cluster.cluster_id}" - entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + entity_class, claimed = PLATFORM_ENTITIES.get_entity( platform, endpoint.device.manufacturer, endpoint.device.model, @@ -444,17 +450,15 @@ def handle_on_off_output_cluster_exception(self, endpoint: Endpoint) -> None: profile_id = endpoint.zigpy_endpoint.profile_id device_type = endpoint.zigpy_endpoint.device_type - if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []): + if device_type in REMOTE_DEVICE_TYPES.get(profile_id, []): return for cluster_id, cluster in endpoint.zigpy_endpoint.out_clusters.items(): - platform = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( - cluster.cluster_id - ) + platform = SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get(cluster.cluster_id) if platform is None: continue - cluster_handler_classes = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_handler_classes = CLUSTER_HANDLER_REGISTRY.get( cluster_id, {None: ClusterHandler} ) @@ -472,7 +476,6 @@ def handle_on_off_output_cluster_exception(self, endpoint: Endpoint) -> None: self.probe_single_cluster(platform, cluster_handler, endpoint) @staticmethod - @callback def discover_multi_entities( endpoint: Endpoint, config_diagnostic_entities: bool = False, @@ -481,7 +484,7 @@ def discover_multi_entities( ep_profile_id = endpoint.zigpy_endpoint.profile_id ep_device_type = endpoint.zigpy_endpoint.device_type - cmpt_by_dev_type = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + cmpt_by_dev_type = DEVICE_CLASS[ep_profile_id].get(ep_device_type) if config_diagnostic_entities: cluster_handlers = list(endpoint.all_cluster_handlers.values()) @@ -490,14 +493,14 @@ def discover_multi_entities( cluster_handlers.append( endpoint.client_cluster_handlers[ota_handler_id] ) - matches, claimed = zha_regs.ZHA_ENTITIES.get_config_diagnostic_entity( + matches, claimed = PLATFORM_ENTITIES.get_config_diagnostic_entity( endpoint.device.manufacturer, endpoint.device.model, cluster_handlers, endpoint.device.quirk_id, ) else: - matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( + matches, claimed = PLATFORM_ENTITIES.get_multi_entity( endpoint.device.manufacturer, endpoint.device.model, endpoint.unclaimed_cluster_handlers(), @@ -533,9 +536,9 @@ def discover_multi_entities( entity_and_handler.claimed_cluster_handlers, ) - def initialize(self, hass: HomeAssistant) -> None: + def initialize(self, gateway: Gateway) -> None: """Update device overrides config.""" - zha_config = get_zha_data(hass).yaml_config + zha_config = gateway.config.yaml_config if overrides := zha_config.get(zha_const.CONF_DEVICE_CONFIG): self._device_configs.update(overrides) @@ -543,37 +546,15 @@ def initialize(self, hass: HomeAssistant) -> None: class GroupProbe: """Determine the appropriate component for a group.""" - _hass: HomeAssistant - def __init__(self) -> None: """Initialize instance.""" - self._unsubs: list[Callable[[], None]] = [] + self._gateway: Gateway - def initialize(self, hass: HomeAssistant) -> None: + def initialize(self, gateway: Gateway) -> None: """Initialize the group probe.""" - self._hass = hass - self._unsubs.append( - async_dispatcher_connect( - hass, zha_const.SIGNAL_GROUP_ENTITY_REMOVED, self._reprobe_group - ) - ) + self._gateway = gateway - def cleanup(self) -> None: - """Clean up on when ZHA shuts down.""" - for unsub in self._unsubs[:]: - unsub() - self._unsubs.remove(unsub) - - @callback - def _reprobe_group(self, group_id: int) -> None: - """Reprobe a group for entities after its members change.""" - zha_gateway = get_zha_gateway(self._hass) - if (zha_group := zha_gateway.groups.get(group_id)) is None: - return - self.discover_group_entities(zha_group) - - @callback - def discover_group_entities(self, group: ZHAGroup) -> None: + def discover_group_entities(self, group: Group) -> None: """Process a group and create any entities that are needed.""" # only create a group entity if there are 2 or more members in a group if len(group.members) < 2: @@ -582,72 +563,55 @@ def discover_group_entities(self, group: ZHAGroup) -> None: group.name, group.group_id, ) + group.group_entities.clear() return - entity_domains = GroupProbe.determine_entity_domains(self._hass, group) + assert self._gateway + entity_platforms = GroupProbe.determine_entity_platforms(group) - if not entity_domains: + if not entity_platforms: + _LOGGER.info("No entity platforms discovered for group %s", group.name) return - zha_data = get_zha_data(self._hass) - zha_gateway = get_zha_gateway(self._hass) - - for domain in entity_domains: - entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain) + for platform in entity_platforms: + entity_class = PLATFORM_ENTITIES.get_group_entity(platform) if entity_class is None: continue - zha_data.platforms[domain].append( - ( - entity_class, - ( - group.get_domain_entity_ids(domain), - f"{domain}_zha_group_0x{group.group_id:04x}", - group.group_id, - zha_gateway.coordinator_zha_device, - ), - {}, - ) - ) - async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) + _LOGGER.info("Creating entity : %s for group %s", entity_class, group.name) + entity_class(group) @staticmethod - def determine_entity_domains( - hass: HomeAssistant, group: ZHAGroup - ) -> list[Platform]: - """Determine the entity domains for this group.""" - entity_registry = er.async_get(hass) - + def determine_entity_platforms(group: Group) -> list[Platform]: + """Determine the entity platforms for this group.""" entity_domains: list[Platform] = [] - all_domain_occurrences: list[Platform] = [] - + all_platform_occurrences = [] for member in group.members: if member.device.is_coordinator: continue - entities = async_entries_for_device( - entity_registry, - member.device.device_id, - include_disabled_entities=True, - ) - all_domain_occurrences.extend( + entities = member.associated_entities + all_platform_occurrences.extend( [ - cast(Platform, entity.domain) + entity.PLATFORM for entity in entities - if entity.domain in zha_regs.GROUP_ENTITY_DOMAINS + if entity.PLATFORM in GROUP_PLATFORMS ] ) - if not all_domain_occurrences: + if not all_platform_occurrences: return entity_domains - # get all domains we care about if there are more than 2 entities of this domain - counts = Counter(all_domain_occurrences) - entity_domains = [domain[0] for domain in counts.items() if domain[1] >= 2] + # get all platforms we care about if there are more than 2 entities of this platform + counts = Counter(all_platform_occurrences) + entity_platforms = [ + platform[0] for platform in counts.items() if platform[1] >= 2 + ] _LOGGER.debug( - "The entity domains are: %s for group: %s:0x%04x", - entity_domains, + "The entity platforms are: %s for group: %s:0x%04x", + entity_platforms, group.name, group.group_id, ) - return entity_domains + return entity_platforms -PROBE = ProbeEndpoint() +DEVICE_PROBE = DeviceProbe() +ENDPOINT_PROBE = EndpointProbe() GROUP_PROBE = GroupProbe() diff --git a/zha/application/entity.py b/zha/application/entity.py deleted file mode 100644 index a302bc4d..00000000 --- a/zha/application/entity.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Entity for Zigbee Home Automation.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import functools -import logging -from typing import TYPE_CHECKING, Any, Self - -from homeassistant.const import ATTR_NAME, EntityCategory -from homeassistant.core import CALLBACK_TYPE, Event, callback -from homeassistant.helpers import entity -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.event import ( - EventStateChangedData, - async_track_state_change_event, -) -from homeassistant.helpers.restore_state import RestoreEntity -from zigpy.quirks.v2 import EntityMetadata, EntityType - -from .core.const import ( - ATTR_MANUFACTURER, - ATTR_MODEL, - DOMAIN, - SIGNAL_GROUP_ENTITY_REMOVED, - SIGNAL_GROUP_MEMBERSHIP_CHANGE, - SIGNAL_REMOVE, -) -from .core.helpers import LogMixin, get_zha_gateway - -if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice - -_LOGGER = logging.getLogger(__name__) - -ENTITY_SUFFIX = "entity_suffix" -DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 - - -class BaseZhaEntity(LogMixin, entity.Entity): - """A base class for ZHA entities.""" - - _unique_id_suffix: str | None = None - """suffix to add to the unique_id of the entity. Used for multi - entities using the same cluster handler/cluster id for the entity.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None: - """Init ZHA entity.""" - 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 = zha_device - self._unsubs: list[Callable[[], None]] = [] - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def zha_device(self) -> ZHADevice: - """Return the ZHA device this entity is attached to.""" - return self._zha_device - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device specific state attributes.""" - return self._extra_state_attributes - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - zha_device_info = self._zha_device.device_info - ieee = zha_device_info["ieee"] - - zha_gateway = get_zha_gateway(self.hass) - - return DeviceInfo( - connections={(CONNECTION_ZIGBEE, ieee)}, - identifiers={(DOMAIN, ieee)}, - manufacturer=zha_device_info[ATTR_MANUFACTURER], - model=zha_device_info[ATTR_MODEL], - name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, zha_gateway.state.node_info.ieee), - ) - - @callback - def async_state_changed(self) -> None: - """Entity state changed.""" - self.async_write_ha_state() - - @callback - def async_update_state_attribute(self, key: str, value: Any) -> None: - """Update a single device state attribute.""" - self._extra_state_attributes.update({key: value}) - self.async_write_ha_state() - - @callback - def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: - """Set the entity state.""" - - async def async_will_remove_from_hass(self) -> None: - """Disconnect entity object when removed.""" - for unsub in self._unsubs[:]: - unsub() - self._unsubs.remove(unsub) - - @callback - def async_accept_signal( - self, - cluster_handler: ClusterHandler | None, - signal: str, - func: Callable[..., Any], - signal_override=False, - ): - """Accept a signal from a cluster handler.""" - unsub = None - if signal_override: - unsub = async_dispatcher_connect(self.hass, signal, func) - else: - assert cluster_handler - unsub = async_dispatcher_connect( - self.hass, f"{cluster_handler.unique_id}_{signal}", func - ) - self._unsubs.append(unsub) - - def log(self, level: int, msg: str, *args, **kwargs): - """Log a message.""" - msg = f"%s: {msg}" - args = (self.entity_id,) + args - _LOGGER.log(level, msg, *args, **kwargs) - - -class ZhaEntity(BaseZhaEntity, RestoreEntity): - """A base class for non group ZHA entities.""" - - remove_future: asyncio.Future[Any] - - def __init__( - self, - unique_id: str, - zha_device: ZHADevice, - cluster_handlers: list[ClusterHandler], - **kwargs: Any, - ) -> None: - """Init ZHA entity.""" - super().__init__(unique_id, zha_device, **kwargs) - - self.cluster_handlers: dict[str, ClusterHandler] = {} - for cluster_handler in cluster_handlers: - self.cluster_handlers[cluster_handler.name] = cluster_handler - - @classmethod - def create_entity( - cls, - unique_id: str, - zha_device: ZHADevice, - cluster_handlers: list[ClusterHandler], - **kwargs: Any, - ) -> Self | None: - """Entity Factory. - - Return entity if it is a supported configuration, otherwise return None - """ - return cls(unique_id, zha_device, cluster_handlers, **kwargs) - - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: - """Init this entity from the quirks metadata.""" - if entity_metadata.initially_disabled: - self._attr_entity_registry_enabled_default = False - - if entity_metadata.translation_key: - self._attr_translation_key = entity_metadata.translation_key - - if hasattr(entity_metadata.entity_metadata, "attribute_name"): - if not entity_metadata.translation_key: - self._attr_translation_key = ( - entity_metadata.entity_metadata.attribute_name - ) - self._unique_id_suffix = entity_metadata.entity_metadata.attribute_name - elif hasattr(entity_metadata.entity_metadata, "command_name"): - if not entity_metadata.translation_key: - self._attr_translation_key = ( - entity_metadata.entity_metadata.command_name - ) - self._unique_id_suffix = entity_metadata.entity_metadata.command_name - if entity_metadata.entity_type is EntityType.CONFIG: - self._attr_entity_category = EntityCategory.CONFIG - elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: - self._attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def available(self) -> bool: - """Return entity availability.""" - return self._zha_device.available - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - self.remove_future = self.hass.loop.create_future() - self.async_accept_signal( - None, - f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", - functools.partial(self.async_remove, force_remove=True), - signal_override=True, - ) - - if last_state := await self.async_get_last_state(): - self.async_restore_last_state(last_state) - - self.async_accept_signal( - None, - f"{self.zha_device.available_signal}_entity", - self.async_state_changed, - signal_override=True, - ) - self._zha_device.gateway.register_entity_reference( - self._zha_device.ieee, - self.entity_id, - self._zha_device, - self.cluster_handlers, - self.device_info, - self.remove_future, - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect entity object when removed.""" - await super().async_will_remove_from_hass() - self.zha_device.gateway.remove_entity_reference(self) - self.remove_future.set_result(True) - - @callback - def async_restore_last_state(self, last_state) -> None: - """Restore previous state.""" - - async def async_update(self) -> None: - """Retrieve latest state.""" - tasks = [ - cluster_handler.async_update() - for cluster_handler in self.cluster_handlers.values() - if hasattr(cluster_handler, "async_update") - ] - if tasks: - await asyncio.gather(*tasks) - - -class ZhaGroupEntity(BaseZhaEntity): - """A base class for ZHA group entities.""" - - # The group name is set in the initializer - _attr_name: str - - def __init__( - self, - entity_ids: list[str], - unique_id: str, - group_id: int, - zha_device: ZHADevice, - **kwargs: Any, - ) -> None: - """Initialize a ZHA group.""" - super().__init__(unique_id, zha_device, **kwargs) - self._available = False - self._group = zha_device.gateway.groups.get(group_id) - self._group_id: int = group_id - self._entity_ids: list[str] = entity_ids - self._async_unsub_state_changed: CALLBACK_TYPE | None = None - self._handled_group_membership = False - self._change_listener_debouncer: Debouncer | None = None - self._update_group_from_child_delay = DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY - - self._attr_name = self._group.name - - @property - def available(self) -> bool: - """Return entity availability.""" - return self._available - - @classmethod - def create_entity( - cls, - entity_ids: list[str], - unique_id: str, - group_id: int, - zha_device: ZHADevice, - **kwargs: Any, - ) -> Self | 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 - if self._handled_group_membership: - return - - self._handled_group_membership = True - await self.async_remove(force_remove=True) - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - await self.async_update() - - self.async_accept_signal( - None, - f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{self._group_id:04x}", - self._handle_group_membership_changed, - signal_override=True, - ) - - if self._change_listener_debouncer is None: - self._change_listener_debouncer = Debouncer( - self.hass, - _LOGGER, - cooldown=self._update_group_from_child_delay, - immediate=False, - function=functools.partial(self.async_update_ha_state, True), - ) - self.async_on_remove(self._change_listener_debouncer.async_cancel) - self._async_unsub_state_changed = async_track_state_change_event( - self.hass, self._entity_ids, self.async_state_changed_listener - ) - - def send_removed_signal(): - async_dispatcher_send( - self.hass, SIGNAL_GROUP_ENTITY_REMOVED, self._group_id - ) - - self.async_on_remove(send_removed_signal) - - @callback - def async_state_changed_listener(self, event: Event[EventStateChangedData]) -> None: - """Handle child updates.""" - # Delay to ensure that we get updates from all members before updating the group - assert self._change_listener_debouncer - self._change_listener_debouncer.async_schedule_call() - - async def async_will_remove_from_hass(self) -> None: - """Handle removal from Home Assistant.""" - await super().async_will_remove_from_hass() - if self._async_unsub_state_changed is not None: - self._async_unsub_state_changed() - self._async_unsub_state_changed = None - - async def async_update(self) -> None: - """Update the state of the group entity.""" diff --git a/zha/application/gateway.py b/zha/application/gateway.py index 50d90dd8..c0457181 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -3,70 +3,37 @@ from __future__ import annotations import asyncio -import collections -from collections.abc import Callable +from collections.abc import Iterable from contextlib import suppress from datetime import timedelta from enum import Enum -import itertools import logging -import re import time -from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast - -from homeassistant import __path__ as HOMEASSISTANT_PATH -from homeassistant.components.system_log import LogEntry, _figure_out_source -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import gather_with_limited_concurrency +from typing import Final, Self, TypeVar, cast + +from zhaquirks import setup as setup_quirks from zigpy.application import ControllerApplication -from zigpy.config import ( - CONF_DATABASE, - CONF_DEVICE, - CONF_DEVICE_PATH, - CONF_NWK, - CONF_NWK_CHANNEL, - CONF_NWK_VALIDATE_SETTINGS, -) +from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_VALIDATE_SETTINGS import zigpy.device import zigpy.endpoint import zigpy.group from zigpy.state import State from zigpy.types.named import EUI64 -from . import discovery -from .const import ( +from zha.application import discovery +from zha.application.const import ( ATTR_IEEE, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NWK, ATTR_SIGNATURE, ATTR_TYPE, + CONF_CUSTOM_QUIRKS_PATH, + CONF_ENABLE_QUIRKS, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, - DATA_ZHA, - DEBUG_COMP_BELLOWS, - DEBUG_COMP_ZHA, - DEBUG_COMP_ZIGPY, - DEBUG_COMP_ZIGPY_DECONZ, - DEBUG_COMP_ZIGPY_XBEE, - DEBUG_COMP_ZIGPY_ZIGATE, - DEBUG_COMP_ZIGPY_ZNP, - DEBUG_LEVEL_CURRENT, - DEBUG_LEVEL_ORIGINAL, - DEBUG_LEVELS, - DEBUG_RELAY_LOGGERS, - DEFAULT_DATABASE_NAME, DEVICE_PAIRING_STATUS, - DOMAIN, - SIGNAL_ADD_ENTITIES, - SIGNAL_GROUP_MEMBERSHIP_CHANGE, - SIGNAL_REMOVE, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, ZHA_GW_MSG, @@ -79,37 +46,24 @@ ZHA_GW_MSG_GROUP_MEMBER_ADDED, ZHA_GW_MSG_GROUP_MEMBER_REMOVED, ZHA_GW_MSG_GROUP_REMOVED, - ZHA_GW_MSG_LOG_ENTRY, - ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_RAW_INIT, RadioType, ) -from .device import DeviceStatus, ZHADevice -from .group import GroupMember, ZHAGroup -from .helpers import get_zha_data -from .registries import GROUP_ENTITY_DOMAINS - -if TYPE_CHECKING: - from logging import Filter, LogRecord - - from ..application.entity import ZhaEntity - from .cluster_handlers import ClusterHandler - - _LogFilterType = Filter | Callable[[LogRecord], bool] +from zha.application.helpers import ZHAData +from zha.async_ import ( + AsyncUtilMixin, + create_eager_task, + gather_with_limited_concurrency, +) +from zha.event import EventBase +from zha.zigbee.device import Device, DeviceStatus +from zha.zigbee.group import Group, GroupMemberReference +BLOCK_LOG_TIMEOUT: Final[int] = 60 +_R = TypeVar("_R") _LOGGER = logging.getLogger(__name__) -class EntityReference(NamedTuple): - """Describes an entity reference.""" - - reference_id: str - zha_device: ZHADevice - cluster_handlers: dict[str, ClusterHandler] - device_info: DeviceInfo - remove_future: asyncio.Future[Any] - - class DevicePairingStatus(Enum): """Status of a device.""" @@ -119,45 +73,32 @@ class DevicePairingStatus(Enum): INITIALIZED = 4 -class ZHAGateway: +class Gateway(AsyncUtilMixin, EventBase): """Gateway that handles events that happen on the ZHA Zigbee network.""" - def __init__( - self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry - ) -> None: + def __init__(self, config: ZHAData) -> None: """Initialize the gateway.""" - self.hass = hass - self._config = config - self._devices: dict[EUI64, ZHADevice] = {} - self._groups: dict[int, ZHAGroup] = {} + super().__init__() + self.config: ZHAData = config + self._devices: dict[EUI64, Device] = {} + self._groups: dict[int, Group] = {} self.application_controller: ControllerApplication = None - self.coordinator_zha_device: ZHADevice = None # type: ignore[assignment] - self._device_registry: collections.defaultdict[EUI64, list[EntityReference]] = ( - collections.defaultdict(list) - ) - self._log_levels: dict[str, dict[str, int]] = { - DEBUG_LEVEL_ORIGINAL: async_capture_log_levels(), - DEBUG_LEVEL_CURRENT: async_capture_log_levels(), - } - self.debug_enabled = False - self._log_relay_handler = LogRelayHandler(hass, self) - self.config_entry = config_entry - self._unsubs: list[Callable[[], None]] = [] + self.coordinator_zha_device: Device = None # type: ignore[assignment] - self.shutting_down = False + self.shutting_down: bool = False self._reload_task: asyncio.Task | None = None + if config.yaml_config.get(CONF_ENABLE_QUIRKS, True): + setup_quirks( + custom_quirks_path=config.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) + ) + self.config.gateway = self + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" - radio_type = RadioType[self.config_entry.data[CONF_RADIO_TYPE]] - - app_config = self._config.get(CONF_ZIGPY, {}) - database = self._config.get( - CONF_DATABASE, - self.hass.config.path(DEFAULT_DATABASE_NAME), - ) - app_config[CONF_DATABASE] = database - app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] + radio_type = RadioType[self.config.config_entry_data["data"][CONF_RADIO_TYPE]] + app_config = self.config.yaml_config.get(CONF_ZIGPY, {}) + app_config[CONF_DEVICE] = self.config.config_entry_data["data"][CONF_DEVICE] if CONF_NWK_VALIDATE_SETTINGS not in app_config: app_config[CONF_NWK_VALIDATE_SETTINGS] = True @@ -171,35 +112,20 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: ): app_config[CONF_USE_THREAD] = False - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( - is_multiprotocol_url, - ) - - # Until we have a way to coordinate channels with the Thread half of multi-PAN, - # stick to the old zigpy default of channel 15 instead of dynamically scanning - if ( - is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH]) - and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None - ): - app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15 - return radio_type.controller, radio_type.controller.SCHEMA(app_config) @classmethod - async def async_from_config( - cls, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry - ) -> Self: + async def async_from_config(cls, config: ZHAData) -> Self: """Create an instance of a gateway from config objects.""" - instance = cls(hass, config, config_entry) + instance = cls(config) await instance.async_initialize() return instance async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self.hass) - discovery.GROUP_PROBE.initialize(self.hass) + discovery.DEVICE_PROBE.initialize(self) + discovery.ENDPOINT_PROBE.initialize(self) + discovery.GROUP_PROBE.initialize(self) self.shutting_down = False @@ -219,15 +145,12 @@ async def async_initialize(self) -> None: self.application_controller = app - zha_data = get_zha_data(self.hass) - zha_data.gateway = self - - self.coordinator_zha_device = self._async_get_or_create_device( + self.coordinator_zha_device = self.get_or_create_device( self._find_coordinator_device() ) - self.async_load_devices() - self.async_load_groups() + self.load_devices() + self.load_groups() self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) @@ -244,9 +167,12 @@ def connection_lost(self, exc: Exception) -> None: _LOGGER.debug("Ignoring reset, one is already running") return - self._reload_task = self.hass.async_create_task( + # pylint: disable=pointless-string-statement + """TODO + self._reload_task = asyncio.create_task( self.hass.config_entries.async_reload(self.config_entry.entry_id) ) + """ def _find_coordinator_device(self) -> zigpy.device.Device: zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) @@ -259,12 +185,12 @@ def _find_coordinator_device(self) -> zigpy.device.Device: return zigpy_coordinator - @callback - def async_load_devices(self) -> None: + def load_devices(self) -> None: """Restore ZHA devices from zigpy application state.""" + assert self.application_controller for zigpy_device in self.application_controller.devices.values(): - zha_device = self._async_get_or_create_device(zigpy_device) + zha_device = self.get_or_create_device(zigpy_device) delta_msg = "not known" if zha_device.last_seen is not None: delta = round(time.time() - zha_device.last_seen) @@ -280,18 +206,30 @@ def async_load_devices(self) -> None: delta_msg, zha_device.consider_unavailable_time, ) + self.create_platform_entities() - @callback - def async_load_groups(self) -> None: + def load_groups(self) -> None: """Initialize ZHA groups.""" - for group_id in self.application_controller.groups: - group = self.application_controller.groups[group_id] - zha_group = self._async_get_or_create_group(group) + for group_id, group in self.application_controller.groups.items(): + _LOGGER.info("Loading group with id: 0x%04x", group_id) + zha_group = self.get_or_create_group(group) # we can do this here because the entities are in the # entity registry tied to the devices discovery.GROUP_PROBE.discover_group_entities(zha_group) + def create_platform_entities(self) -> None: + """Create platform entities.""" + + for platform in discovery.PLATFORMS: + for platform_entity_class, args, kw_args in self.config.platforms[platform]: + platform_entity = platform_entity_class.create_platform_entity( + *args, **kw_args + ) + if platform_entity: + _LOGGER.debug("Platform entity data: %s", platform_entity.to_json()) + self.config.platforms[platform].clear() + @property def radio_concurrency(self) -> int: """Maximum configured radio concurrency.""" @@ -337,11 +275,11 @@ async def fetch_updated_state() -> None: """Fetch updated state for mains powered devices.""" await self.async_fetch_updated_state_mains() _LOGGER.debug("Allowing polled requests") - self.hass.data[DATA_ZHA].allow_polling = True + self.config.allow_polling = True # background the fetching of state for mains powered devices - self.config_entry.async_create_background_task( - self.hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" + self.async_create_background_task( + fetch_updated_state(), "zha.gateway-fetch_updated_state" ) def device_joined(self, device: zigpy.device.Device) -> None: @@ -350,11 +288,11 @@ def device_joined(self, device: zigpy.device.Device) -> None: At this point, no information about the device is known other than its address """ - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + + self.emit( + ZHA_GW_MSG_DEVICE_JOINED, { - ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_INFO: { ATTR_NWK: device.nwk, ATTR_IEEE: str(device.ieee), @@ -363,20 +301,21 @@ def device_joined(self, device: zigpy.device.Device) -> None: }, ) - def raw_device_initialized(self, device: zigpy.device.Device) -> None: + def raw_device_initialized(self, device: zigpy.device.Device) -> None: # pylint: disable=unused-argument """Handle a device initialization without quirks loaded.""" - manuf = device.manufacturer - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + + self.emit( + ZHA_GW_MSG_RAW_INIT, { - ATTR_TYPE: ZHA_GW_MSG_RAW_INIT, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_INFO: { ATTR_NWK: device.nwk, ATTR_IEEE: str(device.ieee), DEVICE_PAIRING_STATUS: DevicePairingStatus.INTERVIEW_COMPLETE.name, ATTR_MODEL: device.model if device.model else UNKNOWN_MODEL, - ATTR_MANUFACTURER: manuf if manuf else UNKNOWN_MANUFACTURER, + ATTR_MANUFACTURER: device.manufacturer + if device.manufacturer + else UNKNOWN_MANUFACTURER, ATTR_SIGNATURE: device.get_signature(), }, }, @@ -384,7 +323,16 @@ def raw_device_initialized(self, device: zigpy.device.Device) -> None: def device_initialized(self, device: zigpy.device.Device) -> None: """Handle device joined and basic information discovered.""" - self.hass.async_create_task(self.async_device_initialized(device)) + if device.ieee in self._device_init_tasks: + _LOGGER.warning( + "Cancelling previous initialization task for device %s", + str(device.ieee), + ) + self._device_init_tasks[device.ieee].cancel() + self._device_init_tasks[device.ieee] = asyncio.create_task( + self.async_device_initialized(device), + name=f"device_initialized_task_{str(device.ieee)}:0x{device.nwk:04x}", + ) def device_left(self, device: zigpy.device.Device) -> None: """Handle device leaving the network.""" @@ -395,163 +343,82 @@ def group_member_removed( ) -> None: """Handle zigpy group member removed event.""" # need to handle endpoint correctly on groups - zha_group = self._async_get_or_create_group(zigpy_group) + zha_group = self.get_or_create_group(zigpy_group) + discovery.GROUP_PROBE.discover_group_entities(zha_group) zha_group.info("group_member_removed - endpoint: %s", endpoint) - self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) - async_dispatcher_send( - self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" - ) + self._emit_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) def group_member_added( self, zigpy_group: zigpy.group.Group, endpoint: zigpy.endpoint.Endpoint ) -> None: """Handle zigpy group member added event.""" # need to handle endpoint correctly on groups - zha_group = self._async_get_or_create_group(zigpy_group) + zha_group = self.get_or_create_group(zigpy_group) + discovery.GROUP_PROBE.discover_group_entities(zha_group) zha_group.info("group_member_added - endpoint: %s", endpoint) - self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) - async_dispatcher_send( - self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" - ) - if len(zha_group.members) == 2: - # we need to do this because there wasn't already - # a group entity to remove and re-add - discovery.GROUP_PROBE.discover_group_entities(zha_group) + self._emit_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) def group_added(self, zigpy_group: zigpy.group.Group) -> None: """Handle zigpy group added event.""" - zha_group = self._async_get_or_create_group(zigpy_group) + zha_group = self.get_or_create_group(zigpy_group) zha_group.info("group_added") # need to dispatch for entity creation here - self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_ADDED) + self._emit_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_ADDED) def group_removed(self, zigpy_group: zigpy.group.Group) -> None: """Handle zigpy group removed event.""" - self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) + self._emit_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) zha_group = self._groups.pop(zigpy_group.group_id) zha_group.info("group_removed") - self._cleanup_group_entity_registry_entries(zigpy_group) - def _send_group_gateway_message( - self, zigpy_group: zigpy.group.Group, gateway_message_type: str + def _emit_group_gateway_message( # pylint: disable=unused-argument + self, + zigpy_group: zigpy.group.Group, + gateway_message_type: str, ) -> None: """Send the gateway event for a zigpy group event.""" zha_group = self._groups.get(zigpy_group.group_id) if zha_group is not None: - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + self.emit( + gateway_message_type, { - ATTR_TYPE: gateway_message_type, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_GROUP_INFO: zha_group.group_info, }, ) - async def _async_remove_device( - self, device: ZHADevice, entity_refs: list[EntityReference] | None - ) -> None: - if entity_refs is not None: - remove_tasks: list[asyncio.Future[Any]] = [] - for entity_ref in entity_refs: - remove_tasks.append(entity_ref.remove_future) - if remove_tasks: - await asyncio.wait(remove_tasks) - - device_registry = dr.async_get(self.hass) - reg_device = device_registry.async_get(device.device_id) - if reg_device is not None: - device_registry.async_remove_device(reg_device.id) - def device_removed(self, device: zigpy.device.Device) -> None: """Handle device being removed from the network.""" + _LOGGER.info("Removing device %s - %s", device.ieee, f"0x{device.nwk:04x}") zha_device = self._devices.pop(device.ieee, None) - entity_refs = self._device_registry.pop(device.ieee, None) if zha_device is not None: device_info = zha_device.zha_device_info - zha_device.async_cleanup_handles() - async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - self.hass.async_create_task( - self._async_remove_device(zha_device, entity_refs), - "ZHAGateway._async_remove_device", + self.track_task( + create_eager_task( + zha_device.on_remove(), name="Gateway._async_remove_device" + ) ) if device_info is not None: - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + self.emit( + ZHA_GW_MSG_DEVICE_REMOVED, { - ATTR_TYPE: ZHA_GW_MSG_DEVICE_REMOVED, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_INFO: device_info, }, ) - def get_device(self, ieee: EUI64) -> ZHADevice | None: - """Return ZHADevice for given ieee.""" + def get_device(self, ieee: EUI64) -> Device | None: + """Return Device for given ieee.""" return self._devices.get(ieee) - def get_group(self, group_id: int) -> ZHAGroup | None: - """Return Group for given group id.""" - return self.groups.get(group_id) - - @callback - def async_get_group_by_name(self, group_name: str) -> ZHAGroup | None: - """Get ZHA group by name.""" - for group in self.groups.values(): - if group.name == group_name: - return group - return None - - def get_entity_reference(self, entity_id: str) -> EntityReference | None: - """Return entity reference for given entity_id if found.""" - for entity_reference in itertools.chain.from_iterable( - self.device_registry.values() - ): - if entity_id == entity_reference.reference_id: - return entity_reference - return None - - def remove_entity_reference(self, entity: ZhaEntity) -> None: - """Remove entity reference for given entity_id if found.""" - if entity.zha_device.ieee in self.device_registry: - entity_refs = self.device_registry.get(entity.zha_device.ieee) - self.device_registry[entity.zha_device.ieee] = [ - e - for e in entity_refs # type: ignore[union-attr] - if e.reference_id != entity.entity_id - ] - - def _cleanup_group_entity_registry_entries( - self, zigpy_group: zigpy.group.Group - ) -> None: - """Remove entity registry entries for group entities when the groups are removed from HA.""" - # first we collect the potential unique ids for entities that could be created from this group - possible_entity_unique_ids = [ - f"{domain}_zha_group_0x{zigpy_group.group_id:04x}" - for domain in GROUP_ENTITY_DOMAINS - ] - - # then we get all group entity entries tied to the coordinator - entity_registry = er.async_get(self.hass) - assert self.coordinator_zha_device - all_group_entity_entries = er.async_entries_for_device( - entity_registry, - self.coordinator_zha_device.device_id, - include_disabled_entities=True, - ) - - # then we get the entity entries for this specific group - # by getting the entries that match - entries_to_remove = [ - entry - for entry in all_group_entity_entries - if entry.unique_id in possible_entity_unique_ids - ] - - # then we remove the entries from the entity registry - for entry in entries_to_remove: - _LOGGER.debug( - "cleaning up entity registry entry for entity: %s", entry.entity_id - ) - entity_registry.async_remove(entry.entity_id) + def get_group(self, group_id_or_name: int | str) -> Group | None: + """Return Group for given group id or group name.""" + if isinstance(group_id_or_name, str): + for group in self.groups.values(): + if group.name == group_id_or_name: + return group + return None + return self.groups.get(group_id_or_name) @property def state(self) -> State: @@ -559,97 +426,30 @@ def state(self) -> State: return self.application_controller.state @property - def devices(self) -> dict[EUI64, ZHADevice]: + def devices(self) -> dict[EUI64, Device]: """Return devices.""" return self._devices @property - def groups(self) -> dict[int, ZHAGroup]: + def groups(self) -> dict[int, Group]: """Return groups.""" return self._groups - @property - def device_registry(self) -> collections.defaultdict[EUI64, list[EntityReference]]: - """Return entities by ieee.""" - return self._device_registry - - def register_entity_reference( - self, - ieee: EUI64, - reference_id: str, - zha_device: ZHADevice, - cluster_handlers: dict[str, ClusterHandler], - device_info: DeviceInfo, - remove_future: asyncio.Future[Any], - ): - """Record the creation of a hass entity associated with ieee.""" - self._device_registry[ieee].append( - EntityReference( - reference_id=reference_id, - zha_device=zha_device, - cluster_handlers=cluster_handlers, - device_info=device_info, - remove_future=remove_future, - ) - ) - - @callback - def async_enable_debug_mode(self, filterer: _LogFilterType | None = None) -> None: - """Enable debug mode for ZHA.""" - self._log_levels[DEBUG_LEVEL_ORIGINAL] = async_capture_log_levels() - async_set_logger_levels(DEBUG_LEVELS) - self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() - - if filterer: - self._log_relay_handler.addFilter(filterer) - - for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).addHandler(self._log_relay_handler) - - self.debug_enabled = True - - @callback - def async_disable_debug_mode(self, filterer: _LogFilterType | None = None) -> None: - """Disable debug mode for ZHA.""" - async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL]) - self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels() - for logger_name in DEBUG_RELAY_LOGGERS: - logging.getLogger(logger_name).removeHandler(self._log_relay_handler) - if filterer: - self._log_relay_handler.removeFilter(filterer) - self.debug_enabled = False - - @callback - def _async_get_or_create_device( - self, zigpy_device: zigpy.device.Device - ) -> ZHADevice: + def get_or_create_device(self, zigpy_device: zigpy.device.Device) -> Device: """Get or create a ZHA device.""" if (zha_device := self._devices.get(zigpy_device.ieee)) is None: - zha_device = ZHADevice.new(self.hass, zigpy_device, self) + zha_device = Device.new(zigpy_device, self) self._devices[zigpy_device.ieee] = zha_device - - device_registry = dr.async_get(self.hass) - device_registry_device = device_registry.async_get_or_create( - config_entry_id=self.config_entry.entry_id, - connections={(dr.CONNECTION_ZIGBEE, str(zha_device.ieee))}, - identifiers={(DOMAIN, str(zha_device.ieee))}, - name=zha_device.name, - manufacturer=zha_device.manufacturer, - model=zha_device.model, - ) - zha_device.set_device_id(device_registry_device.id) return zha_device - @callback - def _async_get_or_create_group(self, zigpy_group: zigpy.group.Group) -> ZHAGroup: + def get_or_create_group(self, zigpy_group: zigpy.group.Group) -> Group: """Get or create a ZHA group.""" zha_group = self._groups.get(zigpy_group.group_id) if zha_group is None: - zha_group = ZHAGroup(self.hass, self, zigpy_group) + zha_group = Group(self, zigpy_group) self._groups[zigpy_group.group_id] = zha_group return zha_group - @callback def async_update_device( self, sender: zigpy.device.Device, available: bool = True ) -> None: @@ -662,7 +462,7 @@ def async_update_device( async def async_device_initialized(self, device: zigpy.device.Device) -> None: """Handle device joined and basic information discovered (async).""" - zha_device = self._async_get_or_create_device(device) + zha_device = self.get_or_create_device(device) _LOGGER.debug( "device - %s:%s entering async_device_initialized - is_new_join: %s", device.nwk, @@ -689,32 +489,30 @@ async def async_device_initialized(self, device: zigpy.device.Device) -> None: device_info = zha_device.zha_device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.INITIALIZED.name - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + self.emit( + ZHA_GW_MSG_DEVICE_FULL_INIT, { - ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_INFO: device_info, }, ) - async def _async_device_joined(self, zha_device: ZHADevice) -> None: + async def _async_device_joined(self, zha_device: Device) -> None: zha_device.available = True device_info = zha_device.device_info await zha_device.async_configure() device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + self.emit( + ZHA_GW_MSG_DEVICE_FULL_INIT, { - ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_INFO: device_info, }, ) await zha_device.async_initialize(from_cache=False) - async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES) + self.create_platform_entities() - async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: + async def _async_device_rejoined(self, zha_device: Device) -> None: _LOGGER.debug( "skipping discovery for previously discovered device - %s:%s", zha_device.nwk, @@ -725,11 +523,10 @@ async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: await zha_device.async_configure() device_info = zha_device.device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, + self.emit( + ZHA_GW_MSG_DEVICE_FULL_INIT, { - ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, + ATTR_TYPE: ZHA_GW_MSG, ZHA_GW_MSG_DEVICE_INFO: device_info, }, ) @@ -740,9 +537,9 @@ async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: async def async_create_zigpy_group( self, name: str, - members: list[GroupMember] | None, + members: list[GroupMemberReference] | None, group_id: int | None = None, - ) -> ZHAGroup | None: + ) -> Group | None: """Create a new Zigpy Zigbee group.""" # we start with two to fill any gaps from a user removing existing groups @@ -753,7 +550,7 @@ async def async_create_zigpy_group( group_id += 1 # guard against group already existing - if self.async_get_group_by_name(name) is None: + if self.get_group(name) is None: self.application_controller.groups.add_group(group_id, name) if members is not None: tasks = [] @@ -795,16 +592,32 @@ async def shutdown(self) -> None: _LOGGER.debug("Ignoring duplicate shutdown event") return + async def _cancel_tasks(tasks_to_cancel: Iterable) -> None: + tasks = [t for t in tasks_to_cancel if not (t.done() or t.cancelled())] + for task in tasks: + _LOGGER.debug("Cancelling task: %s", task) + task.cancel() + with suppress(asyncio.CancelledError): + await asyncio.gather(*tasks, return_exceptions=True) + + await _cancel_tasks(self._background_tasks) + await _cancel_tasks(self._tracked_completable_tasks) + await _cancel_tasks(self._device_init_tasks.values()) + self._cancel_cancellable_timers() + + for device in self._devices.values(): + await device.on_remove() + _LOGGER.debug("Shutting down ZHA ControllerApplication") self.shutting_down = True - for unsubscribe in self._unsubs: - unsubscribe() - for device in self.devices.values(): - device.async_cleanup_handles() await self.application_controller.shutdown() + self.application_controller = None + await asyncio.sleep(0.1) # give bellows thread callback a chance to run + self._devices.clear() + self._groups.clear() - def handle_message( + def handle_message( # pylint: disable=unused-argument self, sender: zigpy.device.Device, profile: int, @@ -816,66 +629,3 @@ def handle_message( """Handle message from a device Event handler.""" if sender.ieee in self.devices and not self.devices[sender.ieee].available: self.async_update_device(sender, available=True) - - -@callback -def async_capture_log_levels() -> dict[str, int]: - """Capture current logger levels for ZHA.""" - return { - DEBUG_COMP_BELLOWS: logging.getLogger(DEBUG_COMP_BELLOWS).getEffectiveLevel(), - DEBUG_COMP_ZHA: logging.getLogger(DEBUG_COMP_ZHA).getEffectiveLevel(), - DEBUG_COMP_ZIGPY: logging.getLogger(DEBUG_COMP_ZIGPY).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_ZNP: logging.getLogger( - DEBUG_COMP_ZIGPY_ZNP - ).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_DECONZ: logging.getLogger( - DEBUG_COMP_ZIGPY_DECONZ - ).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_XBEE: logging.getLogger( - DEBUG_COMP_ZIGPY_XBEE - ).getEffectiveLevel(), - DEBUG_COMP_ZIGPY_ZIGATE: logging.getLogger( - DEBUG_COMP_ZIGPY_ZIGATE - ).getEffectiveLevel(), - } - - -@callback -def async_set_logger_levels(levels: dict[str, int]) -> None: - """Set logger levels for ZHA.""" - logging.getLogger(DEBUG_COMP_BELLOWS).setLevel(levels[DEBUG_COMP_BELLOWS]) - logging.getLogger(DEBUG_COMP_ZHA).setLevel(levels[DEBUG_COMP_ZHA]) - logging.getLogger(DEBUG_COMP_ZIGPY).setLevel(levels[DEBUG_COMP_ZIGPY]) - logging.getLogger(DEBUG_COMP_ZIGPY_ZNP).setLevel(levels[DEBUG_COMP_ZIGPY_ZNP]) - logging.getLogger(DEBUG_COMP_ZIGPY_DECONZ).setLevel(levels[DEBUG_COMP_ZIGPY_DECONZ]) - logging.getLogger(DEBUG_COMP_ZIGPY_XBEE).setLevel(levels[DEBUG_COMP_ZIGPY_XBEE]) - logging.getLogger(DEBUG_COMP_ZIGPY_ZIGATE).setLevel(levels[DEBUG_COMP_ZIGPY_ZIGATE]) - - -class LogRelayHandler(logging.Handler): - """Log handler for error messages.""" - - def __init__(self, hass: HomeAssistant, gateway: ZHAGateway) -> None: - """Initialize a new LogErrorHandler.""" - super().__init__() - self.hass = hass - self.gateway = gateway - hass_path: str = HOMEASSISTANT_PATH[0] - config_dir = self.hass.config.config_dir - self.paths_re = re.compile( - r"(?:{})/(.*)".format( - "|".join([re.escape(x) for x in (hass_path, config_dir)]) - ) - ) - - def emit(self, record: LogRecord) -> None: - """Relay log message via dispatcher.""" - if record.levelno >= logging.WARN: - entry = LogEntry(record, _figure_out_source(record, self.paths_re)) - else: - entry = LogEntry(record, (record.pathname, record.lineno)) - async_dispatcher_send( - self.hass, - ZHA_GW_MSG, - {ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()}, - ) diff --git a/zha/application/helpers.py b/zha/application/helpers.py index cb5e36ca..34366711 100644 --- a/zha/application/helpers.py +++ b/zha/application/helpers.py @@ -8,7 +8,6 @@ import binascii import collections -from collections.abc import Callable, Iterator import dataclasses from dataclasses import dataclass import enum @@ -16,11 +15,6 @@ import re from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.typing import ConfigType import voluptuous as vol import zigpy.exceptions import zigpy.types @@ -29,13 +23,21 @@ from zigpy.zcl.foundation import CommandSchema import zigpy.zdo.types as zdo_types -from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA_ZHA -from .registries import BINDABLE_CLUSTERS +from zha.application import Platform +from zha.application.const import ( + CLUSTER_TYPE_IN, + CLUSTER_TYPE_OUT, + CUSTOM_CONFIGURATION, +) +from zha.decorators import SetRegistry + +# from zha.zigbee.cluster_handlers.registries import BINDABLE_CLUSTERS +BINDABLE_CLUSTERS = SetRegistry() if TYPE_CHECKING: - from .cluster_handlers import ClusterHandler - from .device import ZHADevice - from .gateway import ZHAGateway + from zha.application.gateway import Gateway + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device _ClusterHandlerT = TypeVar("_ClusterHandlerT", bound="ClusterHandler") _T = TypeVar("_T") @@ -61,7 +63,11 @@ def destination_address(self) -> zdo_types.MultiAddress: async def safe_read( - cluster, attributes, allow_cache=True, only_cache=False, manufacturer=None + cluster: zigpy.zcl.Cluster, + attributes: list[int | str], + allow_cache: bool = True, + only_cache: bool = False, + manufacturer=None, ): """Swallow all exceptions from network read. @@ -82,7 +88,7 @@ async def safe_read( async def get_matched_clusters( - source_zha_device: ZHADevice, target_zha_device: ZHADevice + source_zha_device: Device, target_zha_device: Device ) -> list[BindingPair]: """Get matched input/output cluster pairs for 2 devices.""" source_clusters = source_zha_device.async_get_std_clusters() @@ -118,37 +124,6 @@ async def get_matched_clusters( return clusters_to_bind -def cluster_command_schema_to_vol_schema(schema: CommandSchema) -> vol.Schema: - """Convert a cluster command schema to a voluptuous schema.""" - return vol.Schema( - { - vol.Optional(field.name) - if field.optional - else vol.Required(field.name): schema_type_to_vol(field.type) - for field in schema.fields - } - ) - - -def schema_type_to_vol(field_type: Any) -> Any: - """Convert a schema type to a voluptuous type.""" - if issubclass(field_type, enum.Flag) and field_type.__members__: - return cv.multi_select( - [key.replace("_", " ") for key in field_type.__members__] - ) - if issubclass(field_type, enum.Enum) and field_type.__members__: - return vol.In([key.replace("_", " ") for key in field_type.__members__]) - if ( - issubclass(field_type, zigpy.types.FixedIntType) - or issubclass(field_type, enum.Flag) - or issubclass(field_type, enum.Enum) - ): - return vol.All( - vol.Coerce(int), vol.Range(field_type.min_value, field_type.max_value) - ) - return str - - def convert_to_zcl_values( fields: dict[str, Any], schema: CommandSchema ) -> dict[str, Any]: @@ -186,8 +161,7 @@ def convert_to_zcl_values( return converted_fields -@callback -def async_is_bindable_target(source_zha_device, target_zha_device): +def async_is_bindable_target(source_zha_device: Device, target_zha_device: Device): """Determine if target is bindable to source.""" if target_zha_device.nwk == 0x0000: return True @@ -205,119 +179,18 @@ def async_is_bindable_target(source_zha_device, target_zha_device): return False -@callback def async_get_zha_config_value( - config_entry: ConfigEntry, section: str, config_key: str, default: _T + zha_data: ZHAData, section: str, config_key: str, default: _T ) -> _T: """Get the value for the specified configuration from the ZHA config entry.""" return ( - config_entry.options.get(CUSTOM_CONFIGURATION, {}) + zha_data.config_entry_data.get("options", {}) + .get(CUSTOM_CONFIGURATION, {}) .get(section, {}) .get(config_key, default) ) -def async_cluster_exists(hass, cluster_id, skip_coordinator=True): - """Determine if a device containing the specified in cluster is paired.""" - zha_gateway = get_zha_gateway(hass) - zha_devices = zha_gateway.devices.values() - for zha_device in zha_devices: - if skip_coordinator and zha_device.is_coordinator: - continue - clusters_by_endpoint = zha_device.async_get_clusters() - for clusters in clusters_by_endpoint.values(): - if ( - cluster_id in clusters[CLUSTER_TYPE_IN] - or cluster_id in clusters[CLUSTER_TYPE_OUT] - ): - return True - return False - - -@callback -def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: - """Get a ZHA device for the given device registry id.""" - device_registry = dr.async_get(hass) - registry_device = device_registry.async_get(device_id) - if not registry_device: - _LOGGER.error("Device id `%s` not found in registry", device_id) - raise KeyError(f"Device id `{device_id}` not found in registry.") - zha_gateway = get_zha_gateway(hass) - try: - ieee_address = list(registry_device.identifiers)[0][1] - ieee = zigpy.types.EUI64.convert(ieee_address) - except (IndexError, ValueError) as ex: - _LOGGER.error( - "Unable to determine device IEEE for device with device id `%s`", device_id - ) - raise KeyError( - f"Unable to determine device IEEE for device with device id `{device_id}`." - ) from ex - return zha_gateway.devices[ieee] - - -def find_state_attributes(states: list[State], key: str) -> Iterator[Any]: - """Find attributes with matching key from states.""" - for state in states: - if (value := state.attributes.get(key)) is not None: - yield value - - -def mean_int(*args): - """Return the mean of the supplied values.""" - return int(sum(args) / len(args)) - - -def mean_tuple(*args): - """Return the mean values along the columns of the supplied values.""" - return tuple(sum(x) / len(x) for x in zip(*args)) - - -def reduce_attribute( - states: list[State], - key: str, - default: Any | None = None, - reduce: Callable[..., Any] = mean_int, -) -> Any: - """Find the first attribute matching key from states. - - If none are found, return default. - """ - attrs = list(find_state_attributes(states, key)) - - if not attrs: - return default - - if len(attrs) == 1: - return attrs[0] - - return reduce(*attrs) - - -class LogMixin: - """Log helper.""" - - def log(self, level, msg, *args, **kwargs): - """Log with level.""" - raise NotImplementedError - - def debug(self, msg, *args, **kwargs): - """Debug level log.""" - return self.log(logging.DEBUG, msg, *args, **kwargs) - - def info(self, msg, *args, **kwargs): - """Info level log.""" - return self.log(logging.INFO, msg, *args, **kwargs) - - def warning(self, msg, *args, **kwargs): - """Warning method log.""" - return self.log(logging.WARNING, msg, *args, **kwargs) - - def error(self, msg, *args, **kwargs): - """Error level log.""" - return self.log(logging.ERROR, msg, *args, **kwargs) - - def convert_install_code(value: str) -> zigpy.types.KeyData: """Convert string to install code bytes and validate length.""" @@ -396,30 +269,15 @@ def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, zigpy.types.Key @dataclasses.dataclass(kw_only=True, slots=True) class ZHAData: - """ZHA component data stored in `hass.data`.""" + """ZHA data stored in `gateway.data`.""" - yaml_config: ConfigType = dataclasses.field(default_factory=dict) + yaml_config: dict[str, Any] = dataclasses.field(default_factory=dict) + config_entry_data: dict[str, Any] = dataclasses.field(default_factory=dict) platforms: collections.defaultdict[Platform, list] = dataclasses.field( default_factory=lambda: collections.defaultdict(list) ) - gateway: ZHAGateway | None = dataclasses.field(default=None) + gateway: Gateway | None = dataclasses.field(default=None) device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( default_factory=dict ) allow_polling: bool = dataclasses.field(default=False) - - -def get_zha_data(hass: HomeAssistant) -> ZHAData: - """Get the global ZHA data object.""" - if DATA_ZHA not in hass.data: - hass.data[DATA_ZHA] = ZHAData() - - return hass.data[DATA_ZHA] - - -def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: - """Get the ZHA gateway object.""" - if (zha_gateway := get_zha_data(hass).gateway) is None: - raise ValueError("No gateway object exists") - - return zha_gateway diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index e69de29b..3fa1e3d1 100644 --- a/zha/application/platforms/__init__.py +++ b/zha/application/platforms/__init__.py @@ -0,0 +1,313 @@ +"""Platform module for Zigbee Home Automation.""" + +from __future__ import annotations + +import abc +import asyncio +from contextlib import suppress +from dataclasses import dataclass +from enum import StrEnum +import logging +from typing import TYPE_CHECKING, Any, Final, Optional + +from zigpy.quirks.v2 import EntityMetadata, EntityType +from zigpy.types.named import EUI64 + +from zha.application import Platform +from zha.const import STATE_CHANGED +from zha.event import EventBase +from zha.mixins import LogMixin + +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + from zha.zigbee.group import Group + + +_LOGGER = logging.getLogger(__name__) + + +class EntityCategory(StrEnum): + """Category of an entity. + + An entity with a category will: + - Not be exposed to cloud, Alexa, or Google Assistant components + - Not be included in indirect service calls to devices or areas + """ + + # Config: An entity which allows changing the configuration of a device. + CONFIG = "config" + + # Diagnostic: An entity exposing some configuration parameter, + # or diagnostics of a device. + DIAGNOSTIC = "diagnostic" + + +@dataclass(frozen=True, kw_only=True) +class EntityStateChangedEvent: + """Event for when an entity state changes.""" + + event_type: Final[str] = "entity" + event: Final[str] = STATE_CHANGED + platform: str + unique_id: str + device_ieee: Optional[EUI64] = None + endpoint_id: Optional[int] = None + group_id: Optional[int] = None + + +class BaseEntity(LogMixin, EventBase): + """Base class for entities.""" + + PLATFORM: Platform = Platform.UNKNOWN + + _unique_id_suffix: str | None = None + """suffix to add to the unique_id of the entity. Used for multi + entities using the same cluster handler/cluster id for the entity.""" + + def __init__(self, unique_id: str, **kwargs: Any) -> None: + """Initialize the platform entity.""" + super().__init__() + self._unique_id: str = unique_id + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" + self._state: Any = None + self._previous_state: Any = None + self._tracked_tasks: list[asyncio.Task] = [] + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id + + @abc.abstractmethod + def get_identifiers(self) -> dict[str, str | int]: + """Return a dict with the information necessary to identify this entity.""" + + def get_state(self) -> dict: + """Return the arguments to use in the command.""" + return { + "class_name": self.__class__.__name__, + } + + async def async_update(self) -> None: + """Retrieve latest state.""" + + async def on_remove(self) -> None: + """Cancel tasks this entity owns.""" + tasks = [t for t in self._tracked_tasks if not (t.done() or t.cancelled())] + for task in tasks: + self.debug("Cancelling task: %s", task) + task.cancel() + with suppress(asyncio.CancelledError): + await asyncio.gather(*tasks, return_exceptions=True) + + def maybe_emit_state_changed_event(self) -> None: + """Send the state of this platform entity.""" + state = self.get_state() + if self._previous_state != state: + self.emit(STATE_CHANGED, EntityStateChangedEvent(**self.get_identifiers())) + self._previous_state = state + + def to_json(self) -> dict: + """Return a JSON representation of the platform entity.""" + return { + "unique_id": self._unique_id, + "platform": self.PLATFORM, + "class_name": self.__class__.__name__, + "state": self.get_state(), + } + + def log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: + """Log a message.""" + msg = f"%s: {msg}" + args = (self._unique_id,) + args + _LOGGER.log(level, msg, *args, **kwargs) + + +class PlatformEntity(BaseEntity): + """Class that represents an entity for a device platform.""" + + _attr_entity_registry_enabled_default: bool + _attr_translation_key: str | None + _attr_unit_of_measurement: str | None + _attr_entity_category: EntityCategory | None + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ): + """Initialize the platform entity.""" + super().__init__(unique_id, **kwargs) + ieeetail = "".join([f"{o:02x}" for o in device.ieee[:4]]) + ch_names = ", ".join(sorted(ch.name for ch in cluster_handlers)) + self._name: str = f"{device.name} {ieeetail} {ch_names}" + if self._unique_id_suffix: + self._name += f" {self._unique_id_suffix}" + self._cluster_handlers: list[ClusterHandler] = cluster_handlers + self.cluster_handlers: dict[str, ClusterHandler] = {} + for cluster_handler in cluster_handlers: + self.cluster_handlers[cluster_handler.name] = cluster_handler + self._device: Device = device + self._endpoint = endpoint + # we double create these in discovery tests because we reissue the create calls to count and prove them out + if self.unique_id not in self._device.platform_entities: + self._device.platform_entities[self.unique_id] = self + + @classmethod + def create_platform_entity( + cls: type[PlatformEntity], + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> PlatformEntity | None: + """Entity Factory. + + Return a platform entity if it is a supported configuration, otherwise return None + """ + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + if entity_metadata.initially_disabled: + self._attr_entity_registry_enabled_default = False + + has_device_class = hasattr(entity_metadata, "device_class") + has_attribute_name = hasattr(entity_metadata, "attribute_name") + has_command_name = hasattr(entity_metadata, "command_name") + if not has_device_class or ( + has_device_class and entity_metadata.device_class is None + ): + if entity_metadata.translation_key: + self._attr_translation_key = entity_metadata.translation_key + elif has_attribute_name: + self._attr_translation_key = entity_metadata.attribute_name + elif has_command_name: + self._attr_translation_key = entity_metadata.command_name + if has_attribute_name: + self._unique_id_suffix = entity_metadata.attribute_name + elif has_command_name: + self._unique_id_suffix = entity_metadata.command_name + if entity_metadata.entity_type is EntityType.CONFIG: + self._attr_entity_category = EntityCategory.CONFIG + elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + else: + self._attr_entity_category = None + + @property + def device(self) -> Device: + """Return the device.""" + return self._device + + @property + def endpoint(self) -> Endpoint: + """Return the endpoint.""" + return self._endpoint + + @property + def should_poll(self) -> bool: + """Return True if we need to poll for state changes.""" + return False + + @property + def available(self) -> bool: + """Return true if the device this entity belongs to is available.""" + return self.device.available + + @property + def name(self) -> str: + """Return the name of the platform entity.""" + return self._name + + def get_identifiers(self) -> dict[str, str | int]: + """Return a dict with the information necessary to identify this entity.""" + return { + "unique_id": self.unique_id, + "platform": self.PLATFORM, + "device_ieee": self.device.ieee, + "endpoint_id": self.endpoint.id, + } + + def to_json(self) -> dict: + """Return a JSON representation of the platform entity.""" + json = super().to_json() + json["name"] = self._name + json["cluster_handlers"] = [ch.to_json() for ch in self._cluster_handlers] + json["device_ieee"] = str(self._device.ieee) + json["endpoint_id"] = self._endpoint.id + json["available"] = self.available + return json + + def get_state(self) -> dict: + """Return the arguments to use in the command.""" + state = super().get_state() + state["available"] = self.available + return state + + async def async_update(self) -> None: + """Retrieve latest state.""" + self.debug("polling current state") + tasks = [ + cluster_handler.async_update() + for cluster_handler in self.cluster_handlers.values() + if hasattr(cluster_handler, "async_update") + ] + if tasks: + await asyncio.gather(*tasks) + self.maybe_emit_state_changed_event() + + +class GroupEntity(BaseEntity): + """A base class for group entities.""" + + def __init__( + self, + group: Group, + ) -> None: + """Initialize a group.""" + super().__init__(f"{self.PLATFORM}.{group.group_id}") + self._name: str = f"{group.name}_0x{group.group_id:04x}" + self._group: Group = group + self._group.register_group_entity(self) + self.update() + + @property + def name(self) -> str: + """Return the name of the group entity.""" + return self._name + + @property + def group_id(self) -> int: + """Return the group id.""" + return self._group.group_id + + @property + def group(self) -> Group: + """Return the group.""" + return self._group + + def get_identifiers(self) -> dict[str, str | int]: + """Return a dict with the information necessary to identify this entity.""" + return { + "unique_id": self.unique_id, + "platform": self.PLATFORM, + "group_id": self.group.group_id, + } + + def update(self, _: Any | None = None) -> None: + """Update the state of this group entity.""" + + def to_json(self) -> dict[str, Any]: + """Return a JSON representation of the group.""" + json = super().to_json() + json["name"] = self._name + json["group_id"] = self.group_id + return json diff --git a/zha/application/platforms/alarm_control_panel.py b/zha/application/platforms/alarm_control_panel.py deleted file mode 100644 index 3ff5202a..00000000 --- a/zha/application/platforms/alarm_control_panel.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Alarm control panels on Zigbee Home Automation networks.""" - -from __future__ import annotations - -import functools -from typing import TYPE_CHECKING - -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanelEntity, - AlarmControlPanelEntityFeature, - CodeFormat, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, - Platform, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from zigpy.zcl.clusters.security import IasAce - -from .core import discovery -from .core.cluster_handlers.security import ( - SIGNAL_ALARM_TRIGGERED, - SIGNAL_ARMED_STATE_CHANGED, - IasAceClusterHandler, -) -from .core.const import ( - CLUSTER_HANDLER_IAS_ACE, - CONF_ALARM_ARM_REQUIRES_CODE, - CONF_ALARM_FAILED_TRIES, - CONF_ALARM_MASTER_CODE, - SIGNAL_ADD_ENTITIES, - ZHA_ALARM_OPTIONS, -) -from .core.helpers import async_get_zha_config_value, get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity - -if TYPE_CHECKING: - from .core.device import ZHADevice - -STRICT_MATCH = functools.partial( - ZHA_ENTITIES.strict_match, Platform.ALARM_CONTROL_PANEL -) - -IAS_ACE_STATE_MAP = { - IasAce.PanelStatus.Panel_Disarmed: STATE_ALARM_DISARMED, - IasAce.PanelStatus.Armed_Stay: STATE_ALARM_ARMED_HOME, - IasAce.PanelStatus.Armed_Night: STATE_ALARM_ARMED_NIGHT, - IasAce.PanelStatus.Armed_Away: STATE_ALARM_ARMED_AWAY, - IasAce.PanelStatus.In_Alarm: STATE_ALARM_TRIGGERED, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation alarm control panel from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.ALARM_CONTROL_PANEL] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) - - -@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_ACE) -class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): - """Entity for ZHA alarm control devices.""" - - _attr_translation_key: str = "alarm_control_panel" - _attr_code_format = CodeFormat.TEXT - _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.TRIGGER - ) - - def __init__( - self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs - ) -> None: - """Initialize the ZHA alarm control device.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - cfg_entry = zha_device.gateway.config_entry - self._cluster_handler: IasAceClusterHandler = cluster_handlers[0] - self._cluster_handler.panel_code = async_get_zha_config_value( - cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" - ) - self._cluster_handler.code_required_arm_actions = async_get_zha_config_value( - cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_ARM_REQUIRES_CODE, False - ) - self._cluster_handler.max_invalid_tries = async_get_zha_config_value( - cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 - ) - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._cluster_handler, SIGNAL_ARMED_STATE_CHANGED, self.async_set_armed_mode - ) - self.async_accept_signal( - self._cluster_handler, SIGNAL_ALARM_TRIGGERED, self.async_alarm_trigger - ) - - @callback - def async_set_armed_mode(self) -> None: - """Set the entity state.""" - self.async_write_ha_state() - - @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return self._cluster_handler.code_required_arm_actions - - async def async_alarm_disarm(self, code: str | None = None) -> None: - """Send disarm command.""" - self._cluster_handler.arm(IasAce.ArmMode.Disarm, code, 0) - self.async_write_ha_state() - - async def async_alarm_arm_home(self, code: str | None = None) -> None: - """Send arm home command.""" - self._cluster_handler.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) - self.async_write_ha_state() - - async def async_alarm_arm_away(self, code: str | None = None) -> None: - """Send arm away command.""" - self._cluster_handler.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) - self.async_write_ha_state() - - async def async_alarm_arm_night(self, code: str | None = None) -> None: - """Send arm night command.""" - self._cluster_handler.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) - self.async_write_ha_state() - - async def async_alarm_trigger(self, code: str | None = None) -> None: - """Send alarm trigger command.""" - self.async_write_ha_state() - - @property - def state(self) -> str | None: - """Return the state of the entity.""" - return IAS_ACE_STATE_MAP.get(self._cluster_handler.armed_state) diff --git a/zha/application/platforms/alarm_control_panel/__init__.py b/zha/application/platforms/alarm_control_panel/__init__.py new file mode 100644 index 00000000..cd80ff6b --- /dev/null +++ b/zha/application/platforms/alarm_control_panel/__init__.py @@ -0,0 +1,160 @@ +"""Alarm control panels on Zigbee Home Automation networks.""" + +from __future__ import annotations + +import functools +import logging +from typing import TYPE_CHECKING + +from zigpy.zcl.clusters.security import IasAce + +from zha.application import Platform +from zha.application.const import ( + CONF_ALARM_ARM_REQUIRES_CODE, + CONF_ALARM_FAILED_TRIES, + CONF_ALARM_MASTER_CODE, + ZHA_ALARM_OPTIONS, +) +from zha.application.helpers import async_get_zha_config_value +from zha.application.platforms import PlatformEntity +from zha.application.platforms.alarm_control_panel.const import ( + IAS_ACE_STATE_MAP, + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, + SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_TRIGGER, + AlarmState, + CodeFormat, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_IAS_ACE, + CLUSTER_HANDLER_STATE_CHANGED, +) +from zha.zigbee.cluster_handlers.security import ( + ClusterHandlerStateChangedEvent, + IasAceClusterHandler, +) + +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + +STRICT_MATCH = functools.partial( + PLATFORM_ENTITIES.strict_match, Platform.ALARM_CONTROL_PANEL +) + +_LOGGER = logging.getLogger(__name__) + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_ACE) +class AlarmControlPanel(PlatformEntity): + """Entity for ZHA alarm control devices.""" + + _attr_translation_key: str = "alarm_control_panel" + PLATFORM = Platform.ALARM_CONTROL_PANEL + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ) -> None: + """Initialize the ZHA alarm control device.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + config = device.gateway.config + self._cluster_handler: IasAceClusterHandler = cluster_handlers[0] + self._cluster_handler.panel_code = async_get_zha_config_value( + config, ZHA_ALARM_OPTIONS, CONF_ALARM_MASTER_CODE, "1234" + ) + self._cluster_handler.code_required_arm_actions = async_get_zha_config_value( + config, ZHA_ALARM_OPTIONS, CONF_ALARM_ARM_REQUIRES_CODE, False + ) + self._cluster_handler.max_invalid_tries = async_get_zha_config_value( + config, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 + ) + self._cluster_handler.on_event( + CLUSTER_HANDLER_STATE_CHANGED, self._handle_event_protocol + ) + + def handle_cluster_handler_state_changed( + self, + event: ClusterHandlerStateChangedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle state changed on cluster.""" + self.maybe_emit_state_changed_event() + + @property + def code_arm_required(self) -> bool: + """Whether the code is required for arm actions.""" + return self._cluster_handler.code_required_arm_actions + + @property + def code_format(self) -> CodeFormat: + """Code format or None if no code is required.""" + return CodeFormat.NUMBER + + @property + def translation_key(self) -> str: + """Return the translation key.""" + return self._attr_translation_key + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + self._cluster_handler.arm(IasAce.ArmMode.Disarm, code, 0) + self.maybe_emit_state_changed_event() + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + self._cluster_handler.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) + self.maybe_emit_state_changed_event() + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + self._cluster_handler.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) + self.maybe_emit_state_changed_event() + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + self._cluster_handler.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) + self.maybe_emit_state_changed_event() + + async def async_alarm_trigger(self, code: str | None = None) -> None: # pylint: disable=unused-argument + """Send alarm trigger command.""" + self._cluster_handler.panic() + self.maybe_emit_state_changed_event() + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return ( + SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_AWAY + | SUPPORT_ALARM_ARM_NIGHT + | SUPPORT_ALARM_TRIGGER + ) + + def to_json(self) -> dict: + """Return a JSON representation of the alarm control panel.""" + json = super().to_json() + json["supported_features"] = self.supported_features + json["code_arm_required"] = self.code_arm_required + json["code_format"] = self.code_format + json["translation_key"] = self.translation_key + return json + + @property + def state(self) -> str: + """Return the state of the entity.""" + return IAS_ACE_STATE_MAP.get( + self._cluster_handler.armed_state, AlarmState.UNKNOWN + ) + + def get_state(self) -> dict: + """Get the state of the alarm control panel.""" + response = super().get_state() + response["state"] = self.state + return response diff --git a/zha/application/platforms/alarm_control_panel/const.py b/zha/application/platforms/alarm_control_panel/const.py new file mode 100644 index 00000000..a5bdec71 --- /dev/null +++ b/zha/application/platforms/alarm_control_panel/const.py @@ -0,0 +1,59 @@ +"""Constants for the alarm control panel platform.""" + +from enum import IntFlag, StrEnum +from typing import Final + +from zigpy.zcl.clusters.security import IasAce + +SUPPORT_ALARM_ARM_HOME: Final[int] = 1 +SUPPORT_ALARM_ARM_AWAY: Final[int] = 2 +SUPPORT_ALARM_ARM_NIGHT: Final[int] = 4 +SUPPORT_ALARM_TRIGGER: Final[int] = 8 +SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final[int] = 16 +SUPPORT_ALARM_ARM_VACATION: Final[int] = 32 + + +class AlarmState(StrEnum): + """Alarm state.""" + + DISARMED = "disarmed" + ARMED_HOME = "armed_home" + ARMED_AWAY = "armed_away" + ARMED_NIGHT = "armed_night" + ARMED_VACATION = "armed_vacation" + ARMED_CUSTOM_BYPASS = "armed_custom_bypass" + PENDING = "pending" + ARMING = "arming" + DISARMING = "disarming" + TRIGGERED = "triggered" + UNKNOWN = "unknown" + + +IAS_ACE_STATE_MAP = { + IasAce.PanelStatus.Panel_Disarmed: AlarmState.DISARMED, + IasAce.PanelStatus.Armed_Stay: AlarmState.ARMED_HOME, + IasAce.PanelStatus.Armed_Night: AlarmState.ARMED_NIGHT, + IasAce.PanelStatus.Armed_Away: AlarmState.ARMED_AWAY, + IasAce.PanelStatus.In_Alarm: AlarmState.TRIGGERED, +} + +ATTR_CHANGED_BY: Final[str] = "changed_by" +ATTR_CODE_ARM_REQUIRED: Final[str] = "code_arm_required" + + +class CodeFormat(StrEnum): + """Code formats for the Alarm Control Panel.""" + + TEXT = "text" + NUMBER = "number" + + +class AlarmControlPanelEntityFeature(IntFlag): + """Supported features of the alarm control panel entity.""" + + ARM_HOME = 1 + ARM_AWAY = 2 + ARM_NIGHT = 4 + TRIGGER = 8 + ARM_CUSTOM_BYPASS = 16 + ARM_VACATION = 32 diff --git a/zha/application/platforms/binary_sensor.py b/zha/application/platforms/binary_sensor/__init__.py similarity index 65% rename from zha/application/platforms/binary_sensor.py rename to zha/application/platforms/binary_sensor/__init__.py index 56d80832..866c8813 100644 --- a/zha/application/platforms/binary_sensor.py +++ b/zha/application/platforms/binary_sensor/__init__.py @@ -3,98 +3,83 @@ from __future__ import annotations import functools -from typing import Any +import logging +from typing import TYPE_CHECKING -from homeassistant.components.binary_sensor import ( +from zigpy.quirks.v2 import BinarySensorMetadata + +from zha.application import Platform +from zha.application.const import ATTR_DEVICE_CLASS, ENTITY_METADATA +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.platforms.binary_sensor.const import ( + IAS_ZONE_CLASS_MAPPING, BinarySensorDeviceClass, - BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, EntityCategory, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from zigpy.quirks.v2 import BinarySensorMetadata, EntityMetadata -import zigpy.types as t -from zigpy.zcl.clusters.general import OnOff -from zigpy.zcl.clusters.security import IasZone - -from .core import discovery -from .core.const import ( +from zha.application.platforms.helpers import validate_device_class +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( CLUSTER_HANDLER_ACCELEROMETER, + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_BINARY_INPUT, CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, - QUIRK_METADATA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity - -# Zigbee Cluster Library Zone Type to Home Assistant device class -IAS_ZONE_CLASS_MAPPING = { - IasZone.ZoneType.Motion_Sensor: BinarySensorDeviceClass.MOTION, - IasZone.ZoneType.Contact_Switch: BinarySensorDeviceClass.OPENING, - IasZone.ZoneType.Fire_Sensor: BinarySensorDeviceClass.SMOKE, - IasZone.ZoneType.Water_Sensor: BinarySensorDeviceClass.MOISTURE, - IasZone.ZoneType.Carbon_Monoxide_Sensor: BinarySensorDeviceClass.GAS, - IasZone.ZoneType.Vibration_Movement_Sensor: BinarySensorDeviceClass.VIBRATION, -} - -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) -CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR ) +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation binary sensor from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.BINARY_SENSOR] - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.BINARY_SENSOR) +MULTI_MATCH = functools.partial( + PLATFORM_ENTITIES.multipass_match, Platform.BINARY_SENSOR +) +CONFIG_DIAGNOSTIC_MATCH = functools.partial( + PLATFORM_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR +) +_LOGGER = logging.getLogger(__name__) -class BinarySensor(ZhaEntity, BinarySensorEntity): +class BinarySensor(PlatformEntity): """ZHA BinarySensor.""" + _attr_device_class: BinarySensorDeviceClass | None _attribute_name: str - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: + PLATFORM: Platform = Platform.BINARY_SENSOR + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ) -> None: """Initialize the ZHA binary sensor.""" self._cluster_handler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata(self, entity_metadata: BinarySensorMetadata) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - binary_sensor_metadata: BinarySensorMetadata = entity_metadata.entity_metadata - self._attribute_name = binary_sensor_metadata.attribute_name - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) + self._attribute_name = entity_metadata.attribute_name + if entity_metadata.device_class is not None: + self._attr_device_class = validate_device_class( + BinarySensorDeviceClass, + entity_metadata.device_class, + Platform.BINARY_SENSOR.value, + _LOGGER, + ) @property def is_on(self) -> bool: @@ -104,10 +89,43 @@ def is_on(self) -> bool: return False return self.parse(raw_state) - @callback - def async_set_state(self, attr_id, attr_name, value): - """Set the state.""" - self.async_write_ha_state() + @property + def device_class(self) -> BinarySensorDeviceClass | None: + """Return the class of this entity.""" + return self._attr_device_class + + def get_state(self) -> dict: + """Return the state of the binary sensor.""" + response = super().get_state() + response["state"] = self.is_on + return response + + def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Handle attribute updates from the cluster handler.""" + if self._attribute_name is None or self._attribute_name != event.attribute_name: + return + self._state = bool(event.attribute_value) + self.maybe_emit_state_changed_event() + + async def async_update(self) -> None: + """Attempt to retrieve on off state from the binary sensor.""" + await super().async_update() + attribute = getattr(self._cluster_handler, "value_attribute", "on_off") + # this is a cached read to get the value for state mgt so there is no double read + attr_value = await self._cluster_handler.get_attribute_value(attribute) + if attr_value is not None: + self._state = attr_value + self.maybe_emit_state_changed_event() + + def to_json(self) -> dict: + """Return a JSON representation of the binary sensor.""" + json = super().to_json() + json["sensor_attribute"] = self._attribute_name + if hasattr(self, ATTR_DEVICE_CLASS): + json[ATTR_DEVICE_CLASS] = self._attr_device_class + return json @staticmethod def parse(value: bool | int) -> bool: @@ -146,15 +164,18 @@ class Opening(BinarySensor): _attribute_name = "on_off" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING + # pylint: disable=pointless-string-statement + """TODO # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. # We need to manually restore the last state from the sensor state to the runtime cache for now. - @callback + def async_restore_last_state(self, last_state): - """Restore previous state to zigpy cache.""" + #Restore previous state to zigpy cache. self._cluster_handler.cluster.update_attribute( OnOff.attributes_by_name[self._attribute_name].id, t.Bool.true if last_state.state == STATE_ON else t.Bool.false, ) + """ @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_INPUT) @@ -189,6 +210,19 @@ class IASZone(BinarySensor): _attribute_name = "zone_status" + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ) -> None: + """Initialize the ZHA binary sensor.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._attr_device_class = self.device_class + self._attr_translation_key = self.translation_key + @property def translation_key(self) -> str | None: """Return the name of the sensor.""" @@ -208,35 +242,9 @@ def parse(value: bool | int) -> bool: """Parse the raw attribute into a bool state.""" return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state - # temporary code to migrate old IasZone sensors to update attribute cache state once - # remove in 2024.4.0 - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return state attributes.""" - return {"migrated_to_cache": True} # writing new state means we're migrated - - # temporary migration code - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - # trigger migration if extra state attribute is not present - if "migrated_to_cache" not in last_state.attributes: - self.migrate_to_zigpy_cache(last_state) - - # temporary migration code - @callback - def migrate_to_zigpy_cache(self, last_state): - """Save old IasZone sensor state to attribute cache.""" - # previous HA versions did not update the attribute cache for IasZone sensors, so do it once here - # a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute - if last_state.state == STATE_ON: - migrated_state = IasZone.ZoneStatus.Alarm_1 - else: - migrated_state = IasZone.ZoneStatus(0) - - self._cluster_handler.cluster.update_attribute( - IasZone.attributes_by_name[self._attribute_name].id, migrated_state - ) + async def async_update(self) -> None: + """Attempt to retrieve on off state from the IAS Zone sensor.""" + await PlatformEntity.async_update(self) @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ZONE, models={"WL4200", "WL4200S"}) diff --git a/zha/application/platforms/binary_sensor/const.py b/zha/application/platforms/binary_sensor/const.py new file mode 100644 index 00000000..02ec03b3 --- /dev/null +++ b/zha/application/platforms/binary_sensor/const.py @@ -0,0 +1,104 @@ +"""Constants for the binary_sensor platform.""" + +from enum import StrEnum + +from zigpy.zcl.clusters.security import IasZone + + +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" + + +# Zigbee Cluster Library Zone Type to Home Assistant device class +IAS_ZONE_CLASS_MAPPING = { + IasZone.ZoneType.Motion_Sensor: BinarySensorDeviceClass.MOTION, + IasZone.ZoneType.Contact_Switch: BinarySensorDeviceClass.OPENING, + IasZone.ZoneType.Fire_Sensor: BinarySensorDeviceClass.SMOKE, + IasZone.ZoneType.Water_Sensor: BinarySensorDeviceClass.MOISTURE, + IasZone.ZoneType.Carbon_Monoxide_Sensor: BinarySensorDeviceClass.GAS, + IasZone.ZoneType.Vibration_Movement_Sensor: BinarySensorDeviceClass.VIBRATION, +} diff --git a/zha/application/platforms/button.py b/zha/application/platforms/button/__init__.py similarity index 55% rename from zha/application/platforms/button.py rename to zha/application/platforms/button/__init__.py index 347bda45..f08bca27 100644 --- a/zha/application/platforms/button.py +++ b/zha/application/platforms/button/__init__.py @@ -6,62 +6,34 @@ import logging from typing import TYPE_CHECKING, Any, Self -from homeassistant.components.button import ButtonDeviceClass, ButtonEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from zigpy.quirks.v2 import ( - EntityMetadata, - WriteAttributeButtonMetadata, - ZCLCommandButtonMetadata, -) +from zigpy.quirks.v2 import WriteAttributeButtonMetadata, ZCLCommandButtonMetadata -from .core import discovery -from .core.const import CLUSTER_HANDLER_IDENTIFY, QUIRK_METADATA, SIGNAL_ADD_ENTITIES -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity +from zha.application import Platform +from zha.application.const import ENTITY_METADATA +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.platforms.button.const import DEFAULT_DURATION, ButtonDeviceClass +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IDENTIFY if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BUTTON) +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.BUTTON) CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.BUTTON + PLATFORM_ENTITIES.config_diagnostic_match, Platform.BUTTON ) -DEFAULT_DURATION = 5 # seconds _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation button from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.BUTTON] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - ), - ) - config_entry.async_on_unload(unsub) - - -class ZHAButton(ZhaEntity, ButtonEntity): +class Button(PlatformEntity): """Defines a ZHA button.""" + PLATFORM = Platform.BUTTON + _command_name: str _args: list[Any] _kwargs: dict[str, Any] @@ -69,23 +41,25 @@ class ZHAButton(ZhaEntity, ButtonEntity): def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, - ) -> None: - """Init this button.""" + ): + """Initialize button.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata( + self, entity_metadata: ZCLCommandButtonMetadata + ) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - button_metadata: ZCLCommandButtonMetadata = entity_metadata.entity_metadata - self._command_name = button_metadata.command_name - self._args = button_metadata.args - self._kwargs = button_metadata.kwargs + self._command_name = entity_metadata.command_name + self._args = entity_metadata.args + self._kwargs = entity_metadata.kwargs def get_args(self) -> list[Any]: """Return the arguments to use in the command.""" @@ -102,28 +76,35 @@ async def async_press(self) -> None: kwargs = self.get_kwargs() or {} await command(*arguments, **kwargs) + def to_json(self) -> dict: + """Return a JSON representation of the button.""" + json = super().to_json() + json["command"] = self._command_name + return json + @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IDENTIFY) -class ZHAIdentifyButton(ZHAButton): +class IdentifyButton(Button): """Defines a ZHA identify button.""" @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. Return entity if it is a supported configuration, otherwise return None """ - if ZHA_ENTITIES.prevent_entity_creation( - Platform.BUTTON, zha_device.ieee, CLUSTER_HANDLER_IDENTIFY + if PLATFORM_ENTITIES.prevent_entity_creation( + Platform.BUTTON, device.ieee, CLUSTER_HANDLER_IDENTIFY ): return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) _attr_device_class = ButtonDeviceClass.IDENTIFY _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -132,38 +113,41 @@ def create_entity( _args = [DEFAULT_DURATION] -class ZHAAttributeButton(ZhaEntity, ButtonEntity): +class WriteAttributeButton(PlatformEntity): """Defines a ZHA button, which writes a value to an attribute.""" + PLATFORM = Platform.BUTTON + _attribute_name: str _attribute_value: Any = None def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this button.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata( + self, entity_metadata: WriteAttributeButtonMetadata + ) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - button_metadata: WriteAttributeButtonMetadata = entity_metadata.entity_metadata - self._attribute_name = button_metadata.attribute_name - self._attribute_value = button_metadata.attribute_value + self._attribute_name = entity_metadata.attribute_name + self._attribute_value = entity_metadata.attribute_value async def async_press(self) -> None: """Write attribute with defined value.""" await self._cluster_handler.write_attributes_safe( {self._attribute_name: self._attribute_value} ) - self.async_write_ha_state() @CONFIG_DIAGNOSTIC_MATCH( @@ -172,7 +156,7 @@ async def async_press(self) -> None: "_TZE200_htnnfasr", }, ) -class FrostLockResetButton(ZHAAttributeButton): +class FrostLockResetButton(WriteAttributeButton): """Defines a ZHA frost lock reset button.""" _unique_id_suffix = "reset_frost_lock" @@ -186,7 +170,7 @@ class FrostLockResetButton(ZHAAttributeButton): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class NoPresenceStatusResetButton(ZHAAttributeButton): +class NoPresenceStatusResetButton(WriteAttributeButton): """Defines a ZHA no presence status reset button.""" _unique_id_suffix = "reset_no_presence_status" @@ -198,7 +182,7 @@ class NoPresenceStatusResetButton(ZHAAttributeButton): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -class AqaraPetFeederFeedButton(ZHAAttributeButton): +class AqaraPetFeederFeedButton(WriteAttributeButton): """Defines a feed button for the aqara c1 pet feeder.""" _unique_id_suffix = "feeding" @@ -210,7 +194,7 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraSelfTestButton(ZHAAttributeButton): +class AqaraSelfTestButton(WriteAttributeButton): """Defines a ZHA self-test button for Aqara smoke sensors.""" _unique_id_suffix = "self_test" diff --git a/zha/application/platforms/button/const.py b/zha/application/platforms/button/const.py new file mode 100644 index 00000000..d337adfb --- /dev/null +++ b/zha/application/platforms/button/const.py @@ -0,0 +1,13 @@ +"""Constants for the button platform.""" + +from enum import StrEnum + +DEFAULT_DURATION = 5 # seconds + + +class ButtonDeviceClass(StrEnum): + """Device class for buttons.""" + + IDENTIFY = "identify" + RESTART = "restart" + UPDATE = "update" diff --git a/zha/application/platforms/climate.py b/zha/application/platforms/climate.py deleted file mode 100644 index 2a0bfc2c..00000000 --- a/zha/application/platforms/climate.py +++ /dev/null @@ -1,824 +0,0 @@ -"""Climate on Zigbee Home Automation networks. - -For more details on this platform, please refer to the documentation -at https://home-assistant.io/components/zha.climate/ -""" - -from __future__ import annotations - -from datetime import datetime, timedelta -import functools -from random import randint -from typing import Any - -from homeassistant.components.climate import ( - ATTR_HVAC_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - FAN_AUTO, - FAN_ON, - PRESET_AWAY, - PRESET_BOOST, - PRESET_COMFORT, - PRESET_ECO, - PRESET_NONE, - ClimateEntity, - ClimateEntityFeature, - HVACAction, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_TENTHS, - Platform, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util -from zigpy.zcl.clusters.hvac import Fan as F, Thermostat as T - -from .core import discovery -from .core.const import ( - CLUSTER_HANDLER_FAN, - CLUSTER_HANDLER_THERMOSTAT, - PRESET_COMPLEX, - PRESET_SCHEDULE, - PRESET_TEMP_MANUAL, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity - -ATTR_SYS_MODE = "system_mode" -ATTR_RUNNING_MODE = "running_mode" -ATTR_SETPT_CHANGE_SRC = "setpoint_change_source" -ATTR_SETPT_CHANGE_AMT = "setpoint_change_amount" -ATTR_OCCUPANCY = "occupancy" -ATTR_PI_COOLING_DEMAND = "pi_cooling_demand" -ATTR_PI_HEATING_DEMAND = "pi_heating_demand" -ATTR_OCCP_COOL_SETPT = "occupied_cooling_setpoint" -ATTR_OCCP_HEAT_SETPT = "occupied_heating_setpoint" -ATTR_UNOCCP_HEAT_SETPT = "unoccupied_heating_setpoint" -ATTR_UNOCCP_COOL_SETPT = "unoccupied_cooling_setpoint" - - -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.CLIMATE) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.CLIMATE) -RUNNING_MODE = {0x00: HVACMode.OFF, 0x03: HVACMode.COOL, 0x04: HVACMode.HEAT} - -SEQ_OF_OPERATION = { - 0x00: [HVACMode.OFF, HVACMode.COOL], # cooling only - 0x01: [HVACMode.OFF, HVACMode.COOL], # cooling with reheat - 0x02: [HVACMode.OFF, HVACMode.HEAT], # heating only - 0x03: [HVACMode.OFF, HVACMode.HEAT], # heating with reheat - # cooling and heating 4-pipes - 0x04: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], - # cooling and heating 4-pipes - 0x05: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], - 0x06: [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF], # centralite specific - 0x07: [HVACMode.HEAT_COOL, HVACMode.OFF], # centralite specific -} - -HVAC_MODE_2_SYSTEM = { - HVACMode.OFF: T.SystemMode.Off, - HVACMode.HEAT_COOL: T.SystemMode.Auto, - HVACMode.COOL: T.SystemMode.Cool, - HVACMode.HEAT: T.SystemMode.Heat, - HVACMode.FAN_ONLY: T.SystemMode.Fan_only, - HVACMode.DRY: T.SystemMode.Dry, -} - -SYSTEM_MODE_2_HVAC = { - T.SystemMode.Off: HVACMode.OFF, - T.SystemMode.Auto: HVACMode.HEAT_COOL, - T.SystemMode.Cool: HVACMode.COOL, - T.SystemMode.Heat: HVACMode.HEAT, - T.SystemMode.Emergency_Heating: HVACMode.HEAT, - T.SystemMode.Pre_cooling: HVACMode.COOL, # this is 'precooling'. is it the same? - T.SystemMode.Fan_only: HVACMode.FAN_ONLY, - T.SystemMode.Dry: HVACMode.DRY, - T.SystemMode.Sleep: HVACMode.OFF, -} - -ZCL_TEMP = 100 - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation sensor from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.CLIMATE] - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) - - -@MULTI_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - aux_cluster_handlers=CLUSTER_HANDLER_FAN, - stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, -) -class Thermostat(ZhaEntity, ClimateEntity): - """Representation of a ZHA Thermostat device.""" - - DEFAULT_MAX_TEMP = 35 - DEFAULT_MIN_TEMP = 7 - - _attr_precision = PRECISION_TENTHS - _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_translation_key: str = "thermostat" - _enable_turn_on_off_backwards_compatibility = False - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._thrm = self.cluster_handlers.get(CLUSTER_HANDLER_THERMOSTAT) - self._preset = PRESET_NONE - self._presets = [] - self._supported_flags = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - self._fan = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) - - @property - def current_temperature(self): - """Return the current temperature.""" - if self._thrm.local_temperature is None: - return None - return self._thrm.local_temperature / ZCL_TEMP - - @property - def extra_state_attributes(self): - """Return device specific state attributes.""" - data = {} - if self.hvac_mode: - mode = SYSTEM_MODE_2_HVAC.get(self._thrm.system_mode, "unknown") - data[ATTR_SYS_MODE] = f"[{self._thrm.system_mode}]/{mode}" - if self._thrm.occupancy is not None: - data[ATTR_OCCUPANCY] = self._thrm.occupancy - if self._thrm.occupied_cooling_setpoint is not None: - data[ATTR_OCCP_COOL_SETPT] = self._thrm.occupied_cooling_setpoint - if self._thrm.occupied_heating_setpoint is not None: - data[ATTR_OCCP_HEAT_SETPT] = self._thrm.occupied_heating_setpoint - if self._thrm.pi_heating_demand is not None: - data[ATTR_PI_HEATING_DEMAND] = self._thrm.pi_heating_demand - if self._thrm.pi_cooling_demand is not None: - data[ATTR_PI_COOLING_DEMAND] = self._thrm.pi_cooling_demand - - unoccupied_cooling_setpoint = self._thrm.unoccupied_cooling_setpoint - if unoccupied_cooling_setpoint is not None: - data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_cooling_setpoint - - unoccupied_heating_setpoint = self._thrm.unoccupied_heating_setpoint - if unoccupied_heating_setpoint is not None: - data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_heating_setpoint - return data - - @property - def fan_mode(self) -> str | None: - """Return current FAN mode.""" - if self._thrm.running_state is None: - return FAN_AUTO - - if self._thrm.running_state & ( - T.RunningState.Fan_State_On - | T.RunningState.Fan_2nd_Stage_On - | T.RunningState.Fan_3rd_Stage_On - ): - return FAN_ON - return FAN_AUTO - - @property - def fan_modes(self) -> list[str] | None: - """Return supported FAN modes.""" - if not self._fan: - return None - return [FAN_AUTO, FAN_ON] - - @property - def hvac_action(self) -> HVACAction | None: - """Return the current HVAC action.""" - if ( - self._thrm.pi_heating_demand is None - and self._thrm.pi_cooling_demand is None - ): - return self._rm_rs_action - return self._pi_demand_action - - @property - def _rm_rs_action(self) -> HVACAction | None: - """Return the current HVAC action based on running mode and running state.""" - - if (running_state := self._thrm.running_state) is None: - return None - if running_state & ( - T.RunningState.Heat_State_On | T.RunningState.Heat_2nd_Stage_On - ): - return HVACAction.HEATING - if running_state & ( - T.RunningState.Cool_State_On | T.RunningState.Cool_2nd_Stage_On - ): - return HVACAction.COOLING - if running_state & ( - T.RunningState.Fan_State_On - | T.RunningState.Fan_2nd_Stage_On - | T.RunningState.Fan_3rd_Stage_On - ): - return HVACAction.FAN - if running_state & T.RunningState.Idle: - return HVACAction.IDLE - if self.hvac_mode != HVACMode.OFF: - return HVACAction.IDLE - return HVACAction.OFF - - @property - def _pi_demand_action(self) -> HVACAction | None: - """Return the current HVAC action based on pi_demands.""" - - heating_demand = self._thrm.pi_heating_demand - if heating_demand is not None and heating_demand > 0: - return HVACAction.HEATING - cooling_demand = self._thrm.pi_cooling_demand - if cooling_demand is not None and cooling_demand > 0: - return HVACAction.COOLING - - if self.hvac_mode != HVACMode.OFF: - return HVACAction.IDLE - return HVACAction.OFF - - @property - def hvac_mode(self) -> HVACMode | None: - """Return HVAC operation mode.""" - return SYSTEM_MODE_2_HVAC.get(self._thrm.system_mode) - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available HVAC operation modes.""" - return SEQ_OF_OPERATION.get(self._thrm.ctrl_sequence_of_oper, [HVACMode.OFF]) - - @property - def preset_mode(self) -> str: - """Return current preset mode.""" - return self._preset - - @property - def preset_modes(self) -> list[str] | None: - """Return supported preset modes.""" - return self._presets - - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - features = self._supported_flags - if HVACMode.HEAT_COOL in self.hvac_modes: - features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - if self._fan is not None: - self._supported_flags |= ClimateEntityFeature.FAN_MODE - return features - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - temp = None - if self.hvac_mode == HVACMode.COOL: - if self.preset_mode == PRESET_AWAY: - temp = self._thrm.unoccupied_cooling_setpoint - else: - temp = self._thrm.occupied_cooling_setpoint - elif self.hvac_mode == HVACMode.HEAT: - if self.preset_mode == PRESET_AWAY: - temp = self._thrm.unoccupied_heating_setpoint - else: - temp = self._thrm.occupied_heating_setpoint - if temp is None: - return temp - return round(temp / ZCL_TEMP, 1) - - @property - def target_temperature_high(self): - """Return the upper bound temperature we try to reach.""" - if self.hvac_mode != HVACMode.HEAT_COOL: - return None - if self.preset_mode == PRESET_AWAY: - temp = self._thrm.unoccupied_cooling_setpoint - else: - temp = self._thrm.occupied_cooling_setpoint - - if temp is None: - return temp - - return round(temp / ZCL_TEMP, 1) - - @property - def target_temperature_low(self): - """Return the lower bound temperature we try to reach.""" - if self.hvac_mode != HVACMode.HEAT_COOL: - return None - if self.preset_mode == PRESET_AWAY: - temp = self._thrm.unoccupied_heating_setpoint - else: - temp = self._thrm.occupied_heating_setpoint - - if temp is None: - return temp - return round(temp / ZCL_TEMP, 1) - - @property - def max_temp(self) -> float: - """Return the maximum temperature.""" - temps = [] - if HVACMode.HEAT in self.hvac_modes: - temps.append(self._thrm.max_heat_setpoint_limit) - if HVACMode.COOL in self.hvac_modes: - temps.append(self._thrm.max_cool_setpoint_limit) - - if not temps: - return self.DEFAULT_MAX_TEMP - return round(max(temps) / ZCL_TEMP, 1) - - @property - def min_temp(self) -> float: - """Return the minimum temperature.""" - temps = [] - if HVACMode.HEAT in self.hvac_modes: - temps.append(self._thrm.min_heat_setpoint_limit) - if HVACMode.COOL in self.hvac_modes: - temps.append(self._thrm.min_cool_setpoint_limit) - - if not temps: - return self.DEFAULT_MIN_TEMP - return round(min(temps) / ZCL_TEMP, 1) - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated - ) - - async def async_attribute_updated(self, attr_id, attr_name, value): - """Handle attribute update from device.""" - if ( - attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) - and self.preset_mode == PRESET_AWAY - ): - # occupancy attribute is an unreportable attribute, but if we get - # an attribute update for an "occupied" setpoint, there's a chance - # occupancy has changed - if await self._thrm.get_occupancy() is True: - self._preset = PRESET_NONE - - self.debug("Attribute '%s' = %s update", attr_name, value) - self.async_write_ha_state() - - async def async_set_fan_mode(self, fan_mode: str) -> None: - """Set fan mode.""" - if not self.fan_modes or fan_mode not in self.fan_modes: - self.warning("Unsupported '%s' fan mode", fan_mode) - return - - if fan_mode == FAN_ON: - mode = F.FanMode.On - else: - mode = F.FanMode.Auto - - await self._fan.async_set_speed(mode) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target operation mode.""" - if hvac_mode not in self.hvac_modes: - self.warning( - "can't set '%s' mode. Supported modes are: %s", - hvac_mode, - self.hvac_modes, - ) - return - - if await self._thrm.async_set_operation_mode(HVAC_MODE_2_SYSTEM[hvac_mode]): - self.async_write_ha_state() - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - if not self.preset_modes or preset_mode not in self.preset_modes: - self.debug("Preset mode '%s' is not supported", preset_mode) - return - - if self.preset_mode not in ( - preset_mode, - PRESET_NONE, - ): - await self.async_preset_handler(self.preset_mode, enable=False) - - if preset_mode != PRESET_NONE: - await self.async_preset_handler(preset_mode, enable=True) - - self._preset = preset_mode - self.async_write_ha_state() - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) - high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) - temp = kwargs.get(ATTR_TEMPERATURE) - hvac_mode = kwargs.get(ATTR_HVAC_MODE) - - if hvac_mode is not None: - await self.async_set_hvac_mode(hvac_mode) - - is_away = self.preset_mode == PRESET_AWAY - - if self.hvac_mode == HVACMode.HEAT_COOL: - if low_temp is not None: - await self._thrm.async_set_heating_setpoint( - temperature=int(low_temp * ZCL_TEMP), - is_away=is_away, - ) - if high_temp is not None: - await self._thrm.async_set_cooling_setpoint( - temperature=int(high_temp * ZCL_TEMP), - is_away=is_away, - ) - elif temp is not None: - if self.hvac_mode == HVACMode.COOL: - await self._thrm.async_set_cooling_setpoint( - temperature=int(temp * ZCL_TEMP), - is_away=is_away, - ) - elif self.hvac_mode == HVACMode.HEAT: - await self._thrm.async_set_heating_setpoint( - temperature=int(temp * ZCL_TEMP), - is_away=is_away, - ) - else: - self.debug("Not setting temperature for '%s' mode", self.hvac_mode) - return - else: - self.debug("incorrect %s setting for '%s' mode", kwargs, self.hvac_mode) - return - - self.async_write_ha_state() - - async def async_preset_handler(self, preset: str, enable: bool = False) -> None: - """Set the preset mode via handler.""" - - handler = getattr(self, f"async_preset_handler_{preset}") - await handler(enable) - - -@MULTI_MATCH( - cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT, "sinope_manufacturer_specific"}, - manufacturers="Sinope Technologies", - stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, -) -class SinopeTechnologiesThermostat(Thermostat): - """Sinope Technologies Thermostat.""" - - manufacturer = 0x119C - update_time_interval = timedelta(minutes=randint(45, 75)) - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._presets = [PRESET_AWAY, PRESET_NONE] - self._supported_flags |= ClimateEntityFeature.PRESET_MODE - self._manufacturer_ch = self.cluster_handlers["sinope_manufacturer_specific"] - - @property - def _rm_rs_action(self) -> HVACAction: - """Return the current HVAC action based on running mode and running state.""" - - running_mode = self._thrm.running_mode - if running_mode == T.SystemMode.Heat: - return HVACAction.HEATING - if running_mode == T.SystemMode.Cool: - return HVACAction.COOLING - - running_state = self._thrm.running_state - if running_state and running_state & ( - T.RunningState.Fan_State_On - | T.RunningState.Fan_2nd_Stage_On - | T.RunningState.Fan_3rd_Stage_On - ): - return HVACAction.FAN - if self.hvac_mode != HVACMode.OFF and running_mode == T.SystemMode.Off: - return HVACAction.IDLE - return HVACAction.OFF - - @callback - def _async_update_time(self, timestamp=None) -> None: - """Update thermostat's time display.""" - - secs_2k = ( - dt_util.now().replace(tzinfo=None) - datetime(2000, 1, 1, 0, 0, 0, 0) - ).total_seconds() - - self.debug("Updating time: %s", secs_2k) - self._manufacturer_ch.cluster.create_catching_task( - self._manufacturer_ch.write_attributes_safe( - {"secs_since_2k": secs_2k}, manufacturer=self.manufacturer - ) - ) - - async def async_added_to_hass(self) -> None: - """Run when about to be added to Hass.""" - await super().async_added_to_hass() - self.async_on_remove( - async_track_time_interval( - self.hass, self._async_update_time, self.update_time_interval - ) - ) - self._async_update_time() - - async def async_preset_handler_away(self, is_away: bool = False) -> None: - """Set occupancy.""" - mfg_code = self._zha_device.manufacturer_code - await self._thrm.write_attributes_safe( - {"set_occupancy": 0 if is_away else 1}, manufacturer=mfg_code - ) - - -@MULTI_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - aux_cluster_handlers=CLUSTER_HANDLER_FAN, - manufacturers={"Zen Within", "LUX"}, - stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, -) -class ZenWithinThermostat(Thermostat): - """Zen Within Thermostat implementation.""" - - -@MULTI_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - aux_cluster_handlers=CLUSTER_HANDLER_FAN, - manufacturers="Centralite", - models={"3157100", "3157100-E"}, - stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, -) -class CentralitePearl(ZenWithinThermostat): - """Centralite Pearl Thermostat implementation.""" - - -@STRICT_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - manufacturers={ - "_TZE200_ckud7u2l", - "_TZE200_ywdxldoj", - "_TZE200_cwnjrr72", - "_TZE200_2atgpdho", - "_TZE200_pvvbommb", - "_TZE200_4eeyebrt", - "_TZE200_cpmgn2cf", - "_TZE200_9sfg7gm0", - "_TZE200_8whxpsiw", - "_TYST11_ckud7u2l", - "_TYST11_ywdxldoj", - "_TYST11_cwnjrr72", - "_TYST11_2atgpdho", - }, -) -class MoesThermostat(Thermostat): - """Moes Thermostat implementation.""" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._presets = [ - PRESET_NONE, - PRESET_AWAY, - PRESET_SCHEDULE, - PRESET_COMFORT, - PRESET_ECO, - PRESET_BOOST, - PRESET_COMPLEX, - ] - self._supported_flags |= ClimateEntityFeature.PRESET_MODE - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return only the heat mode, because the device can't be turned off.""" - return [HVACMode.HEAT] - - async def async_attribute_updated(self, attr_id, attr_name, value): - """Handle attribute update from device.""" - if attr_name == "operation_preset": - if value == 0: - self._preset = PRESET_AWAY - if value == 1: - self._preset = PRESET_SCHEDULE - if value == 2: - self._preset = PRESET_NONE - if value == 3: - self._preset = PRESET_COMFORT - if value == 4: - self._preset = PRESET_ECO - if value == 5: - self._preset = PRESET_BOOST - if value == 6: - self._preset = PRESET_COMPLEX - await super().async_attribute_updated(attr_id, attr_name, value) - - async def async_preset_handler(self, preset: str, enable: bool = False) -> None: - """Set the preset mode.""" - mfg_code = self._zha_device.manufacturer_code - if not enable: - return await self._thrm.write_attributes_safe( - {"operation_preset": 2}, manufacturer=mfg_code - ) - if preset == PRESET_AWAY: - return await self._thrm.write_attributes_safe( - {"operation_preset": 0}, manufacturer=mfg_code - ) - if preset == PRESET_SCHEDULE: - return await self._thrm.write_attributes_safe( - {"operation_preset": 1}, manufacturer=mfg_code - ) - if preset == PRESET_COMFORT: - return await self._thrm.write_attributes_safe( - {"operation_preset": 3}, manufacturer=mfg_code - ) - if preset == PRESET_ECO: - return await self._thrm.write_attributes_safe( - {"operation_preset": 4}, manufacturer=mfg_code - ) - if preset == PRESET_BOOST: - return await self._thrm.write_attributes_safe( - {"operation_preset": 5}, manufacturer=mfg_code - ) - if preset == PRESET_COMPLEX: - return await self._thrm.write_attributes_safe( - {"operation_preset": 6}, manufacturer=mfg_code - ) - - -@STRICT_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - manufacturers={ - "_TZE200_b6wax7g0", - }, -) -class BecaThermostat(Thermostat): - """Beca Thermostat implementation.""" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._presets = [ - PRESET_NONE, - PRESET_AWAY, - PRESET_SCHEDULE, - PRESET_ECO, - PRESET_BOOST, - PRESET_TEMP_MANUAL, - ] - self._supported_flags |= ClimateEntityFeature.PRESET_MODE - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return only the heat mode, because the device can't be turned off.""" - return [HVACMode.HEAT] - - async def async_attribute_updated(self, attr_id, attr_name, value): - """Handle attribute update from device.""" - if attr_name == "operation_preset": - if value == 0: - self._preset = PRESET_AWAY - if value == 1: - self._preset = PRESET_SCHEDULE - if value == 2: - self._preset = PRESET_NONE - if value == 4: - self._preset = PRESET_ECO - if value == 5: - self._preset = PRESET_BOOST - if value == 7: - self._preset = PRESET_TEMP_MANUAL - await super().async_attribute_updated(attr_id, attr_name, value) - - async def async_preset_handler(self, preset: str, enable: bool = False) -> None: - """Set the preset mode.""" - mfg_code = self._zha_device.manufacturer_code - if not enable: - return await self._thrm.write_attributes_safe( - {"operation_preset": 2}, manufacturer=mfg_code - ) - if preset == PRESET_AWAY: - return await self._thrm.write_attributes_safe( - {"operation_preset": 0}, manufacturer=mfg_code - ) - if preset == PRESET_SCHEDULE: - return await self._thrm.write_attributes_safe( - {"operation_preset": 1}, manufacturer=mfg_code - ) - if preset == PRESET_ECO: - return await self._thrm.write_attributes_safe( - {"operation_preset": 4}, manufacturer=mfg_code - ) - if preset == PRESET_BOOST: - return await self._thrm.write_attributes_safe( - {"operation_preset": 5}, manufacturer=mfg_code - ) - if preset == PRESET_TEMP_MANUAL: - return await self._thrm.write_attributes_safe( - {"operation_preset": 7}, manufacturer=mfg_code - ) - - -@MULTI_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - manufacturers="Stelpro", - models={"SORB"}, - stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, -) -class StelproFanHeater(Thermostat): - """Stelpro Fan Heater implementation.""" - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return only the heat mode, because the device can't be turned off.""" - return [HVACMode.HEAT] - - -@STRICT_MATCH( - cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, - manufacturers={ - "_TZE200_7yoranx2", - "_TZE200_e9ba97vf", # TV01-ZG - "_TZE200_hue3yfsn", # TV02-ZG - "_TZE200_husqqvux", # TSL-TRV-TV01ZG - "_TZE200_kds0pmmv", # MOES TRV TV02 - "_TZE200_kly8gjlz", # TV05-ZG - "_TZE200_lnbfnyxd", - "_TZE200_mudxchsu", - }, -) -class ZONNSMARTThermostat(Thermostat): - """ZONNSMART Thermostat implementation. - - Notice that this device uses two holiday presets (2: HolidayMode, - 3: HolidayModeTemp), but only one of them can be set. - """ - - PRESET_HOLIDAY = "holiday" - PRESET_FROST = "frost protect" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Initialize ZHA Thermostat instance.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._presets = [ - PRESET_NONE, - self.PRESET_HOLIDAY, - PRESET_SCHEDULE, - self.PRESET_FROST, - ] - self._supported_flags |= ClimateEntityFeature.PRESET_MODE - - async def async_attribute_updated(self, attr_id, attr_name, value): - """Handle attribute update from device.""" - if attr_name == "operation_preset": - if value == 0: - self._preset = PRESET_SCHEDULE - if value == 1: - self._preset = PRESET_NONE - if value == 2: - self._preset = self.PRESET_HOLIDAY - if value == 3: - self._preset = self.PRESET_HOLIDAY - if value == 4: - self._preset = self.PRESET_FROST - await super().async_attribute_updated(attr_id, attr_name, value) - - async def async_preset_handler(self, preset: str, enable: bool = False) -> None: - """Set the preset mode.""" - mfg_code = self._zha_device.manufacturer_code - if not enable: - return await self._thrm.write_attributes_safe( - {"operation_preset": 1}, manufacturer=mfg_code - ) - if preset == PRESET_SCHEDULE: - return await self._thrm.write_attributes_safe( - {"operation_preset": 0}, manufacturer=mfg_code - ) - if preset == self.PRESET_HOLIDAY: - return await self._thrm.write_attributes_safe( - {"operation_preset": 3}, manufacturer=mfg_code - ) - if preset == self.PRESET_FROST: - return await self._thrm.write_attributes_safe( - {"operation_preset": 4}, manufacturer=mfg_code - ) diff --git a/zha/application/platforms/climate/__init__.py b/zha/application/platforms/climate/__init__.py new file mode 100644 index 00000000..2c0f028d --- /dev/null +++ b/zha/application/platforms/climate/__init__.py @@ -0,0 +1,846 @@ +"""Climate on Zigbee Home Automation.""" # pylint: disable=too-many-lines + +from __future__ import annotations + +import datetime as dt +import functools +from typing import TYPE_CHECKING, Any + +from zigpy.zcl.clusters.hvac import FanMode, RunningState, SystemMode + +from zha.application import Platform +from zha.application.platforms import PlatformEntity +from zha.application.platforms.climate.const import ( + ATTR_HVAC_MODE, + ATTR_OCCP_COOL_SETPT, + ATTR_OCCP_HEAT_SETPT, + ATTR_OCCUPANCY, + ATTR_PI_COOLING_DEMAND, + ATTR_PI_HEATING_DEMAND, + ATTR_SYS_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + ATTR_UNOCCP_COOL_SETPT, + ATTR_UNOCCP_HEAT_SETPT, + FAN_AUTO, + FAN_ON, + HVAC_MODE_2_SYSTEM, + PRECISION_TENTHS, + SEQ_OF_OPERATION, + SYSTEM_MODE_2_HVAC, + ZCL_TEMP, + ClimateEntityFeature, + HVACAction, + HVACMode, + Preset, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.decorators import periodic +from zha.units import UnitOfTemperature +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_FAN, + CLUSTER_HANDLER_THERMOSTAT, +) + +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.CLIMATE) +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.CLIMATE) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class Thermostat(PlatformEntity): + """Representation of a ZHA Thermostat device.""" + + PLATFORM = Platform.CLIMATE + DEFAULT_MAX_TEMP = 35 + DEFAULT_MIN_TEMP = 7 + + _attr_precision = PRECISION_TENTHS + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key: str = "thermostat" + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._preset = Preset.NONE + self._presets = [] + self._supported_flags = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + self._thermostat_cluster_handler: ClusterHandler = self.cluster_handlers.get( + CLUSTER_HANDLER_THERMOSTAT + ) + self._fan_cluster_handler: ClusterHandler = self.cluster_handlers.get( + CLUSTER_HANDLER_FAN + ) + self._thermostat_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + + @property + def current_temperature(self): + """Return the current temperature.""" + if self._thermostat_cluster_handler.local_temperature is None: + return None + return self._thermostat_cluster_handler.local_temperature / ZCL_TEMP + + @property + def extra_state_attributes(self): + """Return device specific state attributes.""" + data = {} + if self.hvac_mode: + mode = SYSTEM_MODE_2_HVAC.get( + self._thermostat_cluster_handler.system_mode, "unknown" + ) + data[ATTR_SYS_MODE] = ( + f"[{self._thermostat_cluster_handler.system_mode}]/{mode}" + ) + if self._thermostat_cluster_handler.occupancy is not None: + data[ATTR_OCCUPANCY] = self._thermostat_cluster_handler.occupancy + if self._thermostat_cluster_handler.occupied_cooling_setpoint is not None: + data[ATTR_OCCP_COOL_SETPT] = ( + self._thermostat_cluster_handler.occupied_cooling_setpoint + ) + if self._thermostat_cluster_handler.occupied_heating_setpoint is not None: + data[ATTR_OCCP_HEAT_SETPT] = ( + self._thermostat_cluster_handler.occupied_heating_setpoint + ) + if self._thermostat_cluster_handler.pi_heating_demand is not None: + data[ATTR_PI_HEATING_DEMAND] = ( + self._thermostat_cluster_handler.pi_heating_demand + ) + if self._thermostat_cluster_handler.pi_cooling_demand is not None: + data[ATTR_PI_COOLING_DEMAND] = ( + self._thermostat_cluster_handler.pi_cooling_demand + ) + + unoccupied_cooling_setpoint = ( + self._thermostat_cluster_handler.unoccupied_cooling_setpoint + ) + if unoccupied_cooling_setpoint is not None: + data[ATTR_UNOCCP_COOL_SETPT] = unoccupied_cooling_setpoint + + unoccupied_heating_setpoint = ( + self._thermostat_cluster_handler.unoccupied_heating_setpoint + ) + if unoccupied_heating_setpoint is not None: + data[ATTR_UNOCCP_HEAT_SETPT] = unoccupied_heating_setpoint + return data + + @property + def fan_mode(self) -> str | None: + """Return current FAN mode.""" + if self._thermostat_cluster_handler.running_state is None: + return FAN_AUTO + + if self._thermostat_cluster_handler.running_state & ( + RunningState.Fan_State_On + | RunningState.Fan_2nd_Stage_On + | RunningState.Fan_3rd_Stage_On + ): + return FAN_ON + return FAN_AUTO + + @property + def fan_modes(self) -> list[str] | None: + """Return supported FAN modes.""" + if not self._fan_cluster_handler: + return None + return [FAN_AUTO, FAN_ON] + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + if ( + self._thermostat_cluster_handler.pi_heating_demand is None + and self._thermostat_cluster_handler.pi_cooling_demand is None + ): + return self._rm_rs_action + return self._pi_demand_action + + @property + def _rm_rs_action(self) -> HVACAction | None: + """Return the current HVAC action based on running mode and running state.""" + + if (running_state := self._thermostat_cluster_handler.running_state) is None: + return None + if running_state & ( + RunningState.Heat_State_On | RunningState.Heat_2nd_Stage_On + ): + return HVACAction.HEATING + if running_state & ( + RunningState.Cool_State_On | RunningState.Cool_2nd_Stage_On + ): + return HVACAction.COOLING + if running_state & ( + RunningState.Fan_State_On + | RunningState.Fan_2nd_Stage_On + | RunningState.Fan_3rd_Stage_On + ): + return HVACAction.FAN + if running_state & RunningState.Idle: + return HVACAction.IDLE + if self.hvac_mode != HVACMode.OFF: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def _pi_demand_action(self) -> HVACAction | None: + """Return the current HVAC action based on pi_demands.""" + + heating_demand = self._thermostat_cluster_handler.pi_heating_demand + if heating_demand is not None and heating_demand > 0: + return HVACAction.HEATING + cooling_demand = self._thermostat_cluster_handler.pi_cooling_demand + if cooling_demand is not None and cooling_demand > 0: + return HVACAction.COOLING + + if self.hvac_mode != HVACMode.OFF: + return HVACAction.IDLE + return HVACAction.OFF + + @property + def hvac_mode(self) -> HVACMode | None: + """Return HVAC operation mode.""" + return SYSTEM_MODE_2_HVAC.get(self._thermostat_cluster_handler.system_mode) + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVAC operation modes.""" + return SEQ_OF_OPERATION.get( + self._thermostat_cluster_handler.ctrl_sequence_of_oper, [HVACMode.OFF] + ) + + @property + def preset_mode(self) -> str: + """Return current preset mode.""" + return self._preset + + @property + def preset_modes(self) -> list[str] | None: + """Return supported preset modes.""" + return self._presets + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = self._supported_flags + if HVACMode.HEAT_COOL in self.hvac_modes: + features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + if self._fan_cluster_handler is not None: + self._supported_flags |= ClimateEntityFeature.FAN_MODE + return features + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + temp = None + if self.hvac_mode == HVACMode.COOL: + if self.preset_mode == Preset.AWAY: + temp = self._thermostat_cluster_handler.unoccupied_cooling_setpoint + else: + temp = self._thermostat_cluster_handler.occupied_cooling_setpoint + elif self.hvac_mode == HVACMode.HEAT: + if self.preset_mode == Preset.AWAY: + temp = self._thermostat_cluster_handler.unoccupied_heating_setpoint + else: + temp = self._thermostat_cluster_handler.occupied_heating_setpoint + if temp is None: + return temp + return round(temp / ZCL_TEMP, 1) + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + if self.preset_mode == Preset.AWAY: + temp = self._thermostat_cluster_handler.unoccupied_cooling_setpoint + else: + temp = self._thermostat_cluster_handler.occupied_cooling_setpoint + + if temp is None: + return temp + + return round(temp / ZCL_TEMP, 1) + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + if self.preset_mode == Preset.AWAY: + temp = self._thermostat_cluster_handler.unoccupied_heating_setpoint + else: + temp = self._thermostat_cluster_handler.occupied_heating_setpoint + + if temp is None: + return temp + return round(temp / ZCL_TEMP, 1) + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + temps = [] + if HVACMode.HEAT in self.hvac_modes: + temps.append(self._thermostat_cluster_handler.max_heat_setpoint_limit) + if HVACMode.COOL in self.hvac_modes: + temps.append(self._thermostat_cluster_handler.max_cool_setpoint_limit) + + if not temps: + return self.DEFAULT_MAX_TEMP + return round(max(temps) / ZCL_TEMP, 1) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + temps = [] + if HVACMode.HEAT in self.hvac_modes: + temps.append(self._thermostat_cluster_handler.min_heat_setpoint_limit) + if HVACMode.COOL in self.hvac_modes: + temps.append(self._thermostat_cluster_handler.min_cool_setpoint_limit) + + if not temps: + return self.DEFAULT_MIN_TEMP + return round(min(temps) / ZCL_TEMP, 1) + + async def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Handle attribute update from device.""" + if ( + event.attribute_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) + and self.preset_mode == Preset.AWAY + ): + # occupancy attribute is an unreportable attribute, but if we get + # an attribute update for an "occupied" setpoint, there's a chance + # occupancy has changed + if await self._thermostat_cluster_handler.get_occupancy() is True: + self._preset = Preset.NONE + + self.debug( + "Attribute '%s' = %s update", event.attribute_name, event.attribute_value + ) + self.maybe_emit_state_changed_event() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + if not self.fan_modes or fan_mode not in self.fan_modes: + self.warning("Unsupported '%s' fan mode", fan_mode) + return + + if fan_mode == FAN_ON: + mode = FanMode.On + else: + mode = FanMode.Auto + + await self._fan_cluster_handler.async_set_speed(mode) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target operation mode.""" + if hvac_mode not in self.hvac_modes: + self.warning( + "can't set '%s' mode. Supported modes are: %s", + hvac_mode, + self.hvac_modes, + ) + return + + if await self._thermostat_cluster_handler.async_set_operation_mode( + HVAC_MODE_2_SYSTEM[hvac_mode] + ): + self.maybe_emit_state_changed_event() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if not self.preset_modes or preset_mode not in self.preset_modes: + self.debug("Preset mode '%s' is not supported", preset_mode) + return + + if self.preset_mode not in ( + preset_mode, + Preset.NONE, + ): + await self.async_preset_handler(self.preset_mode, enable=False) + + if preset_mode != Preset.NONE: + await self.async_preset_handler(preset_mode, enable=True) + + self._preset = preset_mode + self.maybe_emit_state_changed_event() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + + if hvac_mode is not None: + await self.async_set_hvac_mode(hvac_mode) + + is_away = self.preset_mode == Preset.AWAY + + if self.hvac_mode == HVACMode.HEAT_COOL: + if low_temp is not None: + await self._thermostat_cluster_handler.async_set_heating_setpoint( + temperature=int(low_temp * ZCL_TEMP), + is_away=is_away, + ) + if high_temp is not None: + await self._thermostat_cluster_handler.async_set_cooling_setpoint( + temperature=int(high_temp * ZCL_TEMP), + is_away=is_away, + ) + elif temp is not None: + if self.hvac_mode == HVACMode.COOL: + await self._thermostat_cluster_handler.async_set_cooling_setpoint( + temperature=int(temp * ZCL_TEMP), + is_away=is_away, + ) + elif self.hvac_mode == HVACMode.HEAT: + await self._thermostat_cluster_handler.async_set_heating_setpoint( + temperature=int(temp * ZCL_TEMP), + is_away=is_away, + ) + else: + self.debug("Not setting temperature for '%s' mode", self.hvac_mode) + return + else: + self.debug("incorrect %s setting for '%s' mode", kwargs, self.hvac_mode) + return + + self.maybe_emit_state_changed_event() + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode via handler.""" + + handler = getattr(self, f"async_preset_handler_{preset}") + await handler(enable) + + def to_json(self) -> dict: + """Return a JSON representation of the thermostat.""" + json = super().to_json() + json["hvac_modes"] = self.hvac_modes + json["fan_modes"] = self.fan_modes + json["preset_modes"] = self.preset_modes + json["supported_features"] = self.supported_features + json["max_temp"] = self.max_temp + json["min_temp"] = self.min_temp + return json + + def get_state(self) -> dict: + """Get the state of the lock.""" + response = super().get_state() + response["current_temperature"] = self.current_temperature + response["target_temperature"] = self.target_temperature + response["target_temperature_high"] = self.target_temperature_high + response["target_temperature_low"] = self.target_temperature_low + response["hvac_action"] = self.hvac_action + response["hvac_mode"] = self.hvac_mode + response["preset_mode"] = self.preset_mode + response["fan_mode"] = self.fan_mode + response.update(self.extra_state_attributes) + return response + + +@MULTI_MATCH( + cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT, "sinope_manufacturer_specific"}, + manufacturers="Sinope Technologies", + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class SinopeTechnologiesThermostat(Thermostat): + """Sinope Technologies Thermostat.""" + + manufacturer = 0x119C + __polling_interval: int + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._presets = [Preset.AWAY, Preset.NONE] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + self._manufacturer_ch = self.cluster_handlers["sinope_manufacturer_specific"] + + self._tracked_tasks.append( + device.gateway.async_create_background_task( + self._update_time(), + name=f"sinope_time_updater_{self.unique_id}", + eager_start=True, + untracked=True, + ) + ) + self.debug( + "started time updating interval of %s", + getattr(self, "__polling_interval"), + ) + + @periodic((2700, 4500)) + async def _update_time(self) -> None: + await self._async_update_time() + + @property + def _rm_rs_action(self) -> HVACAction: + """Return the current HVAC action based on running mode and running state.""" + + running_mode = self._thermostat_cluster_handler.running_mode + if running_mode == SystemMode.Heat: + return HVACAction.HEATING + if running_mode == SystemMode.Cool: + return HVACAction.COOLING + + running_state = self._thermostat_cluster_handler.running_state + if running_state and running_state & ( + RunningState.Fan_State_On + | RunningState.Fan_2nd_Stage_On + | RunningState.Fan_3rd_Stage_On + ): + return HVACAction.FAN + if self.hvac_mode != HVACMode.OFF and running_mode == SystemMode.Off: + return HVACAction.IDLE + return HVACAction.OFF + + async def _async_update_time(self) -> None: + """Update thermostat's time display.""" + + secs_2k = ( + dt.datetime.now(dt.UTC).replace(tzinfo=None) + - dt.datetime(2000, 1, 1, 0, 0, 0, 0) + ).total_seconds() + + self.debug("Updating time: %s", secs_2k) + await self._manufacturer_ch.write_attributes_safe( + {"secs_since_2k": secs_2k}, manufacturer=self.manufacturer + ) + + async def async_preset_handler_away(self, is_away: bool = False) -> None: + """Set occupancy.""" + mfg_code = self._device.manufacturer_code + await self._thermostat_cluster_handler.write_attributes_safe( + {"set_occupancy": 0 if is_away else 1}, manufacturer=mfg_code + ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + manufacturers={"Zen Within", "LUX"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class ZenWithinThermostat(Thermostat): + """Zen Within Thermostat implementation.""" + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + aux_cluster_handlers=CLUSTER_HANDLER_FAN, + manufacturers="Centralite", + models={"3157100", "3157100-E"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class CentralitePearl(ZenWithinThermostat): + """Centralite Pearl Thermostat implementation.""" + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={ + "_TZE200_ckud7u2l", + "_TZE200_ywdxldoj", + "_TZE200_cwnjrr72", + "_TZE200_2atgpdho", + "_TZE200_pvvbommb", + "_TZE200_4eeyebrt", + "_TZE200_cpmgn2cf", + "_TZE200_9sfg7gm0", + "_TZE200_8whxpsiw", + "_TYST11_ckud7u2l", + "_TYST11_ywdxldoj", + "_TYST11_cwnjrr72", + "_TYST11_2atgpdho", + }, +) +class MoesThermostat(Thermostat): + """Moes Thermostat implementation.""" + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._presets = [ + Preset.NONE, + Preset.AWAY, + Preset.SCHEDULE, + Preset.COMFORT, + Preset.ECO, + Preset.BOOST, + Preset.COMPLEX, + ] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return only the heat mode, because the device can't be turned off.""" + return [HVACMode.HEAT] + + async def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Handle attribute update from device.""" + if event.attribute_name == "operation_preset": + if event.attribute_value == 0: + self._preset = Preset.AWAY + if event.attribute_value == 1: + self._preset = Preset.SCHEDULE + if event.attribute_value == 2: + self._preset = Preset.NONE + if event.attribute_value == 3: + self._preset = Preset.COMFORT + if event.attribute_value == 4: + self._preset = Preset.ECO + if event.attribute_value == 5: + self._preset = Preset.BOOST + if event.attribute_value == 6: + self._preset = Preset.COMPLEX + await super().handle_cluster_handler_attribute_updated(event) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode.""" + mfg_code = self._device.manufacturer_code + if not enable: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 2}, manufacturer=mfg_code + ) + if preset == Preset.AWAY: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == Preset.SCHEDULE: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == Preset.COMFORT: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 3}, manufacturer=mfg_code + ) + if preset == Preset.ECO: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 4}, manufacturer=mfg_code + ) + if preset == Preset.BOOST: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 5}, manufacturer=mfg_code + ) + if preset == Preset.COMPLEX: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 6}, manufacturer=mfg_code + ) + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={ + "_TZE200_b6wax7g0", + }, +) +class BecaThermostat(Thermostat): + """Beca Thermostat implementation.""" + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._presets = [ + Preset.NONE, + Preset.AWAY, + Preset.SCHEDULE, + Preset.ECO, + Preset.BOOST, + Preset.TEMP_MANUAL, + ] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return only the heat mode, because the device can't be turned off.""" + return [HVACMode.HEAT] + + async def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Handle attribute update from device.""" + if event.attribute_name == "operation_preset": + if event.attribute_value == 0: + self._preset = Preset.AWAY + if event.attribute_value == 1: + self._preset = Preset.SCHEDULE + if event.attribute_value == 2: + self._preset = Preset.NONE + if event.attribute_value == 4: + self._preset = Preset.ECO + if event.attribute_value == 5: + self._preset = Preset.BOOST + if event.attribute_value == 7: + self._preset = Preset.TEMP_MANUAL + await super().handle_cluster_handler_attribute_updated(event) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode.""" + mfg_code = self._device.manufacturer_code + if not enable: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 2}, manufacturer=mfg_code + ) + if preset == Preset.AWAY: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == Preset.SCHEDULE: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == Preset.ECO: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 4}, manufacturer=mfg_code + ) + if preset == Preset.BOOST: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 5}, manufacturer=mfg_code + ) + if preset == Preset.TEMP_MANUAL: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 7}, manufacturer=mfg_code + ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers="Stelpro", + models={"SORB"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +class StelproFanHeater(Thermostat): + """Stelpro Fan Heater implementation.""" + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return only the heat mode, because the device can't be turned off.""" + return [HVACMode.HEAT] + + +@STRICT_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + manufacturers={ + "_TZE200_7yoranx2", + "_TZE200_e9ba97vf", # TV01-ZG + "_TZE200_hue3yfsn", # TV02-ZG + "_TZE200_husqqvux", # TSL-TRV-TV01ZG + "_TZE200_kds0pmmv", # MOES TRV TV02 + "_TZE200_kly8gjlz", # TV05-ZG + "_TZE200_lnbfnyxd", + "_TZE200_mudxchsu", + }, +) +class ZONNSMARTThermostat(Thermostat): + """ZONNSMART Thermostat implementation. + + Notice that this device uses two holiday presets (2: HolidayMode, + 3: HolidayModeTemp), but only one of them can be set. + """ + + PRESET_HOLIDAY = "holiday" + PRESET_FROST = "frost protect" + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): + """Initialize ZHA Thermostat instance.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._presets = [ + Preset.NONE, + self.PRESET_HOLIDAY, + Preset.SCHEDULE, + self.PRESET_FROST, + ] + self._supported_flags |= ClimateEntityFeature.PRESET_MODE + + async def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Handle attribute update from device.""" + if event.attribute_name == "operation_preset": + if event.attribute_value == 0: + self._preset = Preset.SCHEDULE + if event.attribute_value == 1: + self._preset = Preset.NONE + if event.attribute_value in (2, 3): + self._preset = self.PRESET_HOLIDAY + if event.attribute_value == 4: + self._preset = self.PRESET_FROST + await super().handle_cluster_handler_attribute_updated(event) + + async def async_preset_handler(self, preset: str, enable: bool = False) -> None: + """Set the preset mode.""" + mfg_code = self._device.manufacturer_code + if not enable: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 1}, manufacturer=mfg_code + ) + if preset == Preset.SCHEDULE: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 0}, manufacturer=mfg_code + ) + if preset == self.PRESET_HOLIDAY: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 3}, manufacturer=mfg_code + ) + if preset == self.PRESET_FROST: + return await self._thermostat_cluster_handler.write_attributes_safe( + {"operation_preset": 4}, manufacturer=mfg_code + ) diff --git a/zha/application/platforms/climate/const.py b/zha/application/platforms/climate/const.py new file mode 100644 index 00000000..599fbd43 --- /dev/null +++ b/zha/application/platforms/climate/const.py @@ -0,0 +1,189 @@ +"""Constants for the climate platform.""" + +from enum import IntFlag, StrEnum +from typing import Final + +from zigpy.zcl.clusters.hvac import SystemMode + +ATTR_SYS_MODE: Final[str] = "system_mode" +ATTR_FAN_MODE: Final[str] = "fan_mode" +ATTR_RUNNING_MODE: Final[str] = "running_mode" +ATTR_SETPT_CHANGE_SRC: Final[str] = "setpoint_change_source" +ATTR_SETPT_CHANGE_AMT: Final[str] = "setpoint_change_amount" +ATTR_OCCUPANCY: Final[str] = "occupancy" +ATTR_PI_COOLING_DEMAND: Final[str] = "pi_cooling_demand" +ATTR_PI_HEATING_DEMAND: Final[str] = "pi_heating_demand" +ATTR_OCCP_COOL_SETPT: Final[str] = "occupied_cooling_setpoint" +ATTR_OCCP_HEAT_SETPT: Final[str] = "occupied_heating_setpoint" +ATTR_UNOCCP_HEAT_SETPT: Final[str] = "unoccupied_heating_setpoint" +ATTR_UNOCCP_COOL_SETPT: Final[str] = "unoccupied_cooling_setpoint" +ATTR_HVAC_MODE: Final[str] = "hvac_mode" +ATTR_TARGET_TEMP_HIGH: Final[str] = "target_temp_high" +ATTR_TARGET_TEMP_LOW: Final[str] = "target_temp_low" + +SUPPORT_TARGET_TEMPERATURE: Final[int] = 1 +SUPPORT_TARGET_TEMPERATURE_RANGE: Final[int] = 2 +SUPPORT_TARGET_HUMIDITY: Final[int] = 4 +SUPPORT_FAN_MODE: Final[int] = 8 +SUPPORT_PRESET_MODE: Final[int] = 16 +SUPPORT_SWING_MODE: Final[int] = 32 +SUPPORT_AUX_HEAT: Final[int] = 64 + +PRECISION_TENTHS: Final[float] = 0.1 +# Temperature attribute +ATTR_TEMPERATURE: Final[str] = "temperature" +TEMP_CELSIUS: Final[str] = "°C" + +# Possible fan state +FAN_ON = "on" +FAN_OFF = "off" +FAN_AUTO = "auto" +FAN_LOW = "low" +FAN_MEDIUM = "medium" +FAN_HIGH = "high" +FAN_TOP = "top" +FAN_MIDDLE = "middle" +FAN_FOCUS = "focus" +FAN_DIFFUSE = "diffuse" + +# Possible swing state +SWING_ON = "on" +SWING_OFF = "off" +SWING_BOTH = "both" +SWING_VERTICAL = "vertical" +SWING_HORIZONTAL = "horizontal" + + +class ClimateEntityFeature(IntFlag): + """Supported features of the climate entity.""" + + TARGET_TEMPERATURE = 1 + TARGET_TEMPERATURE_RANGE = 2 + TARGET_HUMIDITY = 4 + FAN_MODE = 8 + PRESET_MODE = 16 + SWING_MODE = 32 + AUX_HEAT = 64 + TURN_OFF = 128 + TURN_ON = 256 + + +class HVACMode(StrEnum): + """HVAC mode.""" + + OFF = "off" + # Heating + HEAT = "heat" + # Cooling + COOL = "cool" + # The device supports heating/cooling to a range + HEAT_COOL = "heat_cool" + # The temperature is set based on a schedule, learned behavior, AI or some + # other related mechanism. User is not able to adjust the temperature + AUTO = "auto" + # Device is in Dry/Humidity mode + DRY = "dry" + # Only the fan is on, not fan and another mode like cool + FAN_ONLY = "fan_only" + + +class Preset(StrEnum): + """Preset mode.""" + + # No preset is active + NONE = "none" + # Device is running an energy-saving mode + ECO = "eco" + # Device is in away mode + AWAY = "away" + # Device turn all valve full up + BOOST = "boost" + # Device is in comfort mode + COMFORT = "comfort" + # Device is in home mode + HOME = "home" + # Device is prepared for sleep + SLEEP = "sleep" + # Device is reacting to activity (e.g. movement sensors) + ACTIVITY = "activity" + SCHEDULE = "Schedule" + COMPLEX = "Complex" + TEMP_MANUAL = "Temporary manual" + + +class FanState(StrEnum): + """Fan state.""" + + # Possible fan state + ON = "on" + OFF = "off" + AUTO = "auto" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + TOP = "top" + MIDDLE = "middle" + FOCUS = "focus" + DIFFUSE = "diffuse" + + +class CurrentHVAC(StrEnum): + """Current HVAC state.""" + + OFF = "off" + HEAT = "heating" + COOL = "cooling" + DRY = "drying" + IDLE = "idle" + FAN = "fan" + + +class HVACAction(StrEnum): + """HVAC action for climate devices.""" + + COOLING = "cooling" + DRYING = "drying" + FAN = "fan" + HEATING = "heating" + IDLE = "idle" + OFF = "off" + PREHEATING = "preheating" + + +RUNNING_MODE = {0x00: HVACMode.OFF, 0x03: HVACMode.COOL, 0x04: HVACMode.HEAT} + +SEQ_OF_OPERATION = { + 0x00: [HVACMode.OFF, HVACMode.COOL], # cooling only + 0x01: [HVACMode.OFF, HVACMode.COOL], # cooling with reheat + 0x02: [HVACMode.OFF, HVACMode.HEAT], # heating only + 0x03: [HVACMode.OFF, HVACMode.HEAT], # heating with reheat + # cooling and heating 4-pipes + 0x04: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], + # cooling and heating 4-pipes + 0x05: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], + 0x06: [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF], # centralite specific + 0x07: [HVACMode.HEAT_COOL, HVACMode.OFF], # centralite specific +} + +HVAC_MODE_2_SYSTEM = { + HVACMode.OFF: SystemMode.Off, + HVACMode.HEAT_COOL: SystemMode.Auto, + HVACMode.COOL: SystemMode.Cool, + HVACMode.HEAT: SystemMode.Heat, + HVACMode.FAN_ONLY: SystemMode.Fan_only, + HVACMode.DRY: SystemMode.Dry, +} + +SYSTEM_MODE_2_HVAC = { + SystemMode.Off: HVACMode.OFF, + SystemMode.Auto: HVACMode.HEAT_COOL, + SystemMode.Cool: HVACMode.COOL, + SystemMode.Heat: HVACMode.HEAT, + SystemMode.Emergency_Heating: HVACMode.HEAT, + SystemMode.Pre_cooling: HVACMode.COOL, # this is 'precooling'. is it the same? + SystemMode.Fan_only: HVACMode.FAN_ONLY, + SystemMode.Dry: HVACMode.DRY, + SystemMode.Sleep: HVACMode.OFF, +} + +ZCL_TEMP = 100 diff --git a/zha/application/platforms/cover.py b/zha/application/platforms/cover/__init__.py similarity index 64% rename from zha/application/platforms/cover.py rename to zha/application/platforms/cover/__init__.py index 1c1868ca..0aa415fe 100644 --- a/zha/application/platforms/cover.py +++ b/zha/application/platforms/cover/__init__.py @@ -1,4 +1,4 @@ -"""Support for ZHA covers.""" +"""Support for Zigbee Home Automation covers.""" from __future__ import annotations @@ -7,105 +7,66 @@ import logging from typing import TYPE_CHECKING, Any, cast -from homeassistant.components.cover import ( +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.foundation import Status + +from zha.application import Platform +from zha.application.platforms import PlatformEntity +from zha.application.platforms.cover.const import ( ATTR_CURRENT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, - Platform, + WCT, + ZCL_TO_COVER_DEVICE_CLASS, + CoverDeviceClass, + CoverEntityFeature, + WCAttrs, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from zigpy.zcl.clusters.closures import WindowCovering as WindowCoveringCluster -from zigpy.zcl.foundation import Status - -from .core import discovery -from .core.cluster_handlers.closures import WindowCoveringClusterHandler -from .core.const import ( +from zha.application.registries import PLATFORM_ENTITIES +from zha.exceptions import ZHAException +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.closures import WindowCoveringClusterHandler +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_SHADE, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, - SIGNAL_SET_LEVEL, ) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity +from zha.zigbee.cluster_handlers.general import LevelChangeEvent if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint _LOGGER = logging.getLogger(__name__) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.COVER) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation cover from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.COVER] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) - - -WCAttrs = WindowCoveringCluster.AttributeDefs -WCT = WindowCoveringCluster.WindowCoveringType -WCCS = WindowCoveringCluster.ConfigStatus - -ZCL_TO_COVER_DEVICE_CLASS = { - WCT.Awning: CoverDeviceClass.AWNING, - WCT.Drapery: CoverDeviceClass.CURTAIN, - WCT.Projector_screen: CoverDeviceClass.SHADE, - WCT.Rollershade: CoverDeviceClass.SHADE, - WCT.Rollershade_two_motors: CoverDeviceClass.SHADE, - WCT.Rollershade_exterior: CoverDeviceClass.SHADE, - WCT.Rollershade_exterior_two_motors: CoverDeviceClass.SHADE, - WCT.Shutter: CoverDeviceClass.SHUTTER, - WCT.Tilt_blind_tilt_only: CoverDeviceClass.BLIND, - WCT.Tilt_blind_tilt_and_lift: CoverDeviceClass.BLIND, -} +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.COVER) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) -class ZhaCover(ZhaEntity, CoverEntity): +class Cover(PlatformEntity): """Representation of a ZHA cover.""" + PLATFORM = Platform.COVER + _attr_translation_key: str = "cover" def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], - **kwargs: Any, + endpoint: Endpoint, + device: Device, + **kwargs, ) -> None: """Init this cover.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) assert cluster_handler self._cover_cluster_handler: WindowCoveringClusterHandler = cast( @@ -123,6 +84,10 @@ def __init__( self._target_lift_position: int | None = None self._target_tilt_position: int | None = None self._determine_initial_state() + self._cover_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) def _determine_supported_features(self) -> CoverEntityFeature: """Determine the supported cover features.""" @@ -188,21 +153,21 @@ def _determine_state(self, position_or_tilt, is_lift_update=True) -> None: target = self._target_tilt_position current = self.current_cover_tilt_position - if position_or_tilt == 100: - self._state = STATE_CLOSED + if position_or_tilt == 0: + self._state = ( + STATE_CLOSED + if is_lift_update + else STATE_OPEN + if self.current_cover_position is not None + and self.current_cover_position > 0 + else STATE_CLOSED + ) return if target is not None and target != current: # we are mid transition and shouldn't update the state return self._state = STATE_OPEN - async def async_added_to_hass(self) -> None: - """Run when the cover entity is about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._cover_cluster_handler, SIGNAL_ATTR_UPDATED, self.zcl_attribute_updated - ) - @property def is_closed(self) -> bool | None: """Return True if the cover is closed. @@ -242,59 +207,60 @@ def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" return self._cover_cluster_handler.current_position_tilt_percentage - @callback - def zcl_attribute_updated(self, attr_id, attr_name, value): + def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: """Handle position update from cluster handler.""" - if attr_id in ( + if event.attribute_id in ( WCAttrs.current_position_lift_percentage.id, WCAttrs.current_position_tilt_percentage.id, ): value = ( self.current_cover_position - if attr_id == WCAttrs.current_position_lift_percentage.id + if event.attribute_id == WCAttrs.current_position_lift_percentage.id else self.current_cover_tilt_position ) self._determine_state( value, - is_lift_update=attr_id == WCAttrs.current_position_lift_percentage.id, + is_lift_update=event.attribute_id + == WCAttrs.current_position_lift_percentage.id, ) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - @callback def async_update_state(self, state): """Handle state update from HA operations below.""" _LOGGER.debug("async_update_state=%s", state) self._state = state - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Open the cover.""" res = await self._cover_cluster_handler.up_open() if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to open cover: {res[1]}") + raise ZHAException(f"Failed to open cover: {res[1]}") self.async_update_state(STATE_OPENING) - async def async_open_cover_tilt(self, **kwargs: Any) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Open the cover tilt.""" # 0 is open in ZCL res = await self._cover_cluster_handler.go_to_tilt_percentage(0) if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") + raise ZHAException(f"Failed to open cover tilt: {res[1]}") self.async_update_state(STATE_OPENING) - async def async_close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Close the cover.""" res = await self._cover_cluster_handler.down_close() if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to close cover: {res[1]}") + raise ZHAException(f"Failed to close cover: {res[1]}") self.async_update_state(STATE_CLOSING) - async def async_close_cover_tilt(self, **kwargs: Any) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Close the cover tilt.""" # 100 is closed in ZCL res = await self._cover_cluster_handler.go_to_tilt_percentage(100) if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") + raise ZHAException(f"Failed to close cover tilt: {res[1]}") self.async_update_state(STATE_CLOSING) async def async_set_cover_position(self, **kwargs: Any) -> None: @@ -307,7 +273,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: 100 - self._target_lift_position ) if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to set cover position: {res[1]}") + raise ZHAException(f"Failed to set cover position: {res[1]}") self.async_update_state( STATE_CLOSING if self._target_lift_position < self.current_cover_position @@ -324,30 +290,44 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: 100 - self._target_tilt_position ) if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") + raise ZHAException(f"Failed to set cover tilt position: {res[1]}") self.async_update_state( STATE_CLOSING if self._target_tilt_position < self.current_cover_tilt_position else STATE_OPENING ) - async def async_stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Stop the cover.""" res = await self._cover_cluster_handler.stop() if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + raise ZHAException(f"Failed to stop cover: {res[1]}") self._target_lift_position = self.current_cover_position self._determine_state(self.current_cover_position) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Stop the cover tilt.""" res = await self._cover_cluster_handler.stop() if res[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + raise ZHAException(f"Failed to stop cover: {res[1]}") self._target_tilt_position = self.current_cover_tilt_position self._determine_state(self.current_cover_tilt_position, is_lift_update=False) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() + + def get_state(self) -> dict: + """Get the state of the cover.""" + response = super().get_state() + response.update( + { + ATTR_CURRENT_POSITION: self.current_cover_position, + "state": self._state, + "is_opening": self.is_opening, + "is_closing": self.is_closing, + "is_closed": self.is_closed, + } + ) + return response @MULTI_MATCH( @@ -357,25 +337,43 @@ async def async_stop_cover_tilt(self, **kwargs: Any) -> None: CLUSTER_HANDLER_SHADE, } ) -class Shade(ZhaEntity, CoverEntity): +class Shade(PlatformEntity): """ZHA Shade.""" + PLATFORM = Platform.COVER + _attr_device_class = CoverDeviceClass.SHADE _attr_translation_key: str = "shade" def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs, ) -> None: """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] - self._level_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_LEVEL] - self._position: int | None = None - self._is_open: bool | None = None + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._on_off_cluster_handler: ClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_ON_OFF + ] + self._level_cluster_handler: ClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_LEVEL + ] + self._is_open: bool = bool(self._on_off_cluster_handler.on_off) + position = self._level_cluster_handler.current_level + if position is not None: + position = max(0, min(255, position)) + position = int(position * 100 / 255) + self._position: int | None = position + self._on_off_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + self._level_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, self.handle_cluster_handler_set_level + ) @property def current_cover_position(self) -> int | None: @@ -385,62 +383,47 @@ def current_cover_position(self) -> int | None: """ return self._position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt position of the cover.""" + return None + @property def is_closed(self) -> bool | None: """Return True if shade is closed.""" - if self._is_open is None: - return None return not self._is_open - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._on_off_cluster_handler, - SIGNAL_ATTR_UPDATED, - self.async_set_open_closed, - ) - self.async_accept_signal( - self._level_cluster_handler, SIGNAL_SET_LEVEL, self.async_set_level - ) - - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - self._is_open = last_state.state == STATE_OPEN - if ATTR_CURRENT_POSITION in last_state.attributes: - self._position = last_state.attributes[ATTR_CURRENT_POSITION] - - @callback - def async_set_open_closed(self, attr_id: int, attr_name: str, value: bool) -> None: + def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: """Set open/closed state.""" - self._is_open = bool(value) - self.async_write_ha_state() + if event.attribute_id == OnOff.AttributeDefs.on_off.id: + self._is_open = bool(event.attribute_value) + self.maybe_emit_state_changed_event() - @callback - def async_set_level(self, value: int) -> None: + def handle_cluster_handler_set_level(self, event: LevelChangeEvent) -> None: """Set the reported position.""" - value = max(0, min(255, value)) + value = max(0, min(255, event.level)) self._position = int(value * 100 / 255) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Open the window cover.""" res = await self._on_off_cluster_handler.on() if res[1] != Status.SUCCESS: - raise HomeAssistantError(f"Failed to open cover: {res[1]}") + raise ZHAException(f"Failed to open cover: {res[1]}") self._is_open = True - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Close the window cover.""" res = await self._on_off_cluster_handler.off() if res[1] != Status.SUCCESS: - raise HomeAssistantError(f"Failed to close cover: {res[1]}") + raise ZHAException(f"Failed to close cover: {res[1]}") self._is_open = False - self.async_write_ha_state() + self.maybe_emit_state_changed_event() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" @@ -450,16 +433,32 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: ) if res[1] != Status.SUCCESS: - raise HomeAssistantError(f"Failed to set cover position: {res[1]}") + raise ZHAException(f"Failed to set cover position: {res[1]}") self._position = new_pos - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Stop the cover.""" res = await self._level_cluster_handler.stop() if res[1] != Status.SUCCESS: - raise HomeAssistantError(f"Failed to stop cover: {res[1]}") + raise ZHAException(f"Failed to stop cover: {res[1]}") + + def get_state(self) -> dict: + """Get the state of the cover.""" + if (closed := self.is_closed) is None: + state = None + else: + state = STATE_CLOSED if closed else STATE_OPEN + response = super().get_state() + response.update( + { + ATTR_CURRENT_POSITION: self.current_cover_position, + "is_closed": self.is_closed, + "state": state, + } + ) + return response @MULTI_MATCH( @@ -484,4 +483,4 @@ async def async_open_cover(self, **kwargs: Any) -> None: self._is_open = True self._position = position - self.async_write_ha_state() + self.maybe_emit_state_changed_event() diff --git a/zha/application/platforms/cover/const.py b/zha/application/platforms/cover/const.py new file mode 100644 index 00000000..644c2c19 --- /dev/null +++ b/zha/application/platforms/cover/const.py @@ -0,0 +1,63 @@ +"""Constants for the cover platform.""" + +from enum import IntFlag, StrEnum +from typing import Final + +from zigpy.zcl.clusters.closures import WindowCovering as WindowCoveringCluster + +ATTR_CURRENT_POSITION: Final[str] = "current_position" +ATTR_CURRENT_TILT_POSITION: Final[str] = "current_tilt_position" +ATTR_POSITION: Final[str] = "position" +ATTR_TILT_POSITION: Final[str] = "tilt_position" + +STATE_OPEN: Final[str] = "open" +STATE_OPENING: Final[str] = "opening" +STATE_CLOSED: Final[str] = "closed" +STATE_CLOSING: Final[str] = "closing" + + +class CoverDeviceClass(StrEnum): + """Device class for cover.""" + + # Refer to the cover dev docs for device class descriptions + AWNING = "awning" + BLIND = "blind" + CURTAIN = "curtain" + DAMPER = "damper" + DOOR = "door" + GARAGE = "garage" + GATE = "gate" + SHADE = "shade" + SHUTTER = "shutter" + WINDOW = "window" + + +class CoverEntityFeature(IntFlag): + """Supported features of the cover entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + OPEN_TILT = 16 + CLOSE_TILT = 32 + STOP_TILT = 64 + SET_TILT_POSITION = 128 + + +WCAttrs = WindowCoveringCluster.AttributeDefs +WCT = WindowCoveringCluster.WindowCoveringType +WCCS = WindowCoveringCluster.ConfigStatus + +ZCL_TO_COVER_DEVICE_CLASS = { + WCT.Awning: CoverDeviceClass.AWNING, + WCT.Drapery: CoverDeviceClass.CURTAIN, + WCT.Projector_screen: CoverDeviceClass.SHADE, + WCT.Rollershade: CoverDeviceClass.SHADE, + WCT.Rollershade_two_motors: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior: CoverDeviceClass.SHADE, + WCT.Rollershade_exterior_two_motors: CoverDeviceClass.SHADE, + WCT.Shutter: CoverDeviceClass.SHUTTER, + WCT.Tilt_blind_tilt_only: CoverDeviceClass.BLIND, + WCT.Tilt_blind_tilt_and_lift: CoverDeviceClass.BLIND, +} diff --git a/zha/application/platforms/device_tracker.py b/zha/application/platforms/device_tracker.py index 9c96fd0e..5f2f5dda 100644 --- a/zha/application/platforms/device_tracker.py +++ b/zha/application/platforms/device_tracker.py @@ -1,88 +1,104 @@ -"""Support for the ZHA platform.""" +"""Support for the ZHA device tracker platform.""" from __future__ import annotations +from enum import StrEnum import functools import time - -from homeassistant.components.device_tracker import ScannerEntity, SourceType -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .core import discovery -from .core.const import ( +from typing import TYPE_CHECKING + +from zigpy.zcl.clusters.general import PowerConfiguration + +from zha.application import Platform +from zha.application.platforms import PlatformEntity +from zha.application.platforms.sensor import Battery +from zha.application.registries import PLATFORM_ENTITIES +from zha.decorators import periodic +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_POWER_CONFIGURATION, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, ) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity -from .sensor import Battery -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.DEVICE_TRACKER) +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + +STRICT_MATCH = functools.partial( + PLATFORM_ENTITIES.strict_match, Platform.DEVICE_TRACKER +) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation device tracker from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.DEVICE_TRACKER] +class SourceType(StrEnum): + """Source type for device trackers.""" - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) + GPS = "gps" + ROUTER = "router" + BLUETOOTH = "bluetooth" + BLUETOOTH_LE = "bluetooth_le" @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) -class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): +class DeviceScannerEntity(PlatformEntity): """Represent a tracked device.""" + PLATFORM = Platform.DEVICE_TRACKER + _attr_should_poll = True # BaseZhaEntity defaults to False _attr_name: str = "Device scanner" + __polling_interval: int - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): """Initialize the ZHA device tracker.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._battery_cluster_handler = self.cluster_handlers.get( + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._battery_cluster_handler: ClusterHandler = self.cluster_handlers.get( CLUSTER_HANDLER_POWER_CONFIGURATION ) - self._connected = False - self._keepalive_interval = 60 - self._battery_level = None - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - if self._battery_cluster_handler: - self.async_accept_signal( - self._battery_cluster_handler, - SIGNAL_ATTR_UPDATED, - self.async_battery_percentage_remaining_updated, + self._connected: bool = False + self._keepalive_interval: int = 60 + self._should_poll: bool = True + self._battery_level: float | None = None + self._battery_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + self._tracked_tasks.append( + device.gateway.async_create_background_task( + self._refresh(), + name=f"device_tracker_refresh_{self.unique_id}", + eager_start=True, + untracked=True, ) + ) + self.debug( + "started polling with refresh interval of %s", + getattr(self, "__polling_interval"), + ) async def async_update(self) -> None: """Handle polling.""" - if self.zha_device.last_seen is None: + if self.device.last_seen is None: self._connected = False else: - difference = time.time() - self.zha_device.last_seen + difference = time.time() - self.device.last_seen if difference > self._keepalive_interval: self._connected = False else: self._connected = True + self.maybe_emit_state_changed_event() + + @periodic((30, 45)) + async def _refresh(self) -> None: + """Refresh the state of the device tracker.""" + await self.async_update() @property def is_connected(self): @@ -94,15 +110,19 @@ def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" return SourceType.ROUTER - @callback - def async_battery_percentage_remaining_updated(self, attr_id, attr_name, value): + def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: """Handle tracking.""" - if attr_name != "battery_percentage_remaining": + if ( + event.attribute_name + != PowerConfiguration.AttributeDefs.battery_percentage_remaining.name + ): return - self.debug("battery_percentage_remaining updated: %s", value) + self.debug("battery_percentage_remaining updated: %s", event.attribute_value) self._connected = True - self._battery_level = Battery.formatter(value) - self.async_write_ha_state() + self._battery_level = Battery.formatter(event.attribute_value) + self.maybe_emit_state_changed_event() @property def battery_level(self): @@ -112,20 +132,13 @@ def battery_level(self): """ return self._battery_level - @property # type: ignore[misc] - def device_info( - self, - ) -> DeviceInfo: - """Return device info.""" - # We opt ZHA device tracker back into overriding this method because - # it doesn't track IP-based devices. - # Call Super because ScannerEntity overrode it. - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - return ZhaEntity.device_info.fget(self) # type: ignore[attr-defined] - - @property - def unique_id(self) -> str: - """Return unique ID.""" - # Call Super because ScannerEntity overrode it. - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - return ZhaEntity.unique_id.fget(self) # type: ignore[attr-defined] + def get_state(self) -> dict: + """Return the state of the device.""" + response = super().get_state() + response.update( + { + "connected": self._connected, + "battery_level": self._battery_level, + } + ) + return response diff --git a/zha/application/platforms/fan.py b/zha/application/platforms/fan.py deleted file mode 100644 index 35dda778..00000000 --- a/zha/application/platforms/fan.py +++ /dev/null @@ -1,315 +0,0 @@ -"""Fans on Zigbee Home Automation networks.""" - -from __future__ import annotations - -from abc import abstractmethod -import functools -import math -from typing import Any - -from homeassistant.components.fan import ( - ATTR_PERCENTAGE, - ATTR_PRESET_MODE, - FanEntity, - FanEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.percentage import ( - percentage_to_ranged_value, - ranged_value_to_percentage, -) -from homeassistant.util.scaling import int_states_in_range -from zigpy.zcl.clusters import hvac - -from .core import discovery -from .core.cluster_handlers import wrap_zigpy_exceptions -from .core.const import CLUSTER_HANDLER_FAN, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity, ZhaGroupEntity - -# Additional speeds in zigbee's ZCL -# Spec is unclear as to what this value means. On King Of Fans HBUniversal -# receiver, this means Very High. -PRESET_MODE_ON = "on" -# The fan speed is self-regulated -PRESET_MODE_AUTO = "auto" -# When the heated/cooled space is occupied, the fan is always on -PRESET_MODE_SMART = "smart" - -SPEED_RANGE = (1, 3) # off is not included -PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART} - -DEFAULT_ON_PERCENTAGE = 50 - -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) -GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.FAN) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.FAN) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation fan from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.FAN] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - ), - ) - config_entry.async_on_unload(unsub) - - -class BaseFan(FanEntity): - """Base representation of a ZHA fan.""" - - _attr_supported_features = FanEntityFeature.SET_SPEED - _attr_translation_key: str = "fan" - - @property - def preset_modes(self) -> list[str]: - """Return the available preset modes.""" - return list(self.preset_modes_to_name.values()) - - @property - def preset_modes_to_name(self) -> dict[int, str]: - """Return a dict from preset mode to name.""" - return PRESET_MODES_TO_NAME - - @property - def preset_name_to_mode(self) -> dict[str, int]: - """Return a dict from preset name to mode.""" - return {v: k for k, v in self.preset_modes_to_name.items()} - - @property - def default_on_percentage(self) -> int: - """Return the default on percentage.""" - return DEFAULT_ON_PERCENTAGE - - @property - def speed_range(self) -> tuple[int, int]: - """Return the range of speeds the fan supports. Off is not included.""" - return SPEED_RANGE - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(self.speed_range) - - async def async_turn_on( - self, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs: Any, - ) -> None: - """Turn the entity on.""" - if percentage is None: - percentage = self.default_on_percentage - await self.async_set_percentage(percentage) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - await self.async_set_percentage(0) - - async def async_set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - fan_mode = math.ceil(percentage_to_ranged_value(self.speed_range, percentage)) - await self._async_set_fan_mode(fan_mode) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode for the fan.""" - await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) - - @abstractmethod - async def _async_set_fan_mode(self, fan_mode: int) -> None: - """Set the fan mode for the fan.""" - - @callback - def async_set_state(self, attr_id, attr_name, value): - """Handle state update from cluster handler.""" - - -@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_FAN) -class ZhaFan(BaseFan, ZhaEntity): - """Representation of a ZHA fan.""" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Init this sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._fan_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_FAN) - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) - - @property - def percentage(self) -> int | None: - """Return the current speed percentage.""" - if ( - self._fan_cluster_handler.fan_mode is None - or self._fan_cluster_handler.fan_mode > self.speed_range[1] - ): - return None - if self._fan_cluster_handler.fan_mode == 0: - return 0 - return ranged_value_to_percentage( - self.speed_range, self._fan_cluster_handler.fan_mode - ) - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode.""" - return self.preset_modes_to_name.get(self._fan_cluster_handler.fan_mode) - - @callback - def async_set_state(self, attr_id, attr_name, value): - """Handle state update from cluster handler.""" - self.async_write_ha_state() - - async def _async_set_fan_mode(self, fan_mode: int) -> None: - """Set the fan mode for the fan.""" - await self._fan_cluster_handler.async_set_speed(fan_mode) - self.async_set_state(0, "fan_mode", fan_mode) - - -@GROUP_MATCH() -class FanGroup(BaseFan, ZhaGroupEntity): - """Representation of a fan group.""" - - _attr_translation_key: str = "fan_group" - - def __init__( - self, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs - ) -> None: - """Initialize a fan group.""" - super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) - self._available: bool = False - group = self.zha_device.gateway.get_group(self._group_id) - self._fan_cluster_handler = group.endpoint[hvac.Fan.cluster_id] - self._percentage = None - self._preset_mode = None - - @property - def percentage(self) -> int | None: - """Return the current speed percentage.""" - return self._percentage - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode.""" - return self._preset_mode - - async def _async_set_fan_mode(self, fan_mode: int) -> None: - """Set the fan mode for the group.""" - - with wrap_zigpy_exceptions(): - await self._fan_cluster_handler.write_attributes({"fan_mode": fan_mode}) - - self.async_set_state(0, "fan_mode", fan_mode) - - async def async_update(self) -> None: - """Attempt to retrieve on off state from the fan.""" - all_states = [self.hass.states.get(x) for x in self._entity_ids] - states: list[State] = list(filter(None, all_states)) - percentage_states: list[State] = [ - state for state in states if state.attributes.get(ATTR_PERCENTAGE) - ] - preset_mode_states: list[State] = [ - state for state in states if state.attributes.get(ATTR_PRESET_MODE) - ] - self._available = any(state.state != STATE_UNAVAILABLE for state in states) - - if percentage_states: - self._percentage = percentage_states[0].attributes[ATTR_PERCENTAGE] - self._preset_mode = None - elif preset_mode_states: - self._preset_mode = preset_mode_states[0].attributes[ATTR_PRESET_MODE] - self._percentage = None - else: - self._percentage = None - self._preset_mode = None - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await self.async_update() - await super().async_added_to_hass() - - -IKEA_SPEED_RANGE = (1, 10) # off is not included -IKEA_PRESET_MODES_TO_NAME = { - 1: PRESET_MODE_AUTO, - 2: "Speed 1", - 3: "Speed 1.5", - 4: "Speed 2", - 5: "Speed 2.5", - 6: "Speed 3", - 7: "Speed 3.5", - 8: "Speed 4", - 9: "Speed 4.5", - 10: "Speed 5", -} - - -@MULTI_MATCH( - cluster_handler_names="ikea_airpurifier", - models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, -) -class IkeaFan(ZhaFan): - """Representation of an Ikea fan.""" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: - """Init this sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._fan_cluster_handler = self.cluster_handlers.get("ikea_airpurifier") - - @property - def preset_modes_to_name(self) -> dict[int, str]: - """Return a dict from preset mode to name.""" - return IKEA_PRESET_MODES_TO_NAME - - @property - def speed_range(self) -> tuple[int, int]: - """Return the range of speeds the fan supports. Off is not included.""" - return IKEA_SPEED_RANGE - - @property - def default_on_percentage(self) -> int: - """Return the default on percentage.""" - return int( - (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] - ) - - -@MULTI_MATCH( - cluster_handler_names=CLUSTER_HANDLER_FAN, - models={"HBUniversalCFRemote", "HDC52EastwindFan"}, -) -class KofFan(ZhaFan): - """Representation of a fan made by King Of Fans.""" - - _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE - - @property - def speed_range(self) -> tuple[int, int]: - """Return the range of speeds the fan supports. Off is not included.""" - return (1, 4) - - @property - def preset_modes_to_name(self) -> dict[int, str]: - """Return a dict from preset mode to name.""" - return {6: PRESET_MODE_SMART} diff --git a/zha/application/platforms/fan/__init__.py b/zha/application/platforms/fan/__init__.py new file mode 100644 index 00000000..c88e1f87 --- /dev/null +++ b/zha/application/platforms/fan/__init__.py @@ -0,0 +1,422 @@ +"""Fans on Zigbee Home Automation networks.""" + +from __future__ import annotations + +from abc import abstractmethod +import functools +import math +from typing import TYPE_CHECKING, Any + +from zigpy.zcl.clusters import hvac + +from zha.application import Platform +from zha.application.platforms import BaseEntity, GroupEntity, PlatformEntity +from zha.application.platforms.fan.const import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DEFAULT_ON_PERCENTAGE, + LEGACY_SPEED_LIST, + OFF_SPEED_VALUES, + PRESET_MODE_AUTO, + PRESET_MODE_SMART, + PRESET_MODES_TO_NAME, + SPEED_OFF, + SPEED_RANGE, + SUPPORT_SET_SPEED, + FanEntityFeature, +) +from zha.application.platforms.fan.helpers import ( + NotValidPresetModeError, + int_states_in_range, + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers import ( + ClusterAttributeUpdatedEvent, + wrap_zigpy_exceptions, +) +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_FAN, +) +from zha.zigbee.group import Group + +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.FAN) +GROUP_MATCH = functools.partial(PLATFORM_ENTITIES.group_match, Platform.FAN) +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.FAN) + + +class BaseFan(BaseEntity): + """Base representation of a ZHA fan.""" + + PLATFORM = Platform.FAN + + _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_translation_key: str = "fan" + + @property + def preset_modes(self) -> list[str]: + """Return the available preset modes.""" + return list(self.preset_modes_to_name.values()) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return PRESET_MODES_TO_NAME + + @property + def preset_name_to_mode(self) -> dict[str, int]: + """Return a dict from preset name to mode.""" + return {v: k for k, v in self.preset_modes_to_name.items()} + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return DEFAULT_ON_PERCENTAGE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return SPEED_RANGE + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(self.speed_range) + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @property + def is_on(self) -> bool: + """Return true if the entity is on.""" + return self.speed not in [SPEED_OFF, None] # pylint: disable=no-member + + @property + def percentage_step(self) -> float: + """Return the step size for percentage.""" + return 100 / self.speed_count + + @property + def speed_list(self) -> list[str]: + """Get the list of available speeds.""" + speeds = [SPEED_OFF, *LEGACY_SPEED_LIST] + if preset_modes := self.preset_modes: + speeds.extend(preset_modes) + return speeds + + async def async_turn_on( # pylint: disable=unused-argument + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the entity on.""" + if preset_mode is not None: + await self.async_set_preset_mode(preset_mode) + elif speed is not None: + await self.async_set_percentage(self.speed_to_percentage(speed)) + elif percentage is not None: + await self.async_set_percentage(percentage) + else: + percentage = self.default_on_percentage + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + """Turn the entity off.""" + await self.async_set_percentage(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + fan_mode = math.ceil(percentage_to_ranged_value(self.speed_range, percentage)) + await self._async_set_fan_mode(fan_mode) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the fan.""" + try: + mode = self.preset_name_to_mode[preset_mode] + except KeyError as ex: + raise NotValidPresetModeError( + f"{preset_mode} is not a valid preset mode" + ) from ex + await self._async_set_fan_mode(mode) + + @abstractmethod + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle state update from cluster handler.""" + self.maybe_emit_state_changed_event() + + def speed_to_percentage(self, speed: str) -> int: + """Map a legacy speed to a percentage.""" + if speed in OFF_SPEED_VALUES: + return 0 + if speed not in LEGACY_SPEED_LIST: + raise ValueError(f"The speed {speed} is not a valid speed.") + return ordered_list_item_to_percentage(LEGACY_SPEED_LIST, speed) + + def percentage_to_speed(self, percentage: int) -> str: + """Map a percentage to a legacy speed.""" + if percentage == 0: + return SPEED_OFF + return percentage_to_ordered_list_item(LEGACY_SPEED_LIST, percentage) + + def to_json(self) -> dict: + """Return a JSON representation of the binary sensor.""" + json = super().to_json() + json["preset_modes"] = self.preset_modes + json["supported_features"] = self.supported_features + json["speed_count"] = self.speed_count + json["speed_list"] = self.speed_list + json["percentage_step"] = self.percentage_step + json["default_on_percentage"] = self.default_on_percentage + return json + + +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_FAN) +class Fan(PlatformEntity, BaseFan): + """Representation of a ZHA fan.""" + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ) -> None: + """Initialize the fan.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._fan_cluster_handler: ClusterHandler = self.cluster_handlers.get( + CLUSTER_HANDLER_FAN + ) + if self._fan_cluster_handler: + self._fan_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if ( + self._fan_cluster_handler.fan_mode is None + or self._fan_cluster_handler.fan_mode > self.speed_range[1] + ): + return None + if self._fan_cluster_handler.fan_mode == 0: + return 0 + return ranged_value_to_percentage( + self.speed_range, self._fan_cluster_handler.fan_mode + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.preset_modes_to_name.get(self._fan_cluster_handler.fan_mode) + + @property + def speed(self) -> str | None: + """Return the current speed.""" + if preset_mode := self.preset_mode: + return preset_mode + if (percentage := self.percentage) is None: + return None + return self.percentage_to_speed(percentage) + + def get_state(self) -> dict: + """Return the state of the fan.""" + response = super().get_state() + response.update( + { + "preset_mode": self.preset_mode, + "percentage": self.percentage, + "is_on": self.is_on, + "speed": self.speed, + } + ) + return response + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + await self._fan_cluster_handler.async_set_speed(fan_mode) + self.maybe_emit_state_changed_event() + + +@GROUP_MATCH() +class FanGroup(GroupEntity, BaseFan): + """Representation of a fan group.""" + + _attr_translation_key: str = "fan_group" + + def __init__(self, group: Group): + """Initialize a fan group.""" + self._fan_cluster_handler: ClusterHandler = group.endpoint[hvac.Fan.cluster_id] + super().__init__(group) + self._available: bool = False + self._percentage = None + self._preset_mode = None + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + return self._percentage + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self._preset_mode + + @property + def speed(self) -> str | None: + """Return the current speed.""" + if preset_mode := self.preset_mode: + return preset_mode + if (percentage := self.percentage) is None: + return None + return self.percentage_to_speed(percentage) + + def get_state(self) -> dict: + """Return the state of the fan.""" + response = super().get_state() + response.update( + { + "preset_mode": self.preset_mode, + "percentage": self.percentage, + "is_on": self.is_on, + "speed": self.speed, + } + ) + return response + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the group.""" + + with wrap_zigpy_exceptions(): + await self._fan_cluster_handler.write_attributes({"fan_mode": fan_mode}) + + self.maybe_emit_state_changed_event() + + def update(self, _: Any = None) -> None: + """Attempt to retrieve on off state from the fan.""" + self.debug("Updating fan group entity state") + platform_entities = self._group.get_platform_entities(self.PLATFORM) + all_entities = [entity.to_json() for entity in platform_entities] + all_states = [entity["state"] for entity in all_entities] + self.debug( + "All platform entity states for group entity members: %s", all_states + ) + + self._available = any(entity.available for entity in platform_entities) + percentage_states: list[dict] = [ + state for state in all_states if state.get(ATTR_PERCENTAGE) + ] + preset_mode_states: list[dict] = [ + state for state in all_states if state.get(ATTR_PRESET_MODE) + ] + + if percentage_states: + self._percentage = percentage_states[0][ATTR_PERCENTAGE] + self._preset_mode = None + elif preset_mode_states: + self._preset_mode = preset_mode_states[0][ATTR_PRESET_MODE] + self._percentage = None + else: + self._percentage = None + self._preset_mode = None + + self.maybe_emit_state_changed_event() + + +IKEA_SPEED_RANGE = (1, 10) # off is not included +IKEA_PRESET_MODES_TO_NAME = { + 1: PRESET_MODE_AUTO, + 2: "Speed 1", + 3: "Speed 1.5", + 4: "Speed 2", + 5: "Speed 2.5", + 6: "Speed 3", + 7: "Speed 3.5", + 8: "Speed 4", + 9: "Speed 4.5", + 10: "Speed 5", +} + + +@MULTI_MATCH( + cluster_handler_names="ikea_airpurifier", + models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, +) +class IkeaFan(Fan): + """Representation of an Ikea fan.""" + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ): + """Initialize the fan.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._fan_cluster_handler: ClusterHandler = self.cluster_handlers.get( + "ikea_airpurifier" + ) + self._fan_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return IKEA_PRESET_MODES_TO_NAME + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return IKEA_SPEED_RANGE + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return int( + (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] + ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_FAN, + models={"HBUniversalCFRemote", "HDC52EastwindFan"}, +) +class KofFan(Fan): + """Representation of a fan made by King Of Fans.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return (1, 4) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return {6: PRESET_MODE_SMART} diff --git a/zha/application/platforms/fan/const.py b/zha/application/platforms/fan/const.py new file mode 100644 index 00000000..3f703797 --- /dev/null +++ b/zha/application/platforms/fan/const.py @@ -0,0 +1,51 @@ +"""Constants for the Fan platform.""" + +# Additional speeds in zigbee's ZCL +# Spec is unclear as to what this value means. On King Of Fans HBUniversal +# receiver, this means Very High. +from enum import IntFlag +from typing import Final + +PRESET_MODE_ON: Final[str] = "on" +# The fan speed is self-regulated +PRESET_MODE_AUTO: Final[str] = "auto" +# When the heated/cooled space is occupied, the fan is always on +PRESET_MODE_SMART: Final[str] = "smart" + +SPEED_RANGE: Final = (1, 3) # off is not included +PRESET_MODES_TO_NAME: Final[dict[int, str]] = { + 4: PRESET_MODE_ON, + 5: PRESET_MODE_AUTO, + 6: PRESET_MODE_SMART, +} + +NAME_TO_PRESET_MODE = {v: k for k, v in PRESET_MODES_TO_NAME.items()} +PRESET_MODES = list(NAME_TO_PRESET_MODE) + +DEFAULT_ON_PERCENTAGE: Final[int] = 50 + +ATTR_PERCENTAGE: Final[str] = "percentage" +ATTR_PERCENTAGE_STEP: Final[str] = "percentage_step" +ATTR_OSCILLATING: Final[str] = "oscillating" +ATTR_DIRECTION: Final[str] = "direction" +ATTR_PRESET_MODE: Final[str] = "preset_mode" +ATTR_PRESET_MODES: Final[str] = "preset_modes" + +SUPPORT_SET_SPEED: Final[int] = 1 + +SPEED_OFF: Final[str] = "off" +SPEED_LOW: Final[str] = "low" +SPEED_MEDIUM: Final[str] = "medium" +SPEED_HIGH: Final[str] = "high" + +OFF_SPEED_VALUES: list[str | None] = [SPEED_OFF, None] +LEGACY_SPEED_LIST: list[str] = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + +class FanEntityFeature(IntFlag): + """Supported features of the fan entity.""" + + SET_SPEED = 1 + OSCILLATE = 2 + DIRECTION = 4 + PRESET_MODE = 8 diff --git a/zha/application/platforms/fan/helpers.py b/zha/application/platforms/fan/helpers.py new file mode 100644 index 00000000..86c1a960 --- /dev/null +++ b/zha/application/platforms/fan/helpers.py @@ -0,0 +1,100 @@ +"""Helper functions for the fan platform.""" + +from typing import TypeVar + +T = TypeVar("T") + + +def ordered_list_item_to_percentage(ordered_list: list[T], item: T) -> int: + """Determine the percentage of an item in an ordered list. + + When using this utility for fan speeds, do not include "off" + + Given the list: ["low", "medium", "high", "very_high"], this + function will return the following when the item is passed + in: + + low: 25 + medium: 50 + high: 75 + very_high: 100 + + """ + if item not in ordered_list: + raise ValueError(f'The item "{item}"" is not in "{ordered_list}"') + + list_len = len(ordered_list) + list_position = ordered_list.index(item) + 1 + return (list_position * 100) // list_len + + +def percentage_to_ordered_list_item(ordered_list: list[T], percentage: int) -> T: + """Find the item that most closely matches the percentage in an ordered list. + + When using this utility for fan speeds, do not include "off" + + Given the list: ["low", "medium", "high", "very_high"], this + function will return the following when when the item is passed + in: + + 1-25: low + 26-50: medium + 51-75: high + 76-100: very_high + """ + if not (list_len := len(ordered_list)): + raise ValueError("The ordered list is empty") + + for offset, speed in enumerate(ordered_list): + list_position = offset + 1 + upper_bound = (list_position * 100) // list_len + if percentage <= upper_bound: + return speed + + return ordered_list[-1] + + +def ranged_value_to_percentage( + low_high_range: tuple[float, float], value: float +) -> int: + """Given a range of low and high values convert a single value to a percentage. + + When using this utility for fan speeds, do not include 0 if it is off + Given a low value of 1 and a high value of 255 this function + will return: + (1,255), 255: 100 + (1,255), 127: 50 + (1,255), 10: 4 + """ + offset = low_high_range[0] - 1 + return int(((value - offset) * 100) // states_in_range(low_high_range)) + + +def percentage_to_ranged_value( + low_high_range: tuple[float, float], percentage: int +) -> float: + """Given a range of low and high values convert a percentage to a single value. + + When using this utility for fan speeds, do not include 0 if it is off + Given a low value of 1 and a high value of 255 this function + will return: + (1,255), 100: 255 + (1,255), 50: 127.5 + (1,255), 4: 10.2 + """ + offset = low_high_range[0] - 1 + return states_in_range(low_high_range) * percentage / 100 + offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) + + +class NotValidPresetModeError(ValueError): + """Exception class when the preset_mode in not in the preset_modes list.""" diff --git a/zha/application/platforms/helpers.py b/zha/application/platforms/helpers.py new file mode 100644 index 00000000..885d6f01 --- /dev/null +++ b/zha/application/platforms/helpers.py @@ -0,0 +1,99 @@ +"""Entity helpers for the zhaws server.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +import enum +import logging +from typing import TYPE_CHECKING, Any, overload + +if TYPE_CHECKING: + from zha.application.platforms.binary_sensor.const import BinarySensorDeviceClass + from zha.application.platforms.number.const import NumberDeviceClass + from zha.application.platforms.sensor.const import SensorDeviceClass + + +def find_state_attributes(states: list[dict], key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + if (value := state.get(key)) is not None: + yield value + + +def mean_int(*args: Any) -> int: + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def mean_tuple(*args: Any) -> tuple: + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(x) / len(x) for x in zip(*args)) + + +def reduce_attribute( + states: list[dict], + key: str, + default: Any | None = None, + reduce: Callable[..., Any] = mean_int, +) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) + + +@overload +def validate_device_class( + device_class_enum: type[BinarySensorDeviceClass], + metadata_value, + platform: str, + logger: logging.Logger, +) -> BinarySensorDeviceClass | None: ... + + +@overload +def validate_device_class( + device_class_enum: type[SensorDeviceClass], + metadata_value, + platform: str, + logger: logging.Logger, +) -> SensorDeviceClass | None: ... + + +@overload +def validate_device_class( + device_class_enum: type[NumberDeviceClass], + metadata_value, + platform: str, + logger: logging.Logger, +) -> NumberDeviceClass | None: ... + + +def validate_device_class( + device_class_enum: type[BinarySensorDeviceClass] + | type[SensorDeviceClass] + | type[NumberDeviceClass], + metadata_value: enum.Enum, + platform: str, + logger: logging.Logger, +) -> BinarySensorDeviceClass | SensorDeviceClass | NumberDeviceClass | None: + """Validate and return a device class.""" + try: + return device_class_enum(metadata_value.value) + except ValueError as ex: + logger.warning( + "Quirks provided an invalid device class: %s for platform %s: %s", + metadata_value, + platform, + ex, + ) + return None diff --git a/zha/application/platforms/light.py b/zha/application/platforms/light/__init__.py similarity index 60% rename from zha/application/platforms/light.py rename to zha/application/platforms/light/__init__.py index e60b2883..162895fb 100644 --- a/zha/application/platforms/light.py +++ b/zha/application/platforms/light/__init__.py @@ -1,165 +1,235 @@ """Lights on Zigbee Home Automation networks.""" +# pylint: disable=too-many-lines + from __future__ import annotations +from abc import ABC +import asyncio from collections import Counter from collections.abc import Callable -from datetime import timedelta import functools import itertools import logging -import random from typing import TYPE_CHECKING, Any -from homeassistant.components import light -from homeassistant.components.light import ( - ColorMode, - LightEntityFeature, - brightness_supported, - filter_supported_color_modes, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, - STATE_ON, - STATE_UNAVAILABLE, - Platform, -) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later, async_track_time_interval from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color from zigpy.zcl.foundation import Status -from .core import discovery, helpers -from .core.const import ( - CLUSTER_HANDLER_COLOR, - CLUSTER_HANDLER_LEVEL, - CLUSTER_HANDLER_ON_OFF, +from zha.application import Platform +from zha.application.const import ( CONF_ALWAYS_PREFER_XY_COLOR_MODE, CONF_DEFAULT_LIGHT_TRANSITION, CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, - DATA_ZHA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, - SIGNAL_SET_LEVEL, ZHA_OPTIONS, ) -from .core.helpers import LogMixin, async_get_zha_config_value, get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity, ZhaGroupEntity +from zha.application.helpers import async_get_zha_config_value +from zha.application.platforms import BaseEntity, GroupEntity, PlatformEntity +from zha.application.platforms.helpers import ( + find_state_attributes, + mean_tuple, + reduce_attribute, +) +from zha.application.platforms.light.const import ( + ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY, + ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_EFFECT_LIST, + ATTR_FLASH, + ATTR_HS_COLOR, + ATTR_MAX_MIREDS, + ATTR_MIN_MIREDS, + ATTR_SUPPORTED_COLOR_MODES, + ATTR_SUPPORTED_FEATURES, + ATTR_TRANSITION, + ATTR_XY_COLOR, + DEFAULT_EXTRA_TRANSITION_DELAY_LONG, + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT, + DEFAULT_LONG_TRANSITION_TIME, + DEFAULT_MIN_BRIGHTNESS, + DEFAULT_MIN_TRANSITION_MANUFACTURERS, + DEFAULT_ON_OFF_TRANSITION, + EFFECT_COLORLOOP, + FLASH_EFFECTS, + SUPPORT_GROUP_LIGHT, + ColorMode, + LightEntityFeature, +) +from zha.application.platforms.light.helpers import ( + brightness_supported, + filter_supported_color_modes, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.debounce import Debouncer +from zha.decorators import periodic +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_COLOR, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_ON_OFF, +) +from zha.zigbee.cluster_handlers.general import LevelChangeEvent if TYPE_CHECKING: - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + from zha.zigbee.group import Group _LOGGER = logging.getLogger(__name__) -DEFAULT_ON_OFF_TRANSITION = 1 # most bulbs default to a 1-second turn on/off transition -DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.25 -DEFAULT_EXTRA_TRANSITION_DELAY_LONG = 2.0 -DEFAULT_LONG_TRANSITION_TIME = 10 -DEFAULT_MIN_BRIGHTNESS = 2 -ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY = 0.05 - -FLASH_EFFECTS = { - light.FLASH_SHORT: Identify.EffectIdentifier.Blink, - light.FLASH_LONG: Identify.EffectIdentifier.Breathe, -} - -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT) -GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) -SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" -SIGNAL_LIGHT_GROUP_TRANSITION_START = "zha_light_group_transition_start" -SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED = "zha_light_group_transition_finished" -SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE = "zha_light_group_assume_group_state" -DEFAULT_MIN_TRANSITION_MANUFACTURERS = {"sengled"} - -COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY} -SUPPORT_GROUP_LIGHT = ( - light.LightEntityFeature.EFFECT - | light.LightEntityFeature.FLASH - | light.LightEntityFeature.TRANSITION -) - +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.LIGHT) +GROUP_MATCH = functools.partial(PLATFORM_ENTITIES.group_match, Platform.LIGHT) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation light from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.LIGHT] - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) - - -class BaseLight(LogMixin, light.LightEntity): +class BaseLight(BaseEntity, ABC): """Operations common to all light entities.""" + PLATFORM = Platform.LIGHT _FORCE_ON = False _DEFAULT_MIN_TRANSITION_TIME: float = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" - self._zha_device: ZHADevice = None + self._device: Device = None super().__init__(*args, **kwargs) - self._attr_min_mireds: int | None = 153 - self._attr_max_mireds: int | None = 500 - self._attr_color_mode = ColorMode.UNKNOWN # Set by subclasses - self._attr_supported_features: int = 0 - self._attr_state: bool | None + self._available: bool = False + self._min_mireds: int | None = 153 + self._max_mireds: int | None = 500 + self._hs_color: tuple[float, float] | None = None + self._xy_color: tuple[float, float] | None = None + self._color_mode = ColorMode.UNKNOWN # Set by subclasses + self._color_temp: int | None = None + self._supported_features: int = 0 + self._state: bool | None + self._brightness: int | None = None self._off_with_transition: bool = False self._off_brightness: int | None = None - self._zha_config_transition = self._DEFAULT_MIN_TRANSITION_TIME + self._effect_list: list[str] | None = None + self._effect: str | None = None + self._supported_color_modes: set[ColorMode] = set() + self._zha_config_transition: int = self._DEFAULT_MIN_TRANSITION_TIME self._zha_config_enhanced_light_transition: bool = False self._zha_config_enable_light_transitioning_flag: bool = True self._zha_config_always_prefer_xy_color_mode: bool = True - self._on_off_cluster_handler = None - self._level_cluster_handler = None - self._color_cluster_handler = None - self._identify_cluster_handler = None + self._on_off_cluster_handler: ClusterHandler = None + self._level_cluster_handler: ClusterHandler = None + self._color_cluster_handler: ClusterHandler = None + self._identify_cluster_handler: ClusterHandler = None self._transitioning_individual: bool = False self._transitioning_group: bool = False self._transition_listener: Callable[[], None] | None = None - async def async_will_remove_from_hass(self) -> None: - """Disconnect entity object when removed.""" - self._async_unsub_transition_listener() - await super().async_will_remove_from_hass() - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return state attributes.""" - attributes = { - "off_with_transition": self._off_with_transition, - "off_brightness": self._off_brightness, - } - return attributes + def get_state(self) -> dict[str, Any]: + """Return the state of the light.""" + response = super().get_state() + response["on"] = self.is_on + response["brightness"] = self.brightness + response["hs_color"] = self.hs_color + response["xy_color"] = self.xy_color + response["color_temp"] = self.color_temp + response["effect"] = self.effect + response["off_brightness"] = self._off_brightness + response["off_with_transition"] = self._off_with_transition + response["supported_features"] = self.supported_features + response["color_mode"] = self.color_mode + response["supported_color_modes"] = self._supported_color_modes + return response @property def is_on(self) -> bool: """Return true if entity is on.""" - if self._attr_state is None: + if self._state is None: return False - return self._attr_state + return self._state + + @property + def brightness(self) -> int | None: + """Return the brightness of this light.""" + return self._brightness + + @property + def min_mireds(self) -> int | None: + """Return the coldest color_temp that this light supports.""" + return self._min_mireds + + @property + def max_mireds(self) -> int | None: + """Return the warmest color_temp that this light supports.""" + return self._max_mireds + + def handle_cluster_handler_set_level(self, event: LevelChangeEvent) -> None: + """Set the brightness of this light between 0..254. + + brightness level 255 is a special value instructing the device to come + on at `on_level` Zigbee attribute value, regardless of the last set + level + """ + if self.is_transitioning: + self.debug( + "received level change event %s while transitioning - skipping update", + event, + ) + return + value = max(0, min(254, event.level)) + self._brightness = value + self.maybe_emit_state_changed_event() + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hs color value [int, int].""" + return self._hs_color + + @property + def xy_color(self) -> tuple[float, float] | None: + """Return the xy color value [float, float].""" + return self._xy_color + + @property + def color_temp(self) -> int | None: + """Return the CT color value in mireds.""" + return self._color_temp + + @property + def color_mode(self) -> int | None: + """Return the color mode.""" + return self._color_mode + + @property + def effect_list(self) -> list[str] | None: + """Return the list of supported effects.""" + return self._effect_list + + @property + def effect(self) -> str | None: + """Return the current effect.""" + return self._effect + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def supported_color_modes(self) -> set[ColorMode]: + """Flag supported color modes.""" + return self._supported_color_modes + + def to_json(self) -> dict: + """Return a JSON representation of the select.""" + json = super().to_json() + json["supported_features"] = self.supported_features + json["effect_list"] = self.effect_list + json["min_mireds"] = self.min_mireds + json["max_mireds"] = self.max_mireds + return json - @callback def set_level(self, value: int) -> None: """Set the brightness of this light between 0..254. @@ -174,24 +244,24 @@ def set_level(self, value: int) -> None: ) return value = max(0, min(254, value)) - self._attr_brightness = value - self.async_write_ha_state() + self._brightness = value + self.maybe_emit_state_changed_event() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - transition = kwargs.get(light.ATTR_TRANSITION) + transition = kwargs.get(ATTR_TRANSITION) duration = ( transition if transition is not None else self._zha_config_transition ) or ( # if 0 is passed in some devices still need the minimum default self._DEFAULT_MIN_TRANSITION_TIME ) - brightness = kwargs.get(light.ATTR_BRIGHTNESS) - effect = kwargs.get(light.ATTR_EFFECT) - flash = kwargs.get(light.ATTR_FLASH) - temperature = kwargs.get(light.ATTR_COLOR_TEMP) - xy_color = kwargs.get(light.ATTR_XY_COLOR) - hs_color = kwargs.get(light.ATTR_HS_COLOR) + brightness = kwargs.get(ATTR_BRIGHTNESS) + effect = kwargs.get(ATTR_EFFECT) + flash = kwargs.get(ATTR_FLASH) + temperature = kwargs.get(ATTR_COLOR_TEMP) + xy_color = kwargs.get(ATTR_XY_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) execute_if_off_supported = ( self._GROUP_SUPPORTS_EXECUTE_IF_OFF @@ -201,7 +271,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) set_transition_flag = ( - brightness_supported(self._attr_supported_color_modes) + brightness_supported(self._supported_color_modes) or temperature is not None or xy_color is not None or hs_color is not None @@ -211,7 +281,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: duration + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT if ( (brightness is not None or transition is not None) - and brightness_supported(self._attr_supported_color_modes) + and brightness_supported(self._supported_color_modes) or (self._off_with_transition and self._off_brightness is not None) or temperature is not None or xy_color is not None @@ -247,31 +317,25 @@ async def async_turn_on(self, **kwargs: Any) -> None: new_color_provided_while_off = ( self._zha_config_enhanced_light_transition and not self._FORCE_ON - and not self._attr_state + and not self._state and ( ( temperature is not None and ( - self._attr_color_temp != temperature - or self._attr_color_mode != ColorMode.COLOR_TEMP + self._color_temp != temperature + or self._color_mode != ColorMode.COLOR_TEMP ) ) or ( xy_color is not None - and ( - self._attr_xy_color != xy_color - or self._attr_color_mode != ColorMode.XY - ) + and (self._xy_color != xy_color or self._color_mode != ColorMode.XY) ) or ( hs_color is not None - and ( - self._attr_hs_color != hs_color - or self._attr_color_mode != ColorMode.HS - ) + and (self._hs_color != hs_color or self._color_mode != ColorMode.HS) ) ) - and brightness_supported(self._attr_supported_color_modes) + and brightness_supported(self._supported_color_modes) and not execute_if_off_supported ) @@ -285,7 +349,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: if brightness is not None: level = min(254, brightness) else: - level = self._attr_brightness or 254 + level = self._brightness or 254 t_log = {} @@ -308,7 +372,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: return # Currently only setting it to "on", as the correct level state will # be set at the second move_to_level call - self._attr_state = True + self._state = True if execute_if_off_supported: self.debug("handling color commands before turning on/level") @@ -331,7 +395,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: if ( (brightness is not None or transition is not None) and not new_color_provided_while_off - and brightness_supported(self._attr_supported_color_modes) + and brightness_supported(self._supported_color_modes) ): result = await self._level_cluster_handler.move_to_level_with_on_off( level=level, @@ -345,9 +409,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.async_transition_complete() self.debug("turned on: %s", t_log) return - self._attr_state = bool(level) + self._state = bool(level) if level: - self._attr_brightness = level + self._brightness = level if ( (brightness is None and transition is None) @@ -366,7 +430,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.async_transition_start_timer(transition_time) self.debug("turned on: %s", t_log) return - self._attr_state = True + self._state = True if not execute_if_off_supported: self.debug("handling color commands after turning on/level") @@ -394,16 +458,16 @@ async def async_turn_on(self, **kwargs: Any) -> None: if result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return - self._attr_state = bool(level) + self._state = bool(level) if level: - self._attr_brightness = level + self._brightness = level # Our light is guaranteed to have just started the transitioning process # if necessary, so we start the delay for the transition (to stop parsing # attribute reports after the completed transition). self.async_transition_start_timer(transition_time) - if effect == light.EFFECT_COLORLOOP: + if effect == EFFECT_COLORLOOP: result = await self._color_cluster_handler.color_loop_set( update_flags=( Color.ColorLoopUpdateFlags.Action @@ -416,11 +480,8 @@ async def async_turn_on(self, **kwargs: Any) -> None: start_hue=0, ) t_log["color_loop_set"] = result - self._attr_effect = light.EFFECT_COLORLOOP - elif ( - self._attr_effect == light.EFFECT_COLORLOOP - and effect != light.EFFECT_COLORLOOP - ): + self._effect = EFFECT_COLORLOOP + elif self._effect == EFFECT_COLORLOOP and effect != EFFECT_COLORLOOP: result = await self._color_cluster_handler.color_loop_set( update_flags=Color.ColorLoopUpdateFlags.Action, action=Color.ColorLoopAction.Deactivate, @@ -429,7 +490,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: start_hue=0, ) t_log["color_loop_set"] = result - self._attr_effect = None + self._effect = None if flash is not None: result = await self._identify_cluster_handler.trigger_effect( @@ -441,12 +502,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: self._off_with_transition = False self._off_brightness = None self.debug("turned on: %s", t_log) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - transition = kwargs.get(light.ATTR_TRANSITION) - supports_level = brightness_supported(self._attr_supported_color_modes) + transition = kwargs.get(ATTR_TRANSITION) + supports_level = brightness_supported(self._supported_color_modes) transition_time = ( transition or self._DEFAULT_MIN_TRANSITION_TIME @@ -476,20 +537,20 @@ async def async_turn_off(self, **kwargs: Any) -> None: self.debug("turned off: %s", result) if result[1] is not Status.SUCCESS: return - self._attr_state = False + self._state = False if supports_level and not self._off_with_transition: # store current brightness so that the next turn_on uses it: # when using "enhanced turn on" - self._off_brightness = self._attr_brightness + self._off_brightness = self._brightness if transition is not None: # save for when calling turn_on without a brightness: # current_level is set to 1 after transitioning to level 0, # needed for correct state with light groups - self._attr_brightness = 1 + self._brightness = 1 self._off_with_transition = transition is not None - self.async_write_ha_state() + self.maybe_emit_state_changed_event() async def async_handle_color_commands( self, @@ -516,10 +577,10 @@ async def async_handle_color_commands( t_log["move_to_color_temp"] = result if result[1] is not Status.SUCCESS: return False - self._attr_color_mode = ColorMode.COLOR_TEMP - self._attr_color_temp = temperature - self._attr_xy_color = None - self._attr_hs_color = None + self._color_mode = ColorMode.COLOR_TEMP + self._color_temp = temperature + self._xy_color = None + self._hs_color = None if hs_color is not None: if ( @@ -541,10 +602,10 @@ async def async_handle_color_commands( t_log["move_to_hue_and_saturation"] = result if result[1] is not Status.SUCCESS: return False - self._attr_color_mode = ColorMode.HS - self._attr_hs_color = hs_color - self._attr_xy_color = None - self._attr_color_temp = None + self._color_mode = ColorMode.HS + self._hs_color = hs_color + self._xy_color = None + self._color_temp = None xy_color = None # don't set xy_color if it is also present if xy_color is not None: @@ -556,10 +617,10 @@ async def async_handle_color_commands( t_log["move_to_color"] = result if result[1] is not Status.SUCCESS: return False - self._attr_color_mode = ColorMode.XY - self._attr_xy_color = xy_color - self._attr_color_temp = None - self._attr_hs_color = None + self._color_mode = ColorMode.XY + self._xy_color = xy_color + self._color_temp = None + self._hs_color = None return True @@ -568,21 +629,16 @@ def is_transitioning(self) -> bool: """Return if the light is transitioning.""" return self._transitioning_individual or self._transitioning_group - @callback def async_transition_set_flag(self) -> None: """Set _transitioning to True.""" self.debug("setting transitioning flag to True") self._transitioning_individual = True self._transitioning_group = False if isinstance(self, LightGroup): - async_dispatcher_send( - self.hass, - SIGNAL_LIGHT_GROUP_TRANSITION_START, - {"entity_ids": self._entity_ids}, - ) + for platform_entity in self.group.get_platform_entities(Light.PLATFORM): + platform_entity.transition_on() self._async_unsub_transition_listener() - @callback def async_transition_start_timer(self, transition_time) -> None: """Start a timer to unset _transitioning_individual after transition_time. @@ -594,37 +650,31 @@ def async_transition_start_timer(self, transition_time) -> None: if transition_time >= DEFAULT_LONG_TRANSITION_TIME: transition_time += DEFAULT_EXTRA_TRANSITION_DELAY_LONG self.debug("starting transitioning timer for %s", transition_time) - self._transition_listener = async_call_later( - self._zha_device.hass, + self._transition_listener = asyncio.get_running_loop().call_later( transition_time, self.async_transition_complete, ) - @callback def _async_unsub_transition_listener(self) -> None: """Unsubscribe transition listener.""" if self._transition_listener: - self._transition_listener() + self._transition_listener.cancel() self._transition_listener = None - @callback def async_transition_complete(self, _=None) -> None: """Set _transitioning_individual to False and write HA state.""" self.debug("transition complete - future attribute reports will write HA state") self._transitioning_individual = False self._async_unsub_transition_listener() - self.async_write_ha_state() + self.maybe_emit_state_changed_event() if isinstance(self, LightGroup): - async_dispatcher_send( - self.hass, - SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED, - {"entity_ids": self._entity_ids}, - ) + for platform_entity in self.group.get_platform_entities(Light.PLATFORM): + platform_entity.transition_off() + if self._debounced_member_refresh is not None: self.debug("transition complete - refreshing group member states") - assert self.platform.config_entry - self.platform.config_entry.async_create_background_task( - self.hass, + + self.group.gateway.async_create_task( self._debounced_member_refresh.async_call(), "zha.light-refresh-debounced-member", ) @@ -634,64 +684,76 @@ def async_transition_complete(self, _=None) -> None: cluster_handler_names=CLUSTER_HANDLER_ON_OFF, aux_cluster_handlers={CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL}, ) -class Light(BaseLight, ZhaEntity): +class Light(PlatformEntity, BaseLight): """Representation of a ZHA or ZLL light.""" - _attr_supported_color_modes: set[ColorMode] + _supported_color_modes: set[ColorMode] _attr_translation_key: str = "light" - _REFRESH_INTERVAL = (45, 75) + _REFRESH_INTERVAL = (2700, 4500) + __polling_interval: int def __init__( - self, unique_id, zha_device: ZHADevice, cluster_handlers, **kwargs + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, ) -> None: - """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] - self._attr_state = bool(self._on_off_cluster_handler.on_off) - self._level_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_LEVEL) - self._color_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COLOR) - self._identify_cluster_handler = zha_device.identify_ch + """Initialize the light.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._on_off_cluster_handler: ClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_ON_OFF + ] + self._state: bool = bool(self._on_off_cluster_handler.on_off) + self._level_cluster_handler: ClusterHandler = self.cluster_handlers.get( + CLUSTER_HANDLER_LEVEL + ) + self._color_cluster_handler: ClusterHandler = self.cluster_handlers.get( + CLUSTER_HANDLER_COLOR + ) + self._identify_cluster_handler: ClusterHandler = device.identify_ch if self._color_cluster_handler: - self._attr_min_mireds: int = self._color_cluster_handler.min_mireds - self._attr_max_mireds: int = self._color_cluster_handler.max_mireds - self._cancel_refresh_handle: CALLBACK_TYPE | None = None + self._min_mireds: int = self._color_cluster_handler.min_mireds + self._max_mireds: int = self._color_cluster_handler.max_mireds + self._cancel_refresh_handle: Callable | None = None effect_list = [] self._zha_config_always_prefer_xy_color_mode = async_get_zha_config_value( - zha_device.gateway.config_entry, + device.gateway.config, ZHA_OPTIONS, CONF_ALWAYS_PREFER_XY_COLOR_MODE, True, ) - self._attr_supported_color_modes = {ColorMode.ONOFF} + self._supported_color_modes = {ColorMode.ONOFF} if self._level_cluster_handler: - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) - self._attr_supported_features |= light.LightEntityFeature.TRANSITION - self._attr_brightness = self._level_cluster_handler.current_level + self._supported_color_modes.add(ColorMode.BRIGHTNESS) + self._supported_features |= LightEntityFeature.TRANSITION + self._brightness = self._level_cluster_handler.current_level if self._color_cluster_handler: if self._color_cluster_handler.color_temp_supported: - self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - self._attr_color_temp = self._color_cluster_handler.color_temperature + self._supported_color_modes.add(ColorMode.COLOR_TEMP) + self._color_temp = self._color_cluster_handler.color_temperature if self._color_cluster_handler.xy_supported and ( self._zha_config_always_prefer_xy_color_mode or not self._color_cluster_handler.hs_supported ): - self._attr_supported_color_modes.add(ColorMode.XY) + self._supported_color_modes.add(ColorMode.XY) curr_x = self._color_cluster_handler.current_x curr_y = self._color_cluster_handler.current_y if curr_x is not None and curr_y is not None: - self._attr_xy_color = (curr_x / 65535, curr_y / 65535) + self._xy_color = (curr_x / 65535, curr_y / 65535) else: - self._attr_xy_color = (0, 0) + self._xy_color = (0, 0) if ( self._color_cluster_handler.hs_supported and not self._zha_config_always_prefer_xy_color_mode ): - self._attr_supported_color_modes.add(ColorMode.HS) + self._supported_color_modes.add(ColorMode.HS) if ( self._color_cluster_handler.enhanced_hue_supported and self._color_cluster_handler.enhanced_current_hue is not None @@ -709,164 +771,126 @@ def __init__( ) is None: curr_saturation = 0 - self._attr_hs_color = ( + self._hs_color = ( int(curr_hue), int(curr_saturation * 2.54), ) if self._color_cluster_handler.color_loop_supported: - self._attr_supported_features |= light.LightEntityFeature.EFFECT - effect_list.append(light.EFFECT_COLORLOOP) + self._supported_features |= LightEntityFeature.EFFECT + effect_list.append(EFFECT_COLORLOOP) if self._color_cluster_handler.color_loop_active == 1: - self._attr_effect = light.EFFECT_COLORLOOP - self._attr_supported_color_modes = filter_supported_color_modes( - self._attr_supported_color_modes + self._effect = EFFECT_COLORLOOP + supported_color_modes: set[ColorMode] = filter_supported_color_modes( + self._supported_color_modes ) - if len(self._attr_supported_color_modes) == 1: - self._attr_color_mode = next(iter(self._attr_supported_color_modes)) + if len(supported_color_modes) == 1: + self._color_mode = next(iter(supported_color_modes)) else: # Light supports color_temp + hs, determine which mode the light is in assert self._color_cluster_handler if ( self._color_cluster_handler.color_mode == Color.ColorMode.Color_temperature ): - self._attr_color_mode = ColorMode.COLOR_TEMP + self._color_mode = ColorMode.COLOR_TEMP else: - self._attr_color_mode = ColorMode.XY + self._color_mode = ColorMode.XY if self._identify_cluster_handler: - self._attr_supported_features |= light.LightEntityFeature.FLASH + self._supported_features |= LightEntityFeature.FLASH if effect_list: - self._attr_effect_list = effect_list + self._effect_list = effect_list self._zha_config_transition = async_get_zha_config_value( - zha_device.gateway.config_entry, + device.gateway.config, ZHA_OPTIONS, CONF_DEFAULT_LIGHT_TRANSITION, 0, ) self._zha_config_enhanced_light_transition = async_get_zha_config_value( - zha_device.gateway.config_entry, + device.gateway.config, ZHA_OPTIONS, CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, False, ) self._zha_config_enable_light_transitioning_flag = async_get_zha_config_value( - zha_device.gateway.config_entry, + device.gateway.config, ZHA_OPTIONS, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, True, ) - @callback - def async_set_state(self, attr_id, attr_name, value): - """Set the state.""" - if self.is_transitioning: - self.debug( - "received onoff %s while transitioning - skipping update", - value, - ) - return - self._attr_state = bool(value) - if value: - self._off_with_transition = False - self._off_brightness = None - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + self._on_off_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, ) + if self._level_cluster_handler: - self.async_accept_signal( - self._level_cluster_handler, SIGNAL_SET_LEVEL, self.set_level + self._level_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, self.handle_cluster_handler_set_level + ) + + self._tracked_tasks.append( + device.gateway.async_create_background_task( + self._refresh(), + name=f"light_refresh_{self.unique_id}", + eager_start=True, + untracked=True, ) - refresh_interval = random.randint(*(x * 60 for x in self._REFRESH_INTERVAL)) - self._cancel_refresh_handle = async_track_time_interval( - self.hass, self._refresh, timedelta(seconds=refresh_interval) ) - self.debug("started polling with refresh interval of %s", refresh_interval) - self.async_accept_signal( - None, - SIGNAL_LIGHT_GROUP_STATE_CHANGED, - self._maybe_force_refresh, - signal_override=True, + self.debug( + "started polling with refresh interval of %s", + getattr(self, "__polling_interval"), ) - @callback - def transition_on(signal): - """Handle a transition start event from a group.""" - if self.entity_id in signal["entity_ids"]: - self.debug( - "group transition started - setting member transitioning flag" - ) - self._transitioning_group = True - - self.async_accept_signal( - None, - SIGNAL_LIGHT_GROUP_TRANSITION_START, - transition_on, - signal_override=True, - ) + @periodic(_REFRESH_INTERVAL) + async def _refresh(self) -> None: + """Call async_get_state at an interval.""" + await self.async_update() - @callback - def transition_off(signal): - """Handle a transition finished event from a group.""" - if self.entity_id in signal["entity_ids"]: - self.debug( - "group transition completed - unsetting member transitioning flag" - ) - self._transitioning_group = False + def transition_on(self): + """Handle a transition start event from a group.""" + self.debug("group transition started - setting member transitioning flag") + self._transitioning_group = True - self.async_accept_signal( - None, - SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED, - transition_off, - signal_override=True, - ) + def transition_off(self): + """Handle a transition finished event from a group.""" + self.debug("group transition completed - unsetting member transitioning flag") + self._transitioning_group = False - self.async_accept_signal( - None, - SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE, - self._assume_group_state, - signal_override=True, - ) + def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Set the state.""" + if ( + event.cluster_id != self._on_off_cluster_handler.cluster.cluster_id + or event.attribute_id != OnOff.AttributeDefs.on_off.id + ): + return + if self.is_transitioning: + self.debug( + "received onoff %s while transitioning - skipping update", + event.attribute_value, + ) + return + self._state = bool(event.attribute_value) + if event.attribute_value: + self._off_with_transition = False + self._off_brightness = None + self.maybe_emit_state_changed_event() - async def async_will_remove_from_hass(self) -> None: - """Disconnect entity object when removed.""" - assert self._cancel_refresh_handle - self._cancel_refresh_handle() - self._cancel_refresh_handle = None - self.debug("stopped polling during device removal") - await super().async_will_remove_from_hass() - - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - self._attr_state = last_state.state == STATE_ON - if "brightness" in last_state.attributes: - self._attr_brightness = last_state.attributes["brightness"] - if "off_with_transition" in last_state.attributes: - self._off_with_transition = last_state.attributes["off_with_transition"] - if "off_brightness" in last_state.attributes: - self._off_brightness = last_state.attributes["off_brightness"] - if (color_mode := last_state.attributes.get("color_mode")) is not None: - self._attr_color_mode = ColorMode(color_mode) - if "color_temp" in last_state.attributes: - self._attr_color_temp = last_state.attributes["color_temp"] - if "xy_color" in last_state.attributes: - self._attr_xy_color = last_state.attributes["xy_color"] - if "hs_color" in last_state.attributes: - self._attr_hs_color = last_state.attributes["hs_color"] - if "effect" in last_state.attributes: - self._attr_effect = last_state.attributes["effect"] - - async def async_get_state(self) -> None: + async def async_update(self) -> None: """Attempt to retrieve the state from the light.""" - if not self._attr_available: + if self.is_transitioning: + self.debug("skipping async_update while transitioning") + return + if not self.device.gateway.config.allow_polling or not self._device.available: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._device.available, + self.device.gateway.config.allow_polling, + ) return self.debug("polling current state") @@ -876,10 +900,10 @@ async def async_get_state(self) -> None: ) # check if transition started whilst waiting for polled state if self.is_transitioning: - return + return # type: ignore #TODO figure this out if state is not None: - self._attr_state = state + self._state = state if state: # reset "off with transition" flag if the light is on self._off_with_transition = False self._off_brightness = None @@ -890,9 +914,9 @@ async def async_get_state(self) -> None: ) # check if transition started whilst waiting for polled state if self.is_transitioning: - return + return # type: ignore #TODO figure this out if level is not None: - self._attr_brightness = level + self._brightness = level if self._color_cluster_handler: attributes = [ @@ -926,122 +950,80 @@ async def async_get_state(self) -> None: # for the polled attributes, so abort if we are transitioning, # as that state will not be accurate if self.is_transitioning: - return + return # type: ignore #TODO figure this out if (color_mode := results.get("color_mode")) is not None: if color_mode == Color.ColorMode.Color_temperature: - self._attr_color_mode = ColorMode.COLOR_TEMP + self._color_mode = ColorMode.COLOR_TEMP color_temp = results.get("color_temperature") if color_temp is not None and color_mode: - self._attr_color_temp = color_temp - self._attr_xy_color = None - self._attr_hs_color = None + self._color_temp = color_temp + self._xy_color = None + self._hs_color = None elif ( color_mode == Color.ColorMode.Hue_and_saturation and not self._zha_config_always_prefer_xy_color_mode ): - self._attr_color_mode = ColorMode.HS + self._color_mode = ColorMode.HS if self._color_cluster_handler.enhanced_hue_supported: current_hue = results.get("enhanced_current_hue") else: current_hue = results.get("current_hue") current_saturation = results.get("current_saturation") if current_hue is not None and current_saturation is not None: - self._attr_hs_color = ( + self._hs_color = ( int(current_hue * 360 / 65535) if self._color_cluster_handler.enhanced_hue_supported else int(current_hue * 360 / 254), int(current_saturation / 2.54), ) - self._attr_xy_color = None - self._attr_color_temp = None + self._xy_color = None + self._color_temp = None else: - self._attr_color_mode = ColorMode.XY + self._color_mode = ColorMode.XY color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: - self._attr_xy_color = (color_x / 65535, color_y / 65535) - self._attr_color_temp = None - self._attr_hs_color = None + self._xy_color = (color_x / 65535, color_y / 65535) + self._color_temp = None + self._hs_color = None color_loop_active = results.get("color_loop_active") if color_loop_active is not None: if color_loop_active == 1: - self._attr_effect = light.EFFECT_COLORLOOP + self._effect = EFFECT_COLORLOOP else: - self._attr_effect = None + self._effect = None + self.maybe_emit_state_changed_event() - async def async_update(self) -> None: - """Update to the latest state.""" - if self.is_transitioning: - self.debug("skipping async_update while transitioning") - return - await self.async_get_state() - - async def _refresh(self, time): - """Call async_get_state at an interval.""" - if self.is_transitioning: - self.debug("skipping _refresh while transitioning") - return - if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: - self.debug("polling for updated state") - await self.async_get_state() - self.async_write_ha_state() - else: - self.debug( - "skipping polling for updated state, available: %s, allow polled requests: %s", - self._zha_device.available, - self.hass.data[DATA_ZHA].allow_polling, - ) - - async def _maybe_force_refresh(self, signal): - """Force update the state if the signal contains the entity id for this entity.""" - if self.entity_id in signal["entity_ids"]: - if self.is_transitioning: - self.debug("skipping _maybe_force_refresh while transitioning") - return - if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: - self.debug("forcing polling for updated state") - await self.async_get_state() - self.async_write_ha_state() - else: - self.debug( - "skipping _maybe_force_refresh, available: %s, allow polled requests: %s", - self._zha_device.available, - self.hass.data[DATA_ZHA].allow_polling, - ) - - @callback - def _assume_group_state(self, signal, update_params) -> None: + def _assume_group_state(self, update_params) -> None: """Handle an assume group state event from a group.""" - if self.entity_id in signal["entity_ids"] and self._attr_available: + if self.available: self.debug("member assuming group state with: %s", update_params) state = update_params["state"] - brightness = update_params.get(light.ATTR_BRIGHTNESS) - color_mode = update_params.get(light.ATTR_COLOR_MODE) - color_temp = update_params.get(light.ATTR_COLOR_TEMP) - xy_color = update_params.get(light.ATTR_XY_COLOR) - hs_color = update_params.get(light.ATTR_HS_COLOR) - effect = update_params.get(light.ATTR_EFFECT) + brightness = update_params.get(ATTR_BRIGHTNESS) + color_mode = update_params.get(ATTR_COLOR_MODE) + color_temp = update_params.get(ATTR_COLOR_TEMP) + xy_color = update_params.get(ATTR_XY_COLOR) + hs_color = update_params.get(ATTR_HS_COLOR) + effect = update_params.get(ATTR_EFFECT) - supported_modes = self._attr_supported_color_modes + supported_modes = self._supported_color_modes # unset "off brightness" and "off with transition" # if group turned on this light - if state and not self._attr_state: + if state and not self._state: self._off_with_transition = False self._off_brightness = None # set "off brightness" and "off with transition" # if group turned off this light, and the light was not already off # (to not override _off_with_transition) - elif ( - not state and self._attr_state and brightness_supported(supported_modes) - ): + elif not state and self._state and brightness_supported(supported_modes): # use individual brightness, instead of possibly averaged # brightness from group - self._off_brightness = self._attr_brightness + self._off_brightness = self._brightness self._off_with_transition = update_params["off_with_transition"] # Note: If individual lights have off_with_transition set, but not the @@ -1053,28 +1035,28 @@ def _assume_group_state(self, signal, update_params) -> None: # turn_on should either just be called with a level or individual turn_on # calls can be used. - # state is always set (light.turn_on/light.turn_off) - self._attr_state = state + # state is always set (turn_on/turn_off) + self._state = state # before assuming a group state attribute, check if the attribute # was actually set in that call if brightness is not None and brightness_supported(supported_modes): - self._attr_brightness = brightness + self._brightness = brightness if color_mode is not None and color_mode in supported_modes: - self._attr_color_mode = color_mode + self._color_mode = color_mode if color_temp is not None and ColorMode.COLOR_TEMP in supported_modes: - self._attr_color_temp = color_temp + self._color_temp = color_temp if xy_color is not None and ColorMode.XY in supported_modes: - self._attr_xy_color = xy_color + self._xy_color = xy_color if hs_color is not None and ColorMode.HS in supported_modes: - self._attr_hs_color = hs_color + self._hs_color = hs_color # the effect is always deactivated in async_turn_on if not provided if effect is None: - self._attr_effect = None - elif self._attr_effect_list and effect in self._attr_effect_list: - self._attr_effect = effect + self._effect = None + elif self._effect_list and effect in self._effect_list: + self._effect = effect - self.async_write_ha_state() + self.maybe_emit_state_changed_event() @STRICT_MATCH( @@ -1085,7 +1067,7 @@ def _assume_group_state(self, signal, update_params) -> None: class HueLight(Light): """Representation of a HUE light which does not report attributes.""" - _REFRESH_INTERVAL = (3, 5) + _REFRESH_INTERVAL = (180, 300) @STRICT_MATCH( @@ -1112,24 +1094,15 @@ class MinTransitionLight(Light): @GROUP_MATCH() -class LightGroup(BaseLight, ZhaGroupEntity): +class LightGroup(GroupEntity, BaseLight): """Representation of a light group.""" _attr_translation_key: str = "light_group" - def __init__( - self, - entity_ids: list[str], - unique_id: str, - group_id: int, - zha_device: ZHADevice, - **kwargs: Any, - ) -> None: + def __init__(self, group: Group): """Initialize a light group.""" - super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) - group = self.zha_device.gateway.get_group(self._group_id) - - self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True + super().__init__(group) + self._GROUP_SUPPORTS_EXECUTE_IF_OFF: bool = True for member in group.members: # Ensure we do not send group commands that violate the minimum transition @@ -1151,31 +1124,39 @@ def __init__( self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False break - self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] - self._level_cluster_handler = group.endpoint[LevelControl.cluster_id] - self._color_cluster_handler = group.endpoint[Color.cluster_id] - self._identify_cluster_handler = group.endpoint[Identify.cluster_id] + self._on_off_cluster_handler: ClusterHandler = group.zigpy_group.endpoint[ + OnOff.cluster_id + ] + self._level_cluster_handler: None | ( + ClusterHandler + ) = group.zigpy_group.endpoint[LevelControl.cluster_id] + self._color_cluster_handler: None | ( + ClusterHandler + ) = group.zigpy_group.endpoint[Color.cluster_id] + self._identify_cluster_handler: None | ( + ClusterHandler + ) = group.zigpy_group.endpoint[Identify.cluster_id] self._debounced_member_refresh: Debouncer | None = None self._zha_config_transition = async_get_zha_config_value( - zha_device.gateway.config_entry, + group.gateway.config, ZHA_OPTIONS, CONF_DEFAULT_LIGHT_TRANSITION, 0, ) self._zha_config_enable_light_transitioning_flag = async_get_zha_config_value( - zha_device.gateway.config_entry, + group.gateway.config, ZHA_OPTIONS, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, True, ) self._zha_config_always_prefer_xy_color_mode = async_get_zha_config_value( - zha_device.gateway.config_entry, + group.gateway.config, ZHA_OPTIONS, CONF_ALWAYS_PREFER_XY_COLOR_MODE, True, ) self._zha_config_group_members_assume_state = async_get_zha_config_value( - zha_device.gateway.config_entry, + group.gateway.config, ZHA_OPTIONS, CONF_GROUP_MEMBERS_ASSUME_STATE, True, @@ -1184,28 +1165,30 @@ def __init__( self._update_group_from_child_delay = ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY self._zha_config_enhanced_light_transition = False - self._attr_color_mode = ColorMode.UNKNOWN - self._attr_supported_color_modes = {ColorMode.ONOFF} + self._color_mode = ColorMode.UNKNOWN + self._supported_color_modes = {ColorMode.ONOFF} + + force_refresh_debouncer = Debouncer( + self.group.gateway, + _LOGGER, + cooldown=3, + immediate=True, + function=self._force_member_updates, + ) + self._debounced_member_refresh = force_refresh_debouncer + self.update() # remove this when all ZHA platforms and base entities are updated @property def available(self) -> bool: """Return entity availability.""" - return self._attr_available - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - if self._debounced_member_refresh is None: - force_refresh_debouncer = Debouncer( - self.hass, - _LOGGER, - cooldown=3, - immediate=True, - function=self._force_member_updates, - ) - self._debounced_member_refresh = force_refresh_debouncer - self.async_on_remove(force_refresh_debouncer.async_cancel) + return self._available + + async def on_remove(self) -> None: + """Cancel tasks this entity owns.""" + await super().on_remove() + if self._debounced_member_refresh: + self._debounced_member_refresh.async_cancel() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" @@ -1215,7 +1198,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: off_brightness = self._off_brightness if self._off_with_transition else None await super().async_turn_on(**kwargs) if self._zha_config_group_members_assume_state: - self._send_member_assume_state_event(True, kwargs, off_brightness) + self._make_members_assume_group_state(True, kwargs, off_brightness) if self.is_transitioning: # when transitioning, state is refreshed at the end return if self._debounced_member_refresh: @@ -1225,82 +1208,78 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await super().async_turn_off(**kwargs) if self._zha_config_group_members_assume_state: - self._send_member_assume_state_event(False, kwargs) + self._make_members_assume_group_state(False, kwargs) if self.is_transitioning: return if self._debounced_member_refresh: await self._debounced_member_refresh.async_call() - async def async_update(self) -> None: + def update(self, _: Any = None) -> None: """Query all members and determine the light group state.""" - self.debug("updating group state") - all_states = [self.hass.states.get(x) for x in self._entity_ids] - states: list[State] = list(filter(None, all_states)) - on_states = [state for state in states if state.state == STATE_ON] + self.debug("Updating light group entity state") + platform_entities = self._group.get_platform_entities(self.PLATFORM) + all_states = [entity.get_state() for entity in platform_entities] + states: list = list(filter(None, all_states)) + self.debug( + "All platform entity states for group entity members: %s", all_states + ) + on_states = [state for state in states if state["on"]] - self._attr_state = len(on_states) > 0 + self._state = len(on_states) > 0 # reset "off with transition" flag if any member is on - if self._attr_state: + if self._state: self._off_with_transition = False self._off_brightness = None - self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) - - self._attr_brightness = helpers.reduce_attribute( - on_states, light.ATTR_BRIGHTNESS + self._available = any( + platform_entity.device.available for platform_entity in platform_entities ) - self._attr_xy_color = helpers.reduce_attribute( - on_states, light.ATTR_XY_COLOR, reduce=helpers.mean_tuple - ) + self._brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) + + self._xy_color = reduce_attribute(on_states, ATTR_XY_COLOR, reduce=mean_tuple) if not self._zha_config_always_prefer_xy_color_mode: - self._attr_hs_color = helpers.reduce_attribute( - on_states, light.ATTR_HS_COLOR, reduce=helpers.mean_tuple + self._hs_color = reduce_attribute( + on_states, ATTR_HS_COLOR, reduce=mean_tuple ) - self._attr_color_temp = helpers.reduce_attribute( - on_states, light.ATTR_COLOR_TEMP + self._color_temp = reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._min_mireds = reduce_attribute( + states, ATTR_MIN_MIREDS, default=153, reduce=min ) - self._attr_min_mireds = helpers.reduce_attribute( - states, light.ATTR_MIN_MIREDS, default=153, reduce=min - ) - self._attr_max_mireds = helpers.reduce_attribute( - states, light.ATTR_MAX_MIREDS, default=500, reduce=max + self._max_mireds = reduce_attribute( + states, ATTR_MAX_MIREDS, default=500, reduce=max ) - self._attr_effect_list = None - all_effect_lists = list( - helpers.find_state_attributes(states, light.ATTR_EFFECT_LIST) - ) + self._effect_list = None + all_effect_lists = list(find_state_attributes(states, ATTR_EFFECT_LIST)) if all_effect_lists: # Merge all effects from all effect_lists with a union merge. - self._attr_effect_list = list(set().union(*all_effect_lists)) + self._effect_list = list(set().union(*all_effect_lists)) - self._attr_effect = None - all_effects = list(helpers.find_state_attributes(on_states, light.ATTR_EFFECT)) + self._effect = None + all_effects = list(find_state_attributes(on_states, ATTR_EFFECT)) if all_effects: # Report the most common effect. effects_count = Counter(itertools.chain(all_effects)) - self._attr_effect = effects_count.most_common(1)[0][0] + self._effect = effects_count.most_common(1)[0][0] supported_color_modes = {ColorMode.ONOFF} all_supported_color_modes: list[set[ColorMode]] = list( - helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) + find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) ) + self._supported_color_modes = set().union(*all_supported_color_modes) + if all_supported_color_modes: # Merge all color modes. supported_color_modes = filter_supported_color_modes( set().union(*all_supported_color_modes) ) - self._attr_supported_color_modes = supported_color_modes - - self._attr_color_mode = ColorMode.UNKNOWN - all_color_modes = list( - helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) - ) + self._color_mode = ColorMode.UNKNOWN + all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) if all_color_modes: # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) @@ -1315,34 +1294,32 @@ async def async_update(self) -> None: else: color_mode_count.pop(ColorMode.BRIGHTNESS) if color_mode_count: - self._attr_color_mode = color_mode_count.most_common(1)[0][0] + self._color_mode = color_mode_count.most_common(1)[0][0] else: - self._attr_color_mode = next(iter(supported_color_modes)) + self._color_mode = next(iter(supported_color_modes)) - if self._attr_color_mode == ColorMode.HS and ( + if self._color_mode == ColorMode.HS and ( color_mode_count[ColorMode.HS] != len(self._group.members) or self._zha_config_always_prefer_xy_color_mode ): # switch to XY if all members do not support HS - self._attr_color_mode = ColorMode.XY + self._color_mode = ColorMode.XY - self._attr_supported_features = LightEntityFeature(0) - for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + self._supported_features = LightEntityFeature(0) + for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature # we find. - self._attr_supported_features |= support + self._supported_features |= support # Bitwise-and the supported features with the GroupedLight's features # so that we don't break in the future when a new feature is added. - self._attr_supported_features &= SUPPORT_GROUP_LIGHT + self._supported_features &= SUPPORT_GROUP_LIGHT + self.maybe_emit_state_changed_event() async def _force_member_updates(self) -> None: - """Force the update of member entities to ensure the states are correct for bulbs that don't report their state.""" - async_dispatcher_send( - self.hass, - SIGNAL_LIGHT_GROUP_STATE_CHANGED, - {"entity_ids": self._entity_ids}, - ) + """Force the update of members to ensure the states are correct for bulbs that don't report their state.""" + for platform_entity in self.group.get_platform_entities(Light.PLATFORM): + await platform_entity.async_update() - def _send_member_assume_state_event( + def _make_members_assume_group_state( self, state, service_kwargs, off_brightness=None ) -> None: """Send an assume event to all members of the group.""" @@ -1353,31 +1330,27 @@ def _send_member_assume_state_event( # check if the parameters were actually updated # in the service call before updating members - if light.ATTR_BRIGHTNESS in service_kwargs: # or off brightness - update_params[light.ATTR_BRIGHTNESS] = self._attr_brightness + if ATTR_BRIGHTNESS in service_kwargs: # or off brightness + update_params[ATTR_BRIGHTNESS] = self._brightness elif off_brightness is not None: # if we turn on the group light with "off brightness", # pass that to the members - update_params[light.ATTR_BRIGHTNESS] = off_brightness + update_params[ATTR_BRIGHTNESS] = off_brightness - if light.ATTR_COLOR_TEMP in service_kwargs: - update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode - update_params[light.ATTR_COLOR_TEMP] = self._attr_color_temp + if ATTR_COLOR_TEMP in service_kwargs: + update_params[ATTR_COLOR_MODE] = self._color_mode + update_params[ATTR_COLOR_TEMP] = self._color_temp - if light.ATTR_XY_COLOR in service_kwargs: - update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode - update_params[light.ATTR_XY_COLOR] = self._attr_xy_color + if ATTR_XY_COLOR in service_kwargs: + update_params[ATTR_COLOR_MODE] = self._color_mode + update_params[ATTR_XY_COLOR] = self._xy_color - if light.ATTR_HS_COLOR in service_kwargs: - update_params[light.ATTR_COLOR_MODE] = self._attr_color_mode - update_params[light.ATTR_HS_COLOR] = self._attr_hs_color + if ATTR_HS_COLOR in service_kwargs: + update_params[ATTR_COLOR_MODE] = self._color_mode + update_params[ATTR_HS_COLOR] = self._hs_color - if light.ATTR_EFFECT in service_kwargs: - update_params[light.ATTR_EFFECT] = self._attr_effect + if ATTR_EFFECT in service_kwargs: + update_params[ATTR_EFFECT] = self._effect - async_dispatcher_send( - self.hass, - SIGNAL_LIGHT_GROUP_ASSUME_GROUP_STATE, - {"entity_ids": self._entity_ids}, - update_params, - ) + for platform_entity in self.group.get_platform_entities(Light.PLATFORM): + platform_entity._assume_group_state(update_params) diff --git a/zha/application/platforms/light/const.py b/zha/application/platforms/light/const.py new file mode 100644 index 00000000..de5dca27 --- /dev/null +++ b/zha/application/platforms/light/const.py @@ -0,0 +1,148 @@ +"""Constants for the Light platform.""" + +from enum import IntFlag, StrEnum +from typing import Final + +from zigpy.zcl.clusters.general import Identify + +DEFAULT_ON_OFF_TRANSITION = 1 # most bulbs default to a 1-second turn on/off transition +DEFAULT_EXTRA_TRANSITION_DELAY_SHORT = 0.25 +DEFAULT_EXTRA_TRANSITION_DELAY_LONG = 2.0 +DEFAULT_LONG_TRANSITION_TIME = 10 +DEFAULT_MIN_BRIGHTNESS = 2 +ASSUME_UPDATE_GROUP_FROM_CHILD_DELAY = 0.05 + +DEFAULT_MIN_TRANSITION_MANUFACTURERS = {"sengled"} + +STATE_UNAVAILABLE: Final = "unavailable" + + +class LightEntityFeature(IntFlag): + """Supported features of the light entity.""" + + EFFECT = 4 + FLASH = 8 + TRANSITION = 32 + + +class ColorMode(StrEnum): + """Possible light color modes.""" + + UNKNOWN = "unknown" + """Ambiguous color mode""" + ONOFF = "onoff" + """Must be the only supported mode""" + BRIGHTNESS = "brightness" + """Must be the only supported mode""" + COLOR_TEMP = "color_temp" + HS = "hs" + XY = "xy" + RGB = "rgb" + RGBW = "rgbw" + RGBWW = "rgbww" + WHITE = "white" + """Must *NOT* be the only supported mode""" + + +COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.XY} +SUPPORT_GROUP_LIGHT = ( + LightEntityFeature.EFFECT | LightEntityFeature.FLASH | LightEntityFeature.TRANSITION +) + +# Float that represents transition time in seconds to make change. +ATTR_TRANSITION: Final[str] = "transition" + +# Lists holding color values +ATTR_RGB_COLOR: Final[str] = "rgb_color" +ATTR_RGBW_COLOR: Final[str] = "rgbw_color" +ATTR_RGBWW_COLOR: Final[str] = "rgbww_color" +ATTR_XY_COLOR: Final[str] = "xy_color" +ATTR_HS_COLOR: Final[str] = "hs_color" +ATTR_COLOR_TEMP: Final[str] = "color_temp" +ATTR_KELVIN: Final[str] = "kelvin" +ATTR_MIN_MIREDS: Final[str] = "min_mireds" +ATTR_MAX_MIREDS: Final[str] = "max_mireds" +ATTR_COLOR_NAME: Final[str] = "color_name" +ATTR_WHITE_VALUE: Final[str] = "white_value" +ATTR_WHITE: Final[str] = "white" + +# Brightness of the light, 0..255 or percentage +ATTR_BRIGHTNESS: Final[str] = "brightness" +ATTR_BRIGHTNESS_PCT: Final[str] = "brightness_pct" +ATTR_BRIGHTNESS_STEP: Final[str] = "brightness_step" +ATTR_BRIGHTNESS_STEP_PCT: Final[str] = "brightness_step_pct" + +ATTR_COLOR_MODE = "color_mode" +ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes" + +# String representing a profile (built-in ones or external defined). +ATTR_PROFILE: Final[str] = "profile" + +# If the light should flash, can be FLASH_SHORT or FLASH_LONG. +ATTR_FLASH: Final[str] = "flash" +FLASH_SHORT: Final[str] = "short" +FLASH_LONG: Final[str] = "long" + +# List of possible effects +ATTR_EFFECT_LIST: Final[str] = "effect_list" + +# Apply an effect to the light, can be EFFECT_COLORLOOP. +ATTR_EFFECT: Final[str] = "effect" +EFFECT_COLORLOOP: Final[str] = "colorloop" +EFFECT_RANDOM: Final[str] = "random" +EFFECT_WHITE: Final[str] = "white" + +ATTR_SUPPORTED_FEATURES: Final[str] = "supported_features" + +# Bitfield of features supported by the light entity +SUPPORT_BRIGHTNESS: Final[int] = 1 # Deprecated, replaced by color modes +SUPPORT_COLOR_TEMP: Final[int] = 2 # Deprecated, replaced by color modes +SUPPORT_EFFECT: Final[int] = 4 +SUPPORT_FLASH: Final[int] = 8 +SUPPORT_COLOR: Final[int] = 16 # Deprecated, replaced by color modes +SUPPORT_TRANSITION: Final[int] = 32 +SUPPORT_WHITE_VALUE: Final[int] = 128 # Deprecated, replaced by color modes + +EFFECT_BLINK: Final[int] = 0x00 +EFFECT_BREATHE: Final[int] = 0x01 +EFFECT_OKAY: Final[int] = 0x02 +EFFECT_DEFAULT_VARIANT: Final[int] = 0x00 + +FLASH_EFFECTS: Final[dict[str, int]] = { + FLASH_SHORT: EFFECT_BLINK, + FLASH_LONG: EFFECT_BREATHE, +} + +SUPPORT_GROUP_LIGHT = ( + SUPPORT_BRIGHTNESS + | SUPPORT_COLOR_TEMP + | SUPPORT_EFFECT + | SUPPORT_FLASH + | SUPPORT_COLOR + | SUPPORT_TRANSITION +) + +FLASH_EFFECTS = { + FLASH_SHORT: Identify.EffectIdentifier.Blink, + FLASH_LONG: Identify.EffectIdentifier.Breathe, +} + +VALID_COLOR_MODES = { + ColorMode.ONOFF, + ColorMode.BRIGHTNESS, + ColorMode.COLOR_TEMP, + ColorMode.HS, + ColorMode.XY, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.WHITE, +} +COLOR_MODES_BRIGHTNESS = VALID_COLOR_MODES - {ColorMode.ONOFF} +COLOR_MODES_COLOR = { + ColorMode.HS, + ColorMode.RGB, + ColorMode.RGBW, + ColorMode.RGBWW, + ColorMode.XY, +} diff --git a/zha/application/platforms/light/helpers.py b/zha/application/platforms/light/helpers.py new file mode 100644 index 00000000..5925c7e3 --- /dev/null +++ b/zha/application/platforms/light/helpers.py @@ -0,0 +1,779 @@ +"""Helpers for the light platform.""" + +from __future__ import annotations + +from collections.abc import Iterable +import colorsys +import math +from typing import NamedTuple, cast + +import attr + +from zha.application.platforms.light.const import ( + COLOR_MODES_BRIGHTNESS, + COLOR_MODES_COLOR, + ColorMode, +) +from zha.exceptions import ZHAException + + +def filter_supported_color_modes(color_modes: Iterable[ColorMode]) -> set[ColorMode]: + """Filter the given color modes.""" + color_modes = set(color_modes) + if ( + not color_modes + or ColorMode.UNKNOWN in color_modes + or (ColorMode.WHITE in color_modes and not color_supported(color_modes)) + ): + raise ZHAException + + if ColorMode.ONOFF in color_modes and len(color_modes) > 1: + color_modes.remove(ColorMode.ONOFF) + if ColorMode.BRIGHTNESS in color_modes and len(color_modes) > 1: + color_modes.remove(ColorMode.BRIGHTNESS) + return color_modes + + +def valid_supported_color_modes( + color_modes: Iterable[ColorMode | str], +) -> set[ColorMode | str]: + """Validate the given color modes.""" + color_modes = set(color_modes) + if ( + not color_modes + or ColorMode.UNKNOWN in color_modes + or (ColorMode.BRIGHTNESS in color_modes and len(color_modes) > 1) + or (ColorMode.ONOFF in color_modes and len(color_modes) > 1) + or (ColorMode.WHITE in color_modes and not color_supported(color_modes)) + ): + raise ZHAException(f"Invalid supported_color_modes {sorted(color_modes)}") + return color_modes + + +def brightness_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: + """Test if brightness is supported.""" + if not color_modes: + return False + return not COLOR_MODES_BRIGHTNESS.isdisjoint(color_modes) + + +def color_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: + """Test if color is supported.""" + if not color_modes: + return False + return not COLOR_MODES_COLOR.isdisjoint(color_modes) + + +def color_temp_supported(color_modes: Iterable[ColorMode | str] | None) -> bool: + """Test if color temperature is supported.""" + if not color_modes: + return False + return ColorMode.COLOR_TEMP in color_modes + + +class RGBColor(NamedTuple): + """RGB hex values.""" + + r: int + g: int + b: int + + +# Official CSS3 colors from w3.org: +# https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 +# names do not have spaces in them so that we can compare against +# requests more easily (by removing spaces from the requests as well). +# This lets "dark seagreen" and "dark sea green" both match the same +# color "darkseagreen". +COLORS = { + "aliceblue": RGBColor(240, 248, 255), + "antiquewhite": RGBColor(250, 235, 215), + "aqua": RGBColor(0, 255, 255), + "aquamarine": RGBColor(127, 255, 212), + "azure": RGBColor(240, 255, 255), + "beige": RGBColor(245, 245, 220), + "bisque": RGBColor(255, 228, 196), + "black": RGBColor(0, 0, 0), + "blanchedalmond": RGBColor(255, 235, 205), + "blue": RGBColor(0, 0, 255), + "blueviolet": RGBColor(138, 43, 226), + "brown": RGBColor(165, 42, 42), + "burlywood": RGBColor(222, 184, 135), + "cadetblue": RGBColor(95, 158, 160), + "chartreuse": RGBColor(127, 255, 0), + "chocolate": RGBColor(210, 105, 30), + "coral": RGBColor(255, 127, 80), + "cornflowerblue": RGBColor(100, 149, 237), + "cornsilk": RGBColor(255, 248, 220), + "crimson": RGBColor(220, 20, 60), + "cyan": RGBColor(0, 255, 255), + "darkblue": RGBColor(0, 0, 139), + "darkcyan": RGBColor(0, 139, 139), + "darkgoldenrod": RGBColor(184, 134, 11), + "darkgray": RGBColor(169, 169, 169), + "darkgreen": RGBColor(0, 100, 0), + "darkgrey": RGBColor(169, 169, 169), + "darkkhaki": RGBColor(189, 183, 107), + "darkmagenta": RGBColor(139, 0, 139), + "darkolivegreen": RGBColor(85, 107, 47), + "darkorange": RGBColor(255, 140, 0), + "darkorchid": RGBColor(153, 50, 204), + "darkred": RGBColor(139, 0, 0), + "darksalmon": RGBColor(233, 150, 122), + "darkseagreen": RGBColor(143, 188, 143), + "darkslateblue": RGBColor(72, 61, 139), + "darkslategray": RGBColor(47, 79, 79), + "darkslategrey": RGBColor(47, 79, 79), + "darkturquoise": RGBColor(0, 206, 209), + "darkviolet": RGBColor(148, 0, 211), + "deeppink": RGBColor(255, 20, 147), + "deepskyblue": RGBColor(0, 191, 255), + "dimgray": RGBColor(105, 105, 105), + "dimgrey": RGBColor(105, 105, 105), + "dodgerblue": RGBColor(30, 144, 255), + "firebrick": RGBColor(178, 34, 34), + "floralwhite": RGBColor(255, 250, 240), + "forestgreen": RGBColor(34, 139, 34), + "fuchsia": RGBColor(255, 0, 255), + "gainsboro": RGBColor(220, 220, 220), + "ghostwhite": RGBColor(248, 248, 255), + "gold": RGBColor(255, 215, 0), + "goldenrod": RGBColor(218, 165, 32), + "gray": RGBColor(128, 128, 128), + "green": RGBColor(0, 128, 0), + "greenyellow": RGBColor(173, 255, 47), + "grey": RGBColor(128, 128, 128), + "honeydew": RGBColor(240, 255, 240), + "hotpink": RGBColor(255, 105, 180), + "indianred": RGBColor(205, 92, 92), + "indigo": RGBColor(75, 0, 130), + "ivory": RGBColor(255, 255, 240), + "khaki": RGBColor(240, 230, 140), + "lavender": RGBColor(230, 230, 250), + "lavenderblush": RGBColor(255, 240, 245), + "lawngreen": RGBColor(124, 252, 0), + "lemonchiffon": RGBColor(255, 250, 205), + "lightblue": RGBColor(173, 216, 230), + "lightcoral": RGBColor(240, 128, 128), + "lightcyan": RGBColor(224, 255, 255), + "lightgoldenrodyellow": RGBColor(250, 250, 210), + "lightgray": RGBColor(211, 211, 211), + "lightgreen": RGBColor(144, 238, 144), + "lightgrey": RGBColor(211, 211, 211), + "lightpink": RGBColor(255, 182, 193), + "lightsalmon": RGBColor(255, 160, 122), + "lightseagreen": RGBColor(32, 178, 170), + "lightskyblue": RGBColor(135, 206, 250), + "lightslategray": RGBColor(119, 136, 153), + "lightslategrey": RGBColor(119, 136, 153), + "lightsteelblue": RGBColor(176, 196, 222), + "lightyellow": RGBColor(255, 255, 224), + "lime": RGBColor(0, 255, 0), + "limegreen": RGBColor(50, 205, 50), + "linen": RGBColor(250, 240, 230), + "magenta": RGBColor(255, 0, 255), + "maroon": RGBColor(128, 0, 0), + "mediumaquamarine": RGBColor(102, 205, 170), + "mediumblue": RGBColor(0, 0, 205), + "mediumorchid": RGBColor(186, 85, 211), + "mediumpurple": RGBColor(147, 112, 219), + "mediumseagreen": RGBColor(60, 179, 113), + "mediumslateblue": RGBColor(123, 104, 238), + "mediumspringgreen": RGBColor(0, 250, 154), + "mediumturquoise": RGBColor(72, 209, 204), + "mediumvioletred": RGBColor(199, 21, 133), + "midnightblue": RGBColor(25, 25, 112), + "mintcream": RGBColor(245, 255, 250), + "mistyrose": RGBColor(255, 228, 225), + "moccasin": RGBColor(255, 228, 181), + "navajowhite": RGBColor(255, 222, 173), + "navy": RGBColor(0, 0, 128), + "navyblue": RGBColor(0, 0, 128), + "oldlace": RGBColor(253, 245, 230), + "olive": RGBColor(128, 128, 0), + "olivedrab": RGBColor(107, 142, 35), + "orange": RGBColor(255, 165, 0), + "orangered": RGBColor(255, 69, 0), + "orchid": RGBColor(218, 112, 214), + "palegoldenrod": RGBColor(238, 232, 170), + "palegreen": RGBColor(152, 251, 152), + "paleturquoise": RGBColor(175, 238, 238), + "palevioletred": RGBColor(219, 112, 147), + "papayawhip": RGBColor(255, 239, 213), + "peachpuff": RGBColor(255, 218, 185), + "peru": RGBColor(205, 133, 63), + "pink": RGBColor(255, 192, 203), + "plum": RGBColor(221, 160, 221), + "powderblue": RGBColor(176, 224, 230), + "purple": RGBColor(128, 0, 128), + "red": RGBColor(255, 0, 0), + "rosybrown": RGBColor(188, 143, 143), + "royalblue": RGBColor(65, 105, 225), + "saddlebrown": RGBColor(139, 69, 19), + "salmon": RGBColor(250, 128, 114), + "sandybrown": RGBColor(244, 164, 96), + "seagreen": RGBColor(46, 139, 87), + "seashell": RGBColor(255, 245, 238), + "sienna": RGBColor(160, 82, 45), + "silver": RGBColor(192, 192, 192), + "skyblue": RGBColor(135, 206, 235), + "slateblue": RGBColor(106, 90, 205), + "slategray": RGBColor(112, 128, 144), + "slategrey": RGBColor(112, 128, 144), + "snow": RGBColor(255, 250, 250), + "springgreen": RGBColor(0, 255, 127), + "steelblue": RGBColor(70, 130, 180), + "tan": RGBColor(210, 180, 140), + "teal": RGBColor(0, 128, 128), + "thistle": RGBColor(216, 191, 216), + "tomato": RGBColor(255, 99, 71), + "turquoise": RGBColor(64, 224, 208), + "violet": RGBColor(238, 130, 238), + "wheat": RGBColor(245, 222, 179), + "white": RGBColor(255, 255, 255), + "whitesmoke": RGBColor(245, 245, 245), + "yellow": RGBColor(255, 255, 0), + "yellowgreen": RGBColor(154, 205, 50), + # And... + "homeassistant": RGBColor(3, 169, 244), +} + + +@attr.s() +class XYPoint: + """Represents a CIE 1931 XY coordinate pair.""" + + x: float = attr.ib() # pylint: disable=invalid-name + y: float = attr.ib() # pylint: disable=invalid-name + + +@attr.s() +class GamutType: + """Represents the Gamut of a light.""" + + # ColorGamut = gamut(xypoint(xR,yR),xypoint(xG,yG),xypoint(xB,yB)) + red: XYPoint = attr.ib() + green: XYPoint = attr.ib() + blue: XYPoint = attr.ib() + + +def color_name_to_rgb(color_name: str) -> RGBColor: + """Convert color name to RGB hex value.""" + # COLORS map has no spaces in it, so make the color_name have no + # spaces in it as well for matching purposes + hex_value = COLORS.get(color_name.replace(" ", "").lower()) + if not hex_value: + raise ValueError("Unknown color") + + return hex_value + + +# pylint: disable=invalid-name + + +def color_RGB_to_xy( + iR: int, iG: int, iB: int, Gamut: GamutType | None = None +) -> tuple[float, float]: + """Convert from RGB color to XY color.""" + return color_RGB_to_xy_brightness(iR, iG, iB, Gamut)[:2] + + +# Taken from: +# https://github.com/PhilipsHue/PhilipsHueSDK-iOS-OSX/blob/00187a3/ApplicationDesignNotes/RGB%20to%20xy%20Color%20conversion.md +# License: Code is given as is. Use at your own risk and discretion. +def color_RGB_to_xy_brightness( + iR: int, iG: int, iB: int, Gamut: GamutType | None = None +) -> tuple[float, float, int]: + """Convert from RGB color to XY color.""" + if iR + iG + iB == 0: + return 0.0, 0.0, 0 + + R = iR / 255 + B = iB / 255 + G = iG / 255 + + # Gamma correction + R = pow((R + 0.055) / (1.0 + 0.055), 2.4) if (R > 0.04045) else (R / 12.92) + G = pow((G + 0.055) / (1.0 + 0.055), 2.4) if (G > 0.04045) else (G / 12.92) + B = pow((B + 0.055) / (1.0 + 0.055), 2.4) if (B > 0.04045) else (B / 12.92) + + # Wide RGB D65 conversion formula + X = R * 0.664511 + G * 0.154324 + B * 0.162028 + Y = R * 0.283881 + G * 0.668433 + B * 0.047685 + Z = R * 0.000088 + G * 0.072310 + B * 0.986039 + + # Convert XYZ to xy + x = X / (X + Y + Z) + y = Y / (X + Y + Z) + + # Brightness + Y = 1 if Y > 1 else Y + brightness = round(Y * 255) + + # Check if the given xy value is within the color-reach of the lamp. + if Gamut: + in_reach = check_point_in_lamps_reach((x, y), Gamut) + if not in_reach: + xy_closest = get_closest_point_to_point((x, y), Gamut) + x = xy_closest[0] + y = xy_closest[1] + + return round(x, 3), round(y, 3), brightness + + +def color_xy_to_RGB( + vX: float, vY: float, Gamut: GamutType | None = None +) -> tuple[int, int, int]: + """Convert from XY to a normalized RGB.""" + return color_xy_brightness_to_RGB(vX, vY, 255, Gamut) + + +# Converted to Python from Obj-C, original source from: +# https://github.com/PhilipsHue/PhilipsHueSDK-iOS-OSX/blob/00187a3/ApplicationDesignNotes/RGB%20to%20xy%20Color%20conversion.md +def color_xy_brightness_to_RGB( + vX: float, vY: float, ibrightness: int, Gamut: GamutType | None = None +) -> tuple[int, int, int]: + """Convert from XYZ to RGB.""" + if Gamut and not check_point_in_lamps_reach((vX, vY), Gamut): + xy_closest = get_closest_point_to_point((vX, vY), Gamut) + vX = xy_closest[0] + vY = xy_closest[1] + + brightness = ibrightness / 255.0 + if brightness == 0.0: + return (0, 0, 0) + + Y = brightness + + if vY == 0.0: + vY += 0.00000000001 + + X = (Y / vY) * vX + Z = (Y / vY) * (1 - vX - vY) + + # Convert to RGB using Wide RGB D65 conversion. + r = X * 1.656492 - Y * 0.354851 - Z * 0.255038 + g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152 + b = X * 0.051713 - Y * 0.121364 + Z * 1.011530 + + # Apply reverse gamma correction. + r, g, b = ((12.92 * x) + if (x <= 0.0031308) + else ((1.0 + 0.055) * cast(float, pow(x, (1.0 / 2.4))) - 0.055) for x in [r, g, b]) + + # Bring all negative components to zero. + r, g, b = (max(0, x) for x in [r, g, b]) + + # If one component is greater than 1, weight components by that value. + max_component = max(r, g, b) + if max_component > 1: + r, g, b = (x / max_component for x in [r, g, b]) + + ir, ig, ib = (int(x * 255) for x in [r, g, b]) + + return (ir, ig, ib) + + +def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> tuple[int, int, int]: + """Convert a hsb into its rgb representation.""" + if fS == 0.0: + fV = int(fB * 255) + return fV, fV, fV + + r = g = b = 0 + h = fH / 60 + f = h - float(math.floor(h)) + p = fB * (1 - fS) + q = fB * (1 - fS * f) + t = fB * (1 - (fS * (1 - f))) + + if int(h) == 0: + r = int(fB * 255) + g = int(t * 255) + b = int(p * 255) + elif int(h) == 1: + r = int(q * 255) + g = int(fB * 255) + b = int(p * 255) + elif int(h) == 2: + r = int(p * 255) + g = int(fB * 255) + b = int(t * 255) + elif int(h) == 3: + r = int(p * 255) + g = int(q * 255) + b = int(fB * 255) + elif int(h) == 4: + r = int(t * 255) + g = int(p * 255) + b = int(fB * 255) + elif int(h) == 5: + r = int(fB * 255) + g = int(p * 255) + b = int(q * 255) + + return (r, g, b) + + +def color_RGB_to_hsv(iR: float, iG: float, iB: float) -> tuple[float, float, float]: + """Convert an rgb color to its hsv representation. + + Hue is scaled 0-360 + Sat is scaled 0-100 + Val is scaled 0-100 + """ + fHSV = colorsys.rgb_to_hsv(iR / 255.0, iG / 255.0, iB / 255.0) + return round(fHSV[0] * 360, 3), round(fHSV[1] * 100, 3), round(fHSV[2] * 100, 3) + + +def color_RGB_to_hs(iR: float, iG: float, iB: float) -> tuple[float, float]: + """Convert an rgb color to its hs representation.""" + return color_RGB_to_hsv(iR, iG, iB)[:2] + + +def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> tuple[int, int, int]: + """Convert an hsv color into its rgb representation. + + Hue is scaled 0-360 + Sat is scaled 0-100 + Val is scaled 0-100 + """ + fRGB = colorsys.hsv_to_rgb(iH / 360, iS / 100, iV / 100) + return (int(fRGB[0] * 255), int(fRGB[1] * 255), int(fRGB[2] * 255)) + + +def color_hs_to_RGB(iH: float, iS: float) -> tuple[int, int, int]: + """Convert an hsv color into its rgb representation.""" + return color_hsv_to_RGB(iH, iS, 100) + + +def color_xy_to_hs( + vX: float, vY: float, Gamut: GamutType | None = None +) -> tuple[float, float]: + """Convert an xy color to its hs representation.""" + h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY, Gamut)) + return h, s + + +def color_hs_to_xy( + iH: float, iS: float, Gamut: GamutType | None = None +) -> tuple[float, float]: + """Convert an hs color to its xy representation.""" + return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut) + + +def match_max_scale( + input_colors: tuple[int, ...], output_colors: tuple[float, ...] +) -> tuple[int, ...]: + """Match the maximum value of the output to the input.""" + max_in = max(input_colors) + max_out = max(output_colors) + if max_out == 0: + factor = 0.0 + else: + factor = max_in / max_out + return tuple(int(round(i * factor)) for i in output_colors) + + +def color_rgb_to_rgbw(r: int, g: int, b: int) -> tuple[int, int, int, int]: + """Convert an rgb color to an rgbw representation.""" + # Calculate the white channel as the minimum of input rgb channels. + # Subtract the white portion from the remaining rgb channels. + w = min(r, g, b) + rgbw = (r - w, g - w, b - w, w) + + # Match the output maximum value to the input. This ensures the full + # channel range is used. + return match_max_scale((r, g, b), rgbw) # type: ignore[return-value] + + +def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> tuple[int, int, int]: + """Convert an rgbw color to an rgb representation.""" + # Add the white channel to the rgb channels. + rgb = (r + w, g + w, b + w) + + # Match the output maximum value to the input. This ensures the + # output doesn't overflow. + return match_max_scale((r, g, b, w), rgb) # type: ignore[return-value] + + +def color_rgb_to_rgbww( + r: int, g: int, b: int, min_mireds: int, max_mireds: int +) -> tuple[int, int, int, int, int]: + """Convert an rgb color to an rgbww representation.""" + # Find the color temperature when both white channels have equal brightness + mired_range = max_mireds - min_mireds + mired_midpoint = min_mireds + mired_range / 2 + color_temp_kelvin = color_temperature_mired_to_kelvin(mired_midpoint) + w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin) + + # Find the ratio of the midpoint white in the input rgb channels + white_level = min( + r / w_r if w_r else 0, g / w_g if w_g else 0, b / w_b if w_b else 0 + ) + + # Subtract the white portion from the rgb channels. + rgb = (r - w_r * white_level, g - w_g * white_level, b - w_b * white_level) + rgbww = (*rgb, round(white_level * 255), round(white_level * 255)) + + # Match the output maximum value to the input. This ensures the full + # channel range is used. + return match_max_scale((r, g, b), rgbww) # type: ignore[return-value] + + +def color_rgbww_to_rgb( + r: int, g: int, b: int, cw: int, ww: int, min_mireds: int, max_mireds: int +) -> tuple[int, int, int]: + """Convert an rgbww color to an rgb representation.""" + # Calculate color temperature of the white channels + mired_range = max_mireds - min_mireds + try: + ct_ratio = ww / (cw + ww) + except ZeroDivisionError: + ct_ratio = 0.5 + color_temp_mired = min_mireds + ct_ratio * mired_range + if color_temp_mired: + color_temp_kelvin = color_temperature_mired_to_kelvin(color_temp_mired) + else: + color_temp_kelvin = 0 + w_r, w_g, w_b = color_temperature_to_rgb(color_temp_kelvin) + white_level = max(cw, ww) / 255 + + # Add the white channels to the rgb channels. + rgb = (r + w_r * white_level, g + w_g * white_level, b + w_b * white_level) + + # Match the output maximum value to the input. This ensures the + # output doesn't overflow. + return match_max_scale((r, g, b, cw, ww), rgb) # type: ignore[return-value] + + +def color_rgb_to_hex(r: int, g: int, b: int) -> str: + """Return a RGB color from a hex color string.""" + return f"{round(r):02x}{round(g):02x}{round(b):02x}" + + +def rgb_hex_to_rgb_list(hex_string: str) -> list[int]: + """Return an RGB color value list from a hex color string.""" + return [ + int(hex_string[i : i + len(hex_string) // 3], 16) + for i in range(0, len(hex_string), len(hex_string) // 3) + ] + + +def color_temperature_to_hs(color_temperature_kelvin: float) -> tuple[float, float]: + """Return an hs color from a color temperature in Kelvin.""" + return color_RGB_to_hs(*color_temperature_to_rgb(color_temperature_kelvin)) + + +def color_temperature_to_rgb( + color_temperature_kelvin: float, +) -> tuple[float, float, float]: + """Return an RGB color from a color temperature in Kelvin. + + This is a rough approximation based on the formula provided by T. Helland + http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code/ + """ + # range check + if color_temperature_kelvin < 1000: + color_temperature_kelvin = 1000 + elif color_temperature_kelvin > 40000: + color_temperature_kelvin = 40000 + + tmp_internal = color_temperature_kelvin / 100.0 + + red = _get_red(tmp_internal) + + green = _get_green(tmp_internal) + + blue = _get_blue(tmp_internal) + + return red, green, blue + + +def color_temperature_to_rgbww( + temperature: int, brightness: int, min_mireds: int, max_mireds: int +) -> tuple[int, int, int, int, int]: + """Convert color temperature in mireds to rgbcw.""" + mired_range = max_mireds - min_mireds + cold = ((max_mireds - temperature) / mired_range) * brightness + warm = brightness - cold + return (0, 0, 0, round(cold), round(warm)) + + +def rgbww_to_color_temperature( + rgbww: tuple[int, int, int, int, int], min_mireds: int, max_mireds: int +) -> tuple[int, int]: + """Convert rgbcw to color temperature in mireds.""" + _, _, _, cold, warm = rgbww + return while_levels_to_color_temperature(cold, warm, min_mireds, max_mireds) + + +def while_levels_to_color_temperature( + cold: int, warm: int, min_mireds: int, max_mireds: int +) -> tuple[int, int]: + """Convert whites to color temperature in mireds.""" + brightness = warm / 255 + cold / 255 + if brightness == 0: + return (max_mireds, 0) + return ( + round(((cold / 255 / brightness) * (min_mireds - max_mireds)) + max_mireds), + min(255, round(brightness * 255)), + ) + + +def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: + """Clamp the given color component value between the given min and max values. + + The range defined by the minimum and maximum values is inclusive, i.e. given a + color_component of 0 and a minimum of 10, the returned value is 10. + """ + color_component_out = max(color_component, minimum) + return min(color_component_out, maximum) + + +def _get_red(temperature: float) -> float: + """Get the red component of the temperature in RGB space.""" + if temperature <= 66: + return 255 + tmp_red = 329.698727446 * math.pow(temperature - 60, -0.1332047592) + return _clamp(tmp_red) + + +def _get_green(temperature: float) -> float: + """Get the green component of the given color temp in RGB space.""" + if temperature <= 66: + green = 99.4708025861 * math.log(temperature) - 161.1195681661 + else: + green = 288.1221695283 * math.pow(temperature - 60, -0.0755148492) + return _clamp(green) + + +def _get_blue(temperature: float) -> float: + """Get the blue component of the given color temperature in RGB space.""" + if temperature >= 66: + return 255 + if temperature <= 19: + return 0 + blue = 138.5177312231 * math.log(temperature - 10) - 305.0447927307 + return _clamp(blue) + + +def color_temperature_mired_to_kelvin(mired_temperature: float) -> int: + """Convert absolute mired shift to degrees kelvin.""" + return math.floor(1000000 / mired_temperature) + + +def color_temperature_kelvin_to_mired(kelvin_temperature: float) -> int: + """Convert degrees kelvin to mired shift.""" + return math.floor(1000000 / kelvin_temperature) + + +# The following 5 functions are adapted from rgbxy provided by Benjamin Knight +# License: The MIT License (MIT), 2014. +# https://github.com/benknight/hue-python-rgb-converter +def cross_product(p1: XYPoint, p2: XYPoint) -> float: + """Calculate the cross product of two XYPoints.""" + return float(p1.x * p2.y - p1.y * p2.x) + + +def get_distance_between_two_points(one: XYPoint, two: XYPoint) -> float: + """Calculate the distance between two XYPoints.""" + dx = one.x - two.x + dy = one.y - two.y + return math.sqrt(dx * dx + dy * dy) + + +def get_closest_point_to_line(A: XYPoint, B: XYPoint, P: XYPoint) -> XYPoint: + """Find the closest point from P to a line defined by A and B. + + This point will be reproducible by the lamp + as it is on the edge of the gamut. + """ + AP = XYPoint(P.x - A.x, P.y - A.y) + AB = XYPoint(B.x - A.x, B.y - A.y) + ab2 = AB.x * AB.x + AB.y * AB.y + ap_ab = AP.x * AB.x + AP.y * AB.y + t = ap_ab / ab2 + + if t < 0.0: + t = 0.0 + elif t > 1.0: + t = 1.0 + + return XYPoint(A.x + AB.x * t, A.y + AB.y * t) + + +def get_closest_point_to_point( + xy_tuple: tuple[float, float], Gamut: GamutType +) -> tuple[float, float]: + """Get the closest matching color within the gamut of the light. + + Should only be used if the supplied color is outside of the color gamut. + """ + xy_point = XYPoint(xy_tuple[0], xy_tuple[1]) + + # find the closest point on each line in the CIE 1931 'triangle'. + pAB = get_closest_point_to_line(Gamut.red, Gamut.green, xy_point) + pAC = get_closest_point_to_line(Gamut.blue, Gamut.red, xy_point) + pBC = get_closest_point_to_line(Gamut.green, Gamut.blue, xy_point) + + # Get the distances per point and see which point is closer to our Point. + dAB = get_distance_between_two_points(xy_point, pAB) + dAC = get_distance_between_two_points(xy_point, pAC) + dBC = get_distance_between_two_points(xy_point, pBC) + + lowest = dAB + closest_point = pAB + + if dAC < lowest: + lowest = dAC + closest_point = pAC + + if dBC < lowest: + lowest = dBC + closest_point = pBC + + # Change the xy value to a value which is within the reach of the lamp. + cx = closest_point.x + cy = closest_point.y + + return (cx, cy) + + +def check_point_in_lamps_reach(p: tuple[float, float], Gamut: GamutType) -> bool: + """Check if the provided XYPoint can be recreated by a Hue lamp.""" + v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y) + v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y) + + q = XYPoint(p[0] - Gamut.red.x, p[1] - Gamut.red.y) + s = cross_product(q, v2) / cross_product(v1, v2) + t = cross_product(v1, q) / cross_product(v1, v2) + + return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0) + + +def check_valid_gamut(Gamut: GamutType) -> bool: + """Check if the supplied gamut is valid.""" + # Check if the three points of the supplied gamut are not on the same line. + v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y) + v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y) + not_on_line = cross_product(v1, v2) > 0.0001 + + # Check if all six coordinates of the gamut lie between 0 and 1. + red_valid = ( + Gamut.red.x >= 0 and Gamut.red.x <= 1 and Gamut.red.y >= 0 and Gamut.red.y <= 1 + ) + green_valid = ( + Gamut.green.x >= 0 + and Gamut.green.x <= 1 + and Gamut.green.y >= 0 + and Gamut.green.y <= 1 + ) + blue_valid = ( + Gamut.blue.x >= 0 + and Gamut.blue.x <= 1 + and Gamut.blue.y >= 0 + and Gamut.blue.y <= 1 + ) + + return not_on_line and red_valid and green_valid and blue_valid diff --git a/zha/application/platforms/lock.py b/zha/application/platforms/lock.py deleted file mode 100644 index 77398a05..00000000 --- a/zha/application/platforms/lock.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Locks on Zigbee Home Automation networks.""" - -import functools -from typing import Any - -from homeassistant.components.lock import STATE_LOCKED, STATE_UNLOCKED, LockEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) -from homeassistant.helpers.typing import StateType -import voluptuous as vol -from zigpy.zcl.foundation import Status - -from .core import discovery -from .core.const import ( - CLUSTER_HANDLER_DOORLOCK, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity - -# The first state is Zigbee 'Not fully locked' -STATE_LIST = [STATE_UNLOCKED, STATE_LOCKED, STATE_UNLOCKED] -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.LOCK) - -VALUE_TO_STATE = dict(enumerate(STATE_LIST)) - -SERVICE_SET_LOCK_USER_CODE = "set_lock_user_code" -SERVICE_ENABLE_LOCK_USER_CODE = "enable_lock_user_code" -SERVICE_DISABLE_LOCK_USER_CODE = "disable_lock_user_code" -SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code" - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation Door Lock from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.LOCK] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) - - platform = async_get_current_platform() - - platform.async_register_entity_service( - SERVICE_SET_LOCK_USER_CODE, - { - vol.Required("code_slot"): vol.Coerce(int), - vol.Required("user_code"): cv.string, - }, - "async_set_lock_user_code", - ) - - platform.async_register_entity_service( - SERVICE_ENABLE_LOCK_USER_CODE, - { - vol.Required("code_slot"): vol.Coerce(int), - }, - "async_enable_lock_user_code", - ) - - platform.async_register_entity_service( - SERVICE_DISABLE_LOCK_USER_CODE, - { - vol.Required("code_slot"): vol.Coerce(int), - }, - "async_disable_lock_user_code", - ) - - platform.async_register_entity_service( - SERVICE_CLEAR_LOCK_USER_CODE, - { - vol.Required("code_slot"): vol.Coerce(int), - }, - "async_clear_lock_user_code", - ) - - -@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DOORLOCK) -class ZhaDoorLock(ZhaEntity, LockEntity): - """Representation of a ZHA lock.""" - - _attr_translation_key: str = "door_lock" - - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): - """Init this sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._doorlock_cluster_handler = self.cluster_handlers.get( - CLUSTER_HANDLER_DOORLOCK - ) - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._doorlock_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) - - @callback - def async_restore_last_state(self, last_state): - """Restore previous state.""" - self._state = VALUE_TO_STATE.get(last_state.state, last_state.state) - - @property - def is_locked(self) -> bool: - """Return true if entity is locked.""" - if self._state is None: - return False - return self._state == STATE_LOCKED - - @property - def extra_state_attributes(self) -> dict[str, StateType]: - """Return state attributes.""" - return self.state_attributes - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the lock.""" - result = await self._doorlock_cluster_handler.lock_door() - if result[0] is not Status.SUCCESS: - self.error("Error with lock_door: %s", result) - return - self.async_write_ha_state() - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the lock.""" - result = await self._doorlock_cluster_handler.unlock_door() - if result[0] is not Status.SUCCESS: - self.error("Error with unlock_door: %s", result) - return - self.async_write_ha_state() - - async def async_update(self) -> None: - """Attempt to retrieve state from the lock.""" - await super().async_update() - await self.async_get_state() - - @callback - def async_set_state(self, attr_id, attr_name, value): - """Handle state update from cluster handler.""" - self._state = VALUE_TO_STATE.get(value, self._state) - self.async_write_ha_state() - - async def async_get_state(self, from_cache=True): - """Attempt to retrieve state from the lock.""" - if self._doorlock_cluster_handler: - state = await self._doorlock_cluster_handler.get_attribute_value( - "lock_state", from_cache=from_cache - ) - if state is not None: - self._state = VALUE_TO_STATE.get(state, self._state) - - async def refresh(self, time): - """Call async_get_state at an interval.""" - await self.async_get_state(from_cache=False) - - async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: - """Set the user_code to index X on the lock.""" - if self._doorlock_cluster_handler: - await self._doorlock_cluster_handler.async_set_user_code( - code_slot, user_code - ) - self.debug("User code at slot %s set", code_slot) - - async def async_enable_lock_user_code(self, code_slot: int) -> None: - """Enable user_code at index X on the lock.""" - if self._doorlock_cluster_handler: - await self._doorlock_cluster_handler.async_enable_user_code(code_slot) - self.debug("User code at slot %s enabled", code_slot) - - async def async_disable_lock_user_code(self, code_slot: int) -> None: - """Disable user_code at index X on the lock.""" - if self._doorlock_cluster_handler: - await self._doorlock_cluster_handler.async_disable_user_code(code_slot) - self.debug("User code at slot %s disabled", code_slot) - - async def async_clear_lock_user_code(self, code_slot: int) -> None: - """Clear the user_code at index X on the lock.""" - if self._doorlock_cluster_handler: - await self._doorlock_cluster_handler.async_clear_user_code(code_slot) - self.debug("User code at slot %s cleared", code_slot) diff --git a/zha/application/platforms/lock/__init__.py b/zha/application/platforms/lock/__init__.py new file mode 100644 index 00000000..ed6c7015 --- /dev/null +++ b/zha/application/platforms/lock/__init__.py @@ -0,0 +1,125 @@ +"""Locks on Zigbee Home Automation networks.""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, Any + +from zigpy.zcl.clusters.closures import DoorLock as DoorLockCluster +from zigpy.zcl.foundation import Status + +from zha.application import Platform +from zha.application.platforms import PlatformEntity +from zha.application.platforms.lock.const import ( + STATE_LOCKED, + STATE_UNLOCKED, + VALUE_TO_STATE, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_DOORLOCK, +) + +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint + +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.LOCK) + + +@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DOORLOCK) +class DoorLock(PlatformEntity): + """Representation of a ZHA lock.""" + + PLATFORM = Platform.LOCK + _attr_translation_key: str = "door_lock" + + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs, + ) -> None: + """Initialize the lock.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._doorlock_cluster_handler: ClusterHandler = self.cluster_handlers.get( + CLUSTER_HANDLER_DOORLOCK + ) + self._state = VALUE_TO_STATE.get( + self._doorlock_cluster_handler.cluster.get("lock_state"), None + ) + self._doorlock_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + + @property + def is_locked(self) -> bool: + """Return true if entity is locked.""" + if self._state is None: + return False + return self._state == STATE_LOCKED + + async def async_lock(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + """Lock the lock.""" + result = await self._doorlock_cluster_handler.lock_door() + if result[0] is not Status.SUCCESS: + self.error("Error with lock_door: %s", result) + return + self._state = STATE_LOCKED + self.maybe_emit_state_changed_event() + + async def async_unlock(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + """Unlock the lock.""" + result = await self._doorlock_cluster_handler.unlock_door() + if result[0] is not Status.SUCCESS: + self.error("Error with unlock_door: %s", result) + return + self._state = STATE_UNLOCKED + self.maybe_emit_state_changed_event() + + async def async_set_lock_user_code(self, code_slot: int, user_code: str) -> None: + """Set the user_code to index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_set_user_code( + code_slot, user_code + ) + self.debug("User code at slot %s set", code_slot) + + async def async_enable_lock_user_code(self, code_slot: int) -> None: + """Enable user_code at index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_enable_user_code(code_slot) + self.debug("User code at slot %s enabled", code_slot) + + async def async_disable_lock_user_code(self, code_slot: int) -> None: + """Disable user_code at index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_disable_user_code(code_slot) + self.debug("User code at slot %s disabled", code_slot) + + async def async_clear_lock_user_code(self, code_slot: int) -> None: + """Clear the user_code at index X on the lock.""" + if self._doorlock_cluster_handler: + await self._doorlock_cluster_handler.async_clear_user_code(code_slot) + self.debug("User code at slot %s cleared", code_slot) + + def handle_cluster_handler_attribute_updated( + self, event: ClusterAttributeUpdatedEvent + ) -> None: + """Handle state update from cluster handler.""" + if event.attribute_id != DoorLockCluster.AttributeDefs.lock_state.id: + return + self._state = VALUE_TO_STATE.get(event.attribute_value, self._state) + self.maybe_emit_state_changed_event() + + def get_state(self) -> dict: + """Get the state of the lock.""" + response = super().get_state() + response["is_locked"] = self.is_locked + return response diff --git a/zha/application/platforms/lock/const.py b/zha/application/platforms/lock/const.py new file mode 100644 index 00000000..905d4274 --- /dev/null +++ b/zha/application/platforms/lock/const.py @@ -0,0 +1,12 @@ +"""Constants for the lock platform.""" + +from typing import Final + +STATE_LOCKED: Final[str] = "locked" +STATE_UNLOCKED: Final[str] = "unlocked" +STATE_LOCKING: Final[str] = "locking" +STATE_UNLOCKING: Final[str] = "unlocking" +STATE_JAMMED: Final[str] = "jammed" +# The first state is Zigbee 'Not fully locked' +STATE_LIST: Final[list[str]] = [STATE_UNLOCKED, STATE_LOCKED, STATE_UNLOCKED] +VALUE_TO_STATE: Final = dict(enumerate(STATE_LIST)) diff --git a/zha/application/platforms/number.py b/zha/application/platforms/number/__init__.py similarity index 61% rename from zha/application/platforms/number.py rename to zha/application/platforms/number/__init__.py index ece90f57..c71d00b4 100644 --- a/zha/application/platforms/number.py +++ b/zha/application/platforms/number/__init__.py @@ -1,4 +1,4 @@ -"""Support for ZHA AnalogOutput cluster.""" +"""Support for ZHA AnalogOutput cluster.""" # pylint: disable=too-many-lines from __future__ import annotations @@ -6,306 +6,70 @@ import logging from typing import TYPE_CHECKING, Any, Self -from homeassistant.components.number import NumberEntity, NumberMode -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import UndefinedType -from zigpy.quirks.v2 import EntityMetadata, NumberMetadata +from zigpy.quirks.v2 import NumberMetadata from zigpy.zcl.clusters.hvac import Thermostat -from .core import discovery -from .core.const import ( +from zha.application import Platform +from zha.application.const import ENTITY_METADATA +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.platforms.helpers import validate_device_class +from zha.application.platforms.number.const import ( + ICONS, + UNITS, + NumberDeviceClass, + NumberMode, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.units import UnitOfMass, UnitOfTemperature, validate_unit +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( CLUSTER_HANDLER_ANALOG_OUTPUT, + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_BASIC, CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_THERMOSTAT, - QUIRK_METADATA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, ) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint _LOGGER = logging.getLogger(__name__) -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.NUMBER) +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.NUMBER) CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.NUMBER + PLATFORM_ENTITIES.config_diagnostic_match, Platform.NUMBER ) -UNITS = { - 0: "Square-meters", - 1: "Square-feet", - 2: "Milliamperes", - 3: "Amperes", - 4: "Ohms", - 5: "Volts", - 6: "Kilo-volts", - 7: "Mega-volts", - 8: "Volt-amperes", - 9: "Kilo-volt-amperes", - 10: "Mega-volt-amperes", - 11: "Volt-amperes-reactive", - 12: "Kilo-volt-amperes-reactive", - 13: "Mega-volt-amperes-reactive", - 14: "Degrees-phase", - 15: "Power-factor", - 16: "Joules", - 17: "Kilojoules", - 18: "Watt-hours", - 19: "Kilowatt-hours", - 20: "BTUs", - 21: "Therms", - 22: "Ton-hours", - 23: "Joules-per-kilogram-dry-air", - 24: "BTUs-per-pound-dry-air", - 25: "Cycles-per-hour", - 26: "Cycles-per-minute", - 27: "Hertz", - 28: "Grams-of-water-per-kilogram-dry-air", - 29: "Percent-relative-humidity", - 30: "Millimeters", - 31: "Meters", - 32: "Inches", - 33: "Feet", - 34: "Watts-per-square-foot", - 35: "Watts-per-square-meter", - 36: "Lumens", - 37: "Luxes", - 38: "Foot-candles", - 39: "Kilograms", - 40: "Pounds-mass", - 41: "Tons", - 42: "Kilograms-per-second", - 43: "Kilograms-per-minute", - 44: "Kilograms-per-hour", - 45: "Pounds-mass-per-minute", - 46: "Pounds-mass-per-hour", - 47: "Watts", - 48: "Kilowatts", - 49: "Megawatts", - 50: "BTUs-per-hour", - 51: "Horsepower", - 52: "Tons-refrigeration", - 53: "Pascals", - 54: "Kilopascals", - 55: "Bars", - 56: "Pounds-force-per-square-inch", - 57: "Centimeters-of-water", - 58: "Inches-of-water", - 59: "Millimeters-of-mercury", - 60: "Centimeters-of-mercury", - 61: "Inches-of-mercury", - 62: "°C", - 63: "°K", - 64: "°F", - 65: "Degree-days-Celsius", - 66: "Degree-days-Fahrenheit", - 67: "Years", - 68: "Months", - 69: "Weeks", - 70: "Days", - 71: "Hours", - 72: "Minutes", - 73: "Seconds", - 74: "Meters-per-second", - 75: "Kilometers-per-hour", - 76: "Feet-per-second", - 77: "Feet-per-minute", - 78: "Miles-per-hour", - 79: "Cubic-feet", - 80: "Cubic-meters", - 81: "Imperial-gallons", - 82: "Liters", - 83: "Us-gallons", - 84: "Cubic-feet-per-minute", - 85: "Cubic-meters-per-second", - 86: "Imperial-gallons-per-minute", - 87: "Liters-per-second", - 88: "Liters-per-minute", - 89: "Us-gallons-per-minute", - 90: "Degrees-angular", - 91: "Degrees-Celsius-per-hour", - 92: "Degrees-Celsius-per-minute", - 93: "Degrees-Fahrenheit-per-hour", - 94: "Degrees-Fahrenheit-per-minute", - 95: None, - 96: "Parts-per-million", - 97: "Parts-per-billion", - 98: "%", - 99: "Percent-per-second", - 100: "Per-minute", - 101: "Per-second", - 102: "Psi-per-Degree-Fahrenheit", - 103: "Radians", - 104: "Revolutions-per-minute", - 105: "Currency1", - 106: "Currency2", - 107: "Currency3", - 108: "Currency4", - 109: "Currency5", - 110: "Currency6", - 111: "Currency7", - 112: "Currency8", - 113: "Currency9", - 114: "Currency10", - 115: "Square-inches", - 116: "Square-centimeters", - 117: "BTUs-per-pound", - 118: "Centimeters", - 119: "Pounds-mass-per-second", - 120: "Delta-Degrees-Fahrenheit", - 121: "Delta-Degrees-Kelvin", - 122: "Kilohms", - 123: "Megohms", - 124: "Millivolts", - 125: "Kilojoules-per-kilogram", - 126: "Megajoules", - 127: "Joules-per-degree-Kelvin", - 128: "Joules-per-kilogram-degree-Kelvin", - 129: "Kilohertz", - 130: "Megahertz", - 131: "Per-hour", - 132: "Milliwatts", - 133: "Hectopascals", - 134: "Millibars", - 135: "Cubic-meters-per-hour", - 136: "Liters-per-hour", - 137: "Kilowatt-hours-per-square-meter", - 138: "Kilowatt-hours-per-square-foot", - 139: "Megajoules-per-square-meter", - 140: "Megajoules-per-square-foot", - 141: "Watts-per-square-meter-Degree-Kelvin", - 142: "Cubic-feet-per-second", - 143: "Percent-obscuration-per-foot", - 144: "Percent-obscuration-per-meter", - 145: "Milliohms", - 146: "Megawatt-hours", - 147: "Kilo-BTUs", - 148: "Mega-BTUs", - 149: "Kilojoules-per-kilogram-dry-air", - 150: "Megajoules-per-kilogram-dry-air", - 151: "Kilojoules-per-degree-Kelvin", - 152: "Megajoules-per-degree-Kelvin", - 153: "Newton", - 154: "Grams-per-second", - 155: "Grams-per-minute", - 156: "Tons-per-hour", - 157: "Kilo-BTUs-per-hour", - 158: "Hundredths-seconds", - 159: "Milliseconds", - 160: "Newton-meters", - 161: "Millimeters-per-second", - 162: "Millimeters-per-minute", - 163: "Meters-per-minute", - 164: "Meters-per-hour", - 165: "Cubic-meters-per-minute", - 166: "Meters-per-second-per-second", - 167: "Amperes-per-meter", - 168: "Amperes-per-square-meter", - 169: "Ampere-square-meters", - 170: "Farads", - 171: "Henrys", - 172: "Ohm-meters", - 173: "Siemens", - 174: "Siemens-per-meter", - 175: "Teslas", - 176: "Volts-per-degree-Kelvin", - 177: "Volts-per-meter", - 178: "Webers", - 179: "Candelas", - 180: "Candelas-per-square-meter", - 181: "Kelvins-per-hour", - 182: "Kelvins-per-minute", - 183: "Joule-seconds", - 185: "Square-meters-per-Newton", - 186: "Kilogram-per-cubic-meter", - 187: "Newton-seconds", - 188: "Newtons-per-meter", - 189: "Watts-per-meter-per-degree-Kelvin", -} - -ICONS = { - 0: "mdi:temperature-celsius", - 1: "mdi:water-percent", - 2: "mdi:gauge", - 3: "mdi:speedometer", - 4: "mdi:percent", - 5: "mdi:air-filter", - 6: "mdi:fan", - 7: "mdi:flash", - 8: "mdi:current-ac", - 9: "mdi:flash", - 10: "mdi:flash", - 11: "mdi:flash", - 12: "mdi:counter", - 13: "mdi:thermometer-lines", - 14: "mdi:timer", - 15: "mdi:palette", - 16: "mdi:brightness-percent", -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation Analog Output from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.NUMBER] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - ), - ) - config_entry.async_on_unload(unsub) - - @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class ZhaNumber(ZhaEntity, NumberEntity): +class Number(PlatformEntity): """Representation of a ZHA Number entity.""" + PLATFORM = Platform.NUMBER _attr_translation_key: str = "number" + _attr_mode: NumberMode = NumberMode.AUTO def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, - ) -> None: - """Init this entity.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._analog_output_cluster_handler = self.cluster_handlers[ + ): + """Initialize the number.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._analog_output_cluster_handler: ClusterHandler = self.cluster_handlers[ CLUSTER_HANDLER_ANALOG_OUTPUT ] - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._analog_output_cluster_handler, - SIGNAL_ATTR_UPDATED, - self.async_set_state, + self._analog_output_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, ) @property @@ -332,13 +96,10 @@ def native_max_value(self) -> float: @property def native_step(self) -> float | None: """Return the value step.""" - resolution = self._analog_output_cluster_handler.resolution - if resolution is not None: - return resolution - return super().native_step + return self._analog_output_cluster_handler.resolution @property - def name(self) -> str | UndefinedType | None: + def name(self) -> str | None: """Return the name of the number entity.""" description = self._analog_output_cluster_handler.description if description is not None and len(description) > 0: @@ -350,8 +111,8 @@ def icon(self) -> str | None: """Return the icon to be used for this entity.""" application_type = self._analog_output_cluster_handler.application_type if application_type is not None: - return ICONS.get(application_type >> 16, super().icon) - return super().icon + return ICONS.get(application_type >> 16, None) + return None @property def native_unit_of_measurement(self) -> str | None: @@ -359,42 +120,68 @@ def native_unit_of_measurement(self) -> str | None: engineering_units = self._analog_output_cluster_handler.engineering_units return UNITS.get(engineering_units) - @callback - def async_set_state(self, attr_id, attr_name, value): - """Handle value update from cluster handler.""" - self.async_write_ha_state() + @property + def mode(self) -> NumberMode: + """Return the mode of the entity.""" + return self._attr_mode async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" await self._analog_output_cluster_handler.async_set_present_value(float(value)) - self.async_write_ha_state() - - async def async_update(self) -> None: - """Attempt to retrieve the state of the entity.""" - await super().async_update() - _LOGGER.debug("polling current state") - if self._analog_output_cluster_handler: - value = await self._analog_output_cluster_handler.get_attribute_value( - "present_value", from_cache=False - ) - _LOGGER.debug("read value=%s", value) - + self.maybe_emit_state_changed_event() -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle value update from cluster handler.""" + self.maybe_emit_state_changed_event() + + async def async_set_value(self, value: Any, **kwargs: Any) -> None: # pylint: disable=unused-argument + """Update the current value from service.""" + num_value = float(value) + if await self._analog_output_cluster_handler.async_set_present_value(num_value): + self.maybe_emit_state_changed_event() + + def to_json(self) -> dict: + """Return the JSON representation of the number entity.""" + json = super().to_json() + json["engineer_units"] = self._analog_output_cluster_handler.engineering_units + json["application_type"] = self._analog_output_cluster_handler.application_type + json["step"] = self.native_step + json["min_value"] = self.native_min_value + json["max_value"] = self.native_max_value + json["name"] = self.name + return json + + def get_state(self) -> dict: + """Return the state of the entity.""" + response = super().get_state() + response["state"] = self.native_value + return response + + +class NumberConfigurationEntity(PlatformEntity): """Representation of a ZHA number configuration entity.""" + PLATFORM = Platform.NUMBER _attr_entity_category = EntityCategory.CONFIG + _attr_native_unit_of_measurement: str | None + _attr_native_min_value: float = 0.0 + _attr_native_max_value: float = 100.0 _attr_native_step: float = 1.0 _attr_multiplier: float = 1 _attribute_name: str + _attr_icon: str | None = None + _attr_mode: NumberMode = NumberMode.AUTO @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -402,7 +189,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if QUIRK_METADATA not in kwargs and ( + if ENTITY_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -414,52 +201,98 @@ def create_entity( ) return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this number configuration entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._attr_device_class: NumberDeviceClass | None = None + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata(self, entity_metadata: NumberMetadata) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - number_metadata: NumberMetadata = entity_metadata.entity_metadata - self._attribute_name = number_metadata.attribute_name - - if number_metadata.min is not None: - self._attr_native_min_value = number_metadata.min - if number_metadata.max is not None: - self._attr_native_max_value = number_metadata.max - if number_metadata.step is not None: - self._attr_native_step = number_metadata.step - if number_metadata.unit is not None: - self._attr_native_unit_of_measurement = number_metadata.unit - if number_metadata.multiplier is not None: - self._attr_multiplier = number_metadata.multiplier + self._attribute_name = entity_metadata.attribute_name + + if entity_metadata.min is not None: + self._attr_native_min_value = entity_metadata.min + if entity_metadata.max is not None: + self._attr_native_max_value = entity_metadata.max + if entity_metadata.step is not None: + self._attr_native_step = entity_metadata.step + if entity_metadata.multiplier is not None: + self._attr_multiplier = entity_metadata.multiplier + if entity_metadata.device_class is not None: + self._attr_device_class = validate_device_class( + NumberDeviceClass, + entity_metadata.device_class, + Platform.NUMBER.value, + _LOGGER, + ) + if entity_metadata.device_class is None and entity_metadata.unit is not None: + self._attr_native_unit_of_measurement = validate_unit( + entity_metadata.unit + ).value + self._cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) @property def native_value(self) -> float: """Return the current value.""" - return ( - self._cluster_handler.cluster.get(self._attribute_name) - * self._attr_multiplier - ) + value = self._cluster_handler.cluster.get(self._attribute_name) + if value is None: + return None + return value * self._attr_multiplier + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + return self._attr_native_min_value + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self._attr_native_max_value + + @property + def native_step(self) -> float | None: + """Return the value step.""" + return self._attr_native_step + + @property + def icon(self) -> str | None: + """Return the icon to be used for this entity.""" + return self._attr_icon + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit the value is expressed in.""" + if hasattr(self, "_attr_native_unit_of_measurement"): + return self._attr_native_unit_of_measurement + return None + + @property + def mode(self) -> NumberMode: + """Return the mode of the entity.""" + return self._attr_mode async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" await self._cluster_handler.write_attributes_safe( {self._attribute_name: int(value / self._attr_multiplier)} ) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" @@ -471,13 +304,37 @@ async def async_update(self) -> None: ) _LOGGER.debug("read value=%s", value) + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle value update from cluster handler.""" + if event.attribute_name == self._attribute_name: + self.maybe_emit_state_changed_event() + + def to_json(self) -> dict: + """Return the JSON representation of the number entity.""" + json = super().to_json() + json["multiplier"] = self._attr_multiplier + json["device_class"] = self._attr_device_class + json["step"] = self._attr_native_step + json["min_value"] = self._attr_native_min_value + json["max_value"] = self._attr_native_max_value + json["name"] = self.name + return json + + def get_state(self) -> dict: + """Return the state of the entity.""" + response = super().get_state() + response["state"] = self.native_value + return response + @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): +class AqaraMotionDetectionInterval(NumberConfigurationEntity): """Representation of a ZHA motion detection interval configuration entity.""" _unique_id_suffix = "detection_interval" @@ -488,8 +345,7 @@ class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): +class OnOffTransitionTimeConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA on off transition time configuration entity.""" _unique_id_suffix = "on_off_transition_time" @@ -500,8 +356,7 @@ class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): +class OnLevelConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA on level configuration entity.""" _unique_id_suffix = "on_level" @@ -512,8 +367,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): +class OnTransitionTimeConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA on transition time configuration entity.""" _unique_id_suffix = "on_transition_time" @@ -524,8 +378,7 @@ class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): +class OffTransitionTimeConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA off transition time configuration entity.""" _unique_id_suffix = "off_transition_time" @@ -536,8 +389,7 @@ class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): +class DefaultMoveRateConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA default move rate configuration entity.""" _unique_id_suffix = "default_move_rate" @@ -548,8 +400,7 @@ class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): +class StartUpCurrentLevelConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA startup current level configuration entity.""" _unique_id_suffix = "start_up_current_level" @@ -560,8 +411,7 @@ class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): +class StartUpColorTemperatureConfigurationEntity(NumberConfigurationEntity): """Representation of a ZHA startup color temperature configuration entity.""" _unique_id_suffix = "start_up_color_temperature" @@ -573,12 +423,13 @@ class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this ZHA startup color temperature entity.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) if self._cluster_handler: self._attr_native_min_value: float = self._cluster_handler.min_mireds self._attr_native_max_value: float = self._cluster_handler.max_mireds @@ -590,8 +441,7 @@ def __init__( "_TZE200_htnnfasr", }, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class TimerDurationMinutes(ZHANumberConfigurationEntity): +class TimerDurationMinutes(NumberConfigurationEntity): """Representation of a ZHA timer duration configuration entity.""" _unique_id_suffix = "timer_duration" @@ -605,8 +455,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class FilterLifeTime(ZHANumberConfigurationEntity): +class FilterLifeTime(NumberConfigurationEntity): """Representation of a ZHA filter lifetime configuration entity.""" _unique_id_suffix = "filter_life_time" @@ -624,8 +473,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity): manufacturers={"TexasInstruments"}, models={"ti.router"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class TiRouterTransmitPower(ZHANumberConfigurationEntity): +class TiRouterTransmitPower(NumberConfigurationEntity): """Representation of a ZHA TI transmit power configuration entity.""" _unique_id_suffix = "transmit_power" @@ -636,8 +484,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): +class InovelliRemoteDimmingUpSpeed(NumberConfigurationEntity): """Inovelli remote dimming up speed configuration entity.""" _unique_id_suffix = "dimming_speed_up_remote" @@ -650,8 +497,7 @@ class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliButtonDelay(ZHANumberConfigurationEntity): +class InovelliButtonDelay(NumberConfigurationEntity): """Inovelli button delay configuration entity.""" _unique_id_suffix = "button_delay" @@ -664,8 +510,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): +class InovelliLocalDimmingUpSpeed(NumberConfigurationEntity): """Inovelli local dimming up speed configuration entity.""" _unique_id_suffix = "dimming_speed_up_local" @@ -678,8 +523,7 @@ class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): +class InovelliLocalRampRateOffToOn(NumberConfigurationEntity): """Inovelli off to on local ramp rate configuration entity.""" _unique_id_suffix = "ramp_rate_off_to_on_local" @@ -692,8 +536,7 @@ class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): +class InovelliRemoteDimmingSpeedOffToOn(NumberConfigurationEntity): """Inovelli off to on remote ramp rate configuration entity.""" _unique_id_suffix = "ramp_rate_off_to_on_remote" @@ -706,8 +549,7 @@ class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): +class InovelliRemoteDimmingDownSpeed(NumberConfigurationEntity): """Inovelli remote dimming down speed configuration entity.""" _unique_id_suffix = "dimming_speed_down_remote" @@ -720,8 +562,7 @@ class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): +class InovelliLocalDimmingDownSpeed(NumberConfigurationEntity): """Inovelli local dimming down speed configuration entity.""" _unique_id_suffix = "dimming_speed_down_local" @@ -734,8 +575,7 @@ class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): +class InovelliLocalRampRateOnToOff(NumberConfigurationEntity): """Inovelli local on to off ramp rate configuration entity.""" _unique_id_suffix = "ramp_rate_on_to_off_local" @@ -748,8 +588,7 @@ class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): +class InovelliRemoteDimmingSpeedOnToOff(NumberConfigurationEntity): """Inovelli remote on to off ramp rate configuration entity.""" _unique_id_suffix = "ramp_rate_on_to_off_remote" @@ -762,8 +601,7 @@ class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): +class InovelliMinimumLoadDimmingLevel(NumberConfigurationEntity): """Inovelli minimum load dimming level configuration entity.""" _unique_id_suffix = "minimum_level" @@ -776,8 +614,7 @@ class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): +class InovelliMaximumLoadDimmingLevel(NumberConfigurationEntity): """Inovelli maximum load dimming level configuration entity.""" _unique_id_suffix = "maximum_level" @@ -790,8 +627,7 @@ class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): +class InovelliAutoShutoffTimer(NumberConfigurationEntity): """Inovelli automatic switch shutoff timer configuration entity.""" _unique_id_suffix = "auto_off_timer" @@ -806,8 +642,7 @@ class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliQuickStartTime(ZHANumberConfigurationEntity): +class InovelliQuickStartTime(NumberConfigurationEntity): """Inovelli fan quick start time configuration entity.""" _unique_id_suffix = "quick_start_time" @@ -820,8 +655,7 @@ class InovelliQuickStartTime(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): +class InovelliLoadLevelIndicatorTimeout(NumberConfigurationEntity): """Inovelli load level indicator timeout configuration entity.""" _unique_id_suffix = "load_level_indicator_timeout" @@ -834,8 +668,7 @@ class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): +class InovelliDefaultAllLEDOnColor(NumberConfigurationEntity): """Inovelli default all led color when on configuration entity.""" _unique_id_suffix = "led_color_when_on" @@ -848,8 +681,7 @@ class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): +class InovelliDefaultAllLEDOffColor(NumberConfigurationEntity): """Inovelli default all led color when off configuration entity.""" _unique_id_suffix = "led_color_when_off" @@ -862,8 +694,7 @@ class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): +class InovelliDefaultAllLEDOnIntensity(NumberConfigurationEntity): """Inovelli default all led intensity when on configuration entity.""" _unique_id_suffix = "led_intensity_when_on" @@ -876,8 +707,7 @@ class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): +class InovelliDefaultAllLEDOffIntensity(NumberConfigurationEntity): """Inovelli default all led intensity when off configuration entity.""" _unique_id_suffix = "led_intensity_when_off" @@ -890,8 +720,7 @@ class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): +class InovelliDoubleTapUpLevel(NumberConfigurationEntity): """Inovelli double tap up level configuration entity.""" _unique_id_suffix = "double_tap_up_level" @@ -904,8 +733,7 @@ class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): +class InovelliDoubleTapDownLevel(NumberConfigurationEntity): """Inovelli double tap down level configuration entity.""" _unique_id_suffix = "double_tap_down_level" @@ -920,8 +748,7 @@ class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): +class AqaraPetFeederServingSize(NumberConfigurationEntity): """Aqara pet feeder serving size configuration entity.""" _unique_id_suffix = "serving_size" @@ -938,8 +765,7 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): +class AqaraPetFeederPortionWeight(NumberConfigurationEntity): """Aqara pet feeder portion weight configuration entity.""" _unique_id_suffix = "portion_weight" @@ -957,8 +783,7 @@ class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): +class AqaraThermostatAwayTemp(NumberConfigurationEntity): """Aqara away preset temperature configuration entity.""" _unique_id_suffix = "away_preset_temperature" @@ -978,8 +803,7 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): +class ThermostatLocalTempCalibration(NumberConfigurationEntity): """Local temperature calibration.""" _unique_id_suffix = "local_temperature_calibration" @@ -1000,7 +824,6 @@ class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): models={"TRVZB"}, stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SonoffThermostatLocalTempCalibration(ThermostatLocalTempCalibration): """Local temperature calibration for the Sonoff TRVZB.""" @@ -1012,8 +835,7 @@ class SonoffThermostatLocalTempCalibration(ThermostatLocalTempCalibration): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity): +class SonoffPresenceSenorTimeout(NumberConfigurationEntity): """Configuration of Sonoff sensor presence detection timeout.""" _unique_id_suffix = "presence_detection_timeout" @@ -1027,8 +849,7 @@ class SonoffPresenceSenorTimeout(ZHANumberConfigurationEntity): _attr_icon: str = "mdi:timer-edit" -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class ZCLTemperatureEntity(ZHANumberConfigurationEntity): +class ZCLTemperatureEntity(NumberConfigurationEntity): """Common entity class for ZCL temperature input.""" _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS @@ -1037,7 +858,6 @@ class ZCLTemperatureEntity(ZHANumberConfigurationEntity): _attr_multiplier: float = 0.01 -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ZCLHeatSetpointLimitEntity(ZCLTemperatureEntity): """Min or max heat setpoint setting on thermostats.""" @@ -1062,7 +882,6 @@ def native_max_value(self) -> float: @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class MaxHeatSetpointLimit(ZCLHeatSetpointLimitEntity): """Max heat setpoint setting on thermostats. @@ -1078,7 +897,6 @@ class MaxHeatSetpointLimit(ZCLHeatSetpointLimitEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class MinHeatSetpointLimit(ZCLHeatSetpointLimitEntity): """Min heat setpoint setting on thermostats. diff --git a/zha/application/platforms/number/const.py b/zha/application/platforms/number/const.py new file mode 100644 index 00000000..6a8eb9f3 --- /dev/null +++ b/zha/application/platforms/number/const.py @@ -0,0 +1,560 @@ +"""Constants for the Number platform.""" + +from enum import StrEnum + +UNITS = { + 0: "Square-meters", + 1: "Square-feet", + 2: "Milliamperes", + 3: "Amperes", + 4: "Ohms", + 5: "Volts", + 6: "Kilo-volts", + 7: "Mega-volts", + 8: "Volt-amperes", + 9: "Kilo-volt-amperes", + 10: "Mega-volt-amperes", + 11: "Volt-amperes-reactive", + 12: "Kilo-volt-amperes-reactive", + 13: "Mega-volt-amperes-reactive", + 14: "Degrees-phase", + 15: "Power-factor", + 16: "Joules", + 17: "Kilojoules", + 18: "Watt-hours", + 19: "Kilowatt-hours", + 20: "BTUs", + 21: "Therms", + 22: "Ton-hours", + 23: "Joules-per-kilogram-dry-air", + 24: "BTUs-per-pound-dry-air", + 25: "Cycles-per-hour", + 26: "Cycles-per-minute", + 27: "Hertz", + 28: "Grams-of-water-per-kilogram-dry-air", + 29: "Percent-relative-humidity", + 30: "Millimeters", + 31: "Meters", + 32: "Inches", + 33: "Feet", + 34: "Watts-per-square-foot", + 35: "Watts-per-square-meter", + 36: "Lumens", + 37: "Luxes", + 38: "Foot-candles", + 39: "Kilograms", + 40: "Pounds-mass", + 41: "Tons", + 42: "Kilograms-per-second", + 43: "Kilograms-per-minute", + 44: "Kilograms-per-hour", + 45: "Pounds-mass-per-minute", + 46: "Pounds-mass-per-hour", + 47: "Watts", + 48: "Kilowatts", + 49: "Megawatts", + 50: "BTUs-per-hour", + 51: "Horsepower", + 52: "Tons-refrigeration", + 53: "Pascals", + 54: "Kilopascals", + 55: "Bars", + 56: "Pounds-force-per-square-inch", + 57: "Centimeters-of-water", + 58: "Inches-of-water", + 59: "Millimeters-of-mercury", + 60: "Centimeters-of-mercury", + 61: "Inches-of-mercury", + 62: "°C", + 63: "°K", + 64: "°F", + 65: "Degree-days-Celsius", + 66: "Degree-days-Fahrenheit", + 67: "Years", + 68: "Months", + 69: "Weeks", + 70: "Days", + 71: "Hours", + 72: "Minutes", + 73: "Seconds", + 74: "Meters-per-second", + 75: "Kilometers-per-hour", + 76: "Feet-per-second", + 77: "Feet-per-minute", + 78: "Miles-per-hour", + 79: "Cubic-feet", + 80: "Cubic-meters", + 81: "Imperial-gallons", + 82: "Liters", + 83: "Us-gallons", + 84: "Cubic-feet-per-minute", + 85: "Cubic-meters-per-second", + 86: "Imperial-gallons-per-minute", + 87: "Liters-per-second", + 88: "Liters-per-minute", + 89: "Us-gallons-per-minute", + 90: "Degrees-angular", + 91: "Degrees-Celsius-per-hour", + 92: "Degrees-Celsius-per-minute", + 93: "Degrees-Fahrenheit-per-hour", + 94: "Degrees-Fahrenheit-per-minute", + 95: None, + 96: "Parts-per-million", + 97: "Parts-per-billion", + 98: "%", + 99: "Percent-per-second", + 100: "Per-minute", + 101: "Per-second", + 102: "Psi-per-Degree-Fahrenheit", + 103: "Radians", + 104: "Revolutions-per-minute", + 105: "Currency1", + 106: "Currency2", + 107: "Currency3", + 108: "Currency4", + 109: "Currency5", + 110: "Currency6", + 111: "Currency7", + 112: "Currency8", + 113: "Currency9", + 114: "Currency10", + 115: "Square-inches", + 116: "Square-centimeters", + 117: "BTUs-per-pound", + 118: "Centimeters", + 119: "Pounds-mass-per-second", + 120: "Delta-Degrees-Fahrenheit", + 121: "Delta-Degrees-Kelvin", + 122: "Kilohms", + 123: "Megohms", + 124: "Millivolts", + 125: "Kilojoules-per-kilogram", + 126: "Megajoules", + 127: "Joules-per-degree-Kelvin", + 128: "Joules-per-kilogram-degree-Kelvin", + 129: "Kilohertz", + 130: "Megahertz", + 131: "Per-hour", + 132: "Milliwatts", + 133: "Hectopascals", + 134: "Millibars", + 135: "Cubic-meters-per-hour", + 136: "Liters-per-hour", + 137: "Kilowatt-hours-per-square-meter", + 138: "Kilowatt-hours-per-square-foot", + 139: "Megajoules-per-square-meter", + 140: "Megajoules-per-square-foot", + 141: "Watts-per-square-meter-Degree-Kelvin", + 142: "Cubic-feet-per-second", + 143: "Percent-obscuration-per-foot", + 144: "Percent-obscuration-per-meter", + 145: "Milliohms", + 146: "Megawatt-hours", + 147: "Kilo-BTUs", + 148: "Mega-BTUs", + 149: "Kilojoules-per-kilogram-dry-air", + 150: "Megajoules-per-kilogram-dry-air", + 151: "Kilojoules-per-degree-Kelvin", + 152: "Megajoules-per-degree-Kelvin", + 153: "Newton", + 154: "Grams-per-second", + 155: "Grams-per-minute", + 156: "Tons-per-hour", + 157: "Kilo-BTUs-per-hour", + 158: "Hundredths-seconds", + 159: "Milliseconds", + 160: "Newton-meters", + 161: "Millimeters-per-second", + 162: "Millimeters-per-minute", + 163: "Meters-per-minute", + 164: "Meters-per-hour", + 165: "Cubic-meters-per-minute", + 166: "Meters-per-second-per-second", + 167: "Amperes-per-meter", + 168: "Amperes-per-square-meter", + 169: "Ampere-square-meters", + 170: "Farads", + 171: "Henrys", + 172: "Ohm-meters", + 173: "Siemens", + 174: "Siemens-per-meter", + 175: "Teslas", + 176: "Volts-per-degree-Kelvin", + 177: "Volts-per-meter", + 178: "Webers", + 179: "Candelas", + 180: "Candelas-per-square-meter", + 181: "Kelvins-per-hour", + 182: "Kelvins-per-minute", + 183: "Joule-seconds", + 185: "Square-meters-per-Newton", + 186: "Kilogram-per-cubic-meter", + 187: "Newton-seconds", + 188: "Newtons-per-meter", + 189: "Watts-per-meter-per-degree-Kelvin", +} + +ICONS = { + 0: "mdi:temperature-celsius", + 1: "mdi:water-percent", + 2: "mdi:gauge", + 3: "mdi:speedometer", + 4: "mdi:percent", + 5: "mdi:air-filter", + 6: "mdi:fan", + 7: "mdi:flash", + 8: "mdi:current-ac", + 9: "mdi:flash", + 10: "mdi:flash", + 11: "mdi:flash", + 12: "mdi:counter", + 13: "mdi:thermometer-lines", + 14: "mdi:timer", + 15: "mdi:palette", + 16: "mdi:brightness-percent", +} + + +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 + + APPARENT_POWER = "apparent_power" + """Apparent power. + + Unit of measurement: `VA` + """ + + AQI = "aqi" + """Air Quality Index. + + Unit of measurement: `None` + """ + + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" + """Atmospheric pressure. + + Unit of measurement: `UnitOfPressure` units + """ + + BATTERY = "battery" + """Percentage of battery that is left. + + Unit of measurement: `%` + """ + + CO = "carbon_monoxide" + """Carbon Monoxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ + + CO2 = "carbon_dioxide" + """Carbon Dioxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ + + 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` + """ + + ENERGY = "energy" + """Energy. + + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + 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: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + FREQUENCY = "frequency" + """Frequency. + + Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + """ + + GAS = "gas" + """Gas. + + Unit of measurement: + - SI / metric: `m³` + - USCS / imperial: `ft³`, `CCF` + """ + + 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: `µg/m³` + """ + + NITROGEN_MONOXIDE = "nitrogen_monoxide" + """Amount of NO. + + Unit of measurement: `µg/m³` + """ + + NITROUS_OXIDE = "nitrous_oxide" + """Amount of N2O. + + Unit of measurement: `µg/m³` + """ + + OZONE = "ozone" + """Amount of O3. + + Unit of measurement: `µ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³` + """ + + POWER_FACTOR = "power_factor" + """Power factor. + + Unit of measurement: `%`, `None` + """ + + POWER = "power" + """Power. + + Unit of measurement: `W`, `kW` + """ + + 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` + - `Pa`, `hPa`, `kPa` + - `inHg` + - `psi` + """ + + REACTIVE_POWER = "reactive_power" + """Reactive power. + + Unit of measurement: `var` + """ + + 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: `µg/m³` + """ + + TEMPERATURE = "temperature" + """Temperature. + + Unit of measurement: `°C`, `°F`, `K` + """ + + VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" + """Amount of VOC. + + Unit of measurement: `µg/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` + """ + + VOLUME = "volume" + """Generic volume. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `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`, `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`, `L/min` + - USCS / imperial: `ft³/min`, `gal/min` + """ + + WATER = "water" + """Water. + + Unit of measurement: + - SI / metric: `m³`, `L` + - USCS / imperial: `ft³`, `CCF`, `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_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/select.py b/zha/application/platforms/select.py index 264617e1..7ccc2c3a 100644 --- a/zha/application/platforms/select.py +++ b/zha/application/platforms/select.py @@ -7,71 +7,44 @@ import logging from typing import TYPE_CHECKING, Any, Self -from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, EntityCategory, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zigpy import types -from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata +from zigpy.quirks.v2 import ZCLEnumMetadata from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd -from .core import discovery -from .core.const import ( +from zha.application import Platform +from zha.application.const import ENTITY_METADATA, Strobe +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_HUE_OCCUPANCY, CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, - QUIRK_METADATA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, - Strobe, ) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.SELECT + PLATFORM_ENTITIES.config_diagnostic_match, Platform.SELECT ) _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation siren from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.SELECT] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - ), - ) - config_entry.async_on_unload(unsub) - - -class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): +class EnumSelectEntity(PlatformEntity): """Representation of a ZHA select entity.""" + PLATFORM = Platform.SELECT _attr_entity_category = EntityCategory.CONFIG _attribute_name: str _enum: type[Enum] @@ -79,15 +52,16 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this select entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] self._attribute_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) @property def current_option(self) -> str | None: @@ -102,18 +76,23 @@ async def async_select_option(self, option: str) -> None: self._cluster_handler.data_cache[self._attribute_name] = self._enum[ option.replace(" ", "_") ] - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - @callback - def async_restore_last_state(self, last_state) -> None: - """Restore previous state.""" - if last_state.state and last_state.state != STATE_UNKNOWN: - self._cluster_handler.data_cache[self._attribute_name] = self._enum[ - last_state.state.replace(" ", "_") - ] + def to_json(self) -> dict: + """Return a JSON representation of the select.""" + json = super().to_json() + json["enum"] = self._enum.__name__ + json["options"] = self._attr_options + return json + def get_state(self) -> dict: + """Return the state of the select.""" + response = super().get_state() + response["state"] = self.current_option + return response -class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): + +class NonZCLSelectEntity(EnumSelectEntity): """Representation of a ZHA select entity with no ZCL interaction.""" @property @@ -123,7 +102,7 @@ def available(self) -> bool: @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultToneSelectEntity(ZHANonZCLSelectEntity): +class DefaultToneSelectEntity(NonZCLSelectEntity): """Representation of a ZHA default siren tone select entity.""" _unique_id_suffix = IasWd.Warning.WarningMode.__name__ @@ -132,7 +111,7 @@ class ZHADefaultToneSelectEntity(ZHANonZCLSelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultSirenLevelSelectEntity(ZHANonZCLSelectEntity): +class DefaultSirenLevelSelectEntity(NonZCLSelectEntity): """Representation of a ZHA default siren level select entity.""" _unique_id_suffix = IasWd.Warning.SirenLevel.__name__ @@ -141,7 +120,7 @@ class ZHADefaultSirenLevelSelectEntity(ZHANonZCLSelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultStrobeLevelSelectEntity(ZHANonZCLSelectEntity): +class DefaultStrobeLevelSelectEntity(NonZCLSelectEntity): """Representation of a ZHA default siren strobe level select entity.""" _unique_id_suffix = IasWd.StrobeLevel.__name__ @@ -150,7 +129,7 @@ class ZHADefaultStrobeLevelSelectEntity(ZHANonZCLSelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): +class DefaultStrobeSelectEntity(NonZCLSelectEntity): """Representation of a ZHA default siren strobe select entity.""" _unique_id_suffix = Strobe.__name__ @@ -158,19 +137,21 @@ class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): _attr_translation_key: str = "default_strobe" -class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): +class ZCLEnumSelectEntity(PlatformEntity): """Representation of a ZHA ZCL enum select entity.""" + PLATFORM = Platform.SELECT _attribute_name: str _attr_entity_category = EntityCategory.CONFIG _enum: type[Enum] @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -178,7 +159,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if QUIRK_METADATA not in kwargs and ( + if ENTITY_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -190,28 +171,32 @@ def create_entity( ) return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this select entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata(self, entity_metadata: ZCLEnumMetadata) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - zcl_enum_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata - self._attribute_name = zcl_enum_metadata.attribute_name - self._enum = zcl_enum_metadata.enum + self._attribute_name = entity_metadata.attribute_name + self._enum = entity_metadata.enum @property def current_option(self) -> str | None: @@ -227,23 +212,32 @@ async def async_select_option(self, option: str) -> None: await self._cluster_handler.write_attributes_safe( {self._attribute_name: self._enum[option.replace(" ", "_")]} ) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle value update from cluster handler.""" + if event.attribute_name == self._attribute_name: + self.maybe_emit_state_changed_event() + + def to_json(self) -> dict: + """Return a JSON representation of the select.""" + json = super().to_json() + json["enum"] = self._enum.__name__ + json["options"] = self._attr_options + return json - @callback - def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from cluster handler.""" - self.async_write_ha_state() + def get_state(self) -> dict: + """Return the state of the select.""" + response = super().get_state() + response["state"] = self.current_option + return response @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) -class ZHAStartupOnOffSelectEntity(ZCLEnumSelectEntity): +class StartupOnOffSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA startup onoff select entity.""" _unique_id_suffix = OnOff.StartUpOnOff.__name__ diff --git a/zha/application/platforms/sensor.py b/zha/application/platforms/sensor/__init__.py similarity index 77% rename from zha/application/platforms/sensor.py rename to zha/application/platforms/sensor/__init__.py index d4962fb9..79c0ad3e 100644 --- a/zha/application/platforms/sensor.py +++ b/zha/application/platforms/sensor/__init__.py @@ -1,34 +1,36 @@ -"""Sensors on Zigbee Home Automation networks.""" +"""Sensors on Zigbee Home Automation networks.""" # pylint: disable=too-many-lines from __future__ import annotations -import asyncio +from collections.abc import Callable from dataclasses import dataclass -from datetime import timedelta import enum import functools import logging import numbers -import random from typing import TYPE_CHECKING, Any, Self -from homeassistant.components.climate import HVACAction -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from zigpy import types +from zigpy.quirks.v2 import ZCLEnumMetadata, ZCLSensorMetadata +from zigpy.state import Counter, State +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import Basic + +from zha.application import Platform +from zha.application.const import ENTITY_METADATA +from zha.application.platforms import BaseEntity, EntityCategory, PlatformEntity +from zha.application.platforms.climate.const import HVACAction +from zha.application.platforms.helpers import validate_device_class +from zha.application.platforms.sensor.const import SensorDeviceClass, SensorStateClass +from zha.application.registries import PLATFORM_ENTITIES +from zha.decorators import periodic +from zha.units import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - EntityCategory, - Platform, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -41,21 +43,12 @@ UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, + validate_unit, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import StateType -from zigpy import types -from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata, ZCLSensorMetadata -from zigpy.state import Counter, State -from zigpy.zcl.clusters.closures import WindowCovering -from zigpy.zcl.clusters.general import Basic - -from .core import discovery -from .core.const import ( +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( CLUSTER_HANDLER_ANALOG_INPUT, + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_BASIC, CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_DEVICE_TEMPERATURE, @@ -69,18 +62,13 @@ CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, - QUIRK_METADATA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, + SMARTTHINGS_HUMIDITY_CLUSTER, ) -from .core.helpers import get_zha_data -from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES -from .entity import BaseZhaEntity, ZhaEntity if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint BATTERY_SIZES = { 0: "No battery", @@ -103,49 +91,32 @@ CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" ) -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SENSOR) -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SENSOR) +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.SENSOR) +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.SENSOR) CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.SENSOR + PLATFORM_ENTITIES.config_diagnostic_match, Platform.SENSOR ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation sensor from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.SENSOR] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - ), - ) - config_entry.async_on_unload(unsub) - - -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class Sensor(ZhaEntity, SensorEntity): +class Sensor(PlatformEntity): """Base ZHA sensor.""" + PLATFORM = Platform.SENSOR _attribute_name: int | str | None = None _decimals: int = 1 _divisor: int = 1 _multiplier: int | float = 1 + _attr_native_unit_of_measurement: str | None = None + _attr_device_class: SensorDeviceClass | None = None + _attr_state_class: SensorStateClass | None = None @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -153,7 +124,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if QUIRK_METADATA not in kwargs and ( + if ENTITY_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name ): @@ -164,42 +135,48 @@ def create_entity( ) return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this sensor.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata(self, entity_metadata: ZCLSensorMetadata) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - sensor_metadata: ZCLSensorMetadata = entity_metadata.entity_metadata - self._attribute_name = sensor_metadata.attribute_name - if sensor_metadata.divisor is not None: - self._divisor = sensor_metadata.divisor - if sensor_metadata.multiplier is not None: - self._multiplier = sensor_metadata.multiplier - if sensor_metadata.unit is not None: - self._attr_native_unit_of_measurement = sensor_metadata.unit - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) + self._attribute_name = entity_metadata.attribute_name + if entity_metadata.divisor is not None: + self._divisor = entity_metadata.divisor + if entity_metadata.multiplier is not None: + self._multiplier = entity_metadata.multiplier + if entity_metadata.device_class is not None: + self._attr_device_class = validate_device_class( + SensorDeviceClass, + entity_metadata.device_class, + Platform.SENSOR.value, + _LOGGER, + ) + if entity_metadata.device_class is None and entity_metadata.unit is not None: + self._attr_native_unit_of_measurement = validate_unit( + entity_metadata.unit + ).value @property - def native_value(self) -> StateType: + def native_value(self) -> str | int | float | None: """Return the state of the entity.""" assert self._attribute_name is not None raw_state = self._cluster_handler.cluster.get(self._attribute_name) @@ -207,10 +184,32 @@ def native_value(self) -> StateType: return None return self.formatter(raw_state) - @callback - def async_set_state(self, attr_id: int, attr_name: str, value: Any) -> None: - """Handle state update from cluster handler.""" - self.async_write_ha_state() + def get_state(self) -> dict: + """Return the state for this sensor.""" + response = super().get_state() + native_value = self.native_value + response["state"] = native_value + return response + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle attribute updates from the cluster handler.""" + if event.attribute_name == self._attribute_name: + self.maybe_emit_state_changed_event() + + def to_json(self) -> dict: + """Return a JSON representation of the sensor.""" + json = super().to_json() + json["attribute"] = self._attribute_name + json["decimals"] = self._decimals + json["divisor"] = self._divisor + json["multiplier"] = self._multiplier + json["unit"] = self._attr_native_unit_of_measurement + json["device_class"] = self._attr_device_class + json["state_class"] = self._attr_state_class + return json def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: """Numeric pass-through formatter.""" @@ -221,68 +220,74 @@ def formatter(self, value: int | enum.IntEnum) -> int | float | str | None: return round(float(value * self._multiplier) / self._divisor) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class PollableSensor(Sensor): """Base ZHA sensor that polls for state.""" + _REFRESH_INTERVAL = (30, 45) _use_custom_polling: bool = True + __polling_interval: int def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cancel_refresh_handle: CALLBACK_TYPE | None = None - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - if self._use_custom_polling: - refresh_interval = random.randint(30, 60) - self._cancel_refresh_handle = async_track_time_interval( - self.hass, self._refresh, timedelta(seconds=refresh_interval) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._cancel_refresh_handle: Callable | None = None + if self.should_poll: + self._tracked_tasks.append( + device.gateway.async_create_background_task( + self._refresh(), + name=f"sensor_state_poller_{self.unique_id}_{self.__class__.__name__}", + eager_start=True, + untracked=True, + ) + ) + self.debug( + "started polling with refresh interval of %s", + getattr(self, "__polling_interval"), ) - self.debug("started polling with refresh interval of %s", refresh_interval) - async def async_will_remove_from_hass(self) -> None: - """Disconnect entity object when removed.""" - if self._cancel_refresh_handle is not None: - self._cancel_refresh_handle() - self._cancel_refresh_handle = None - self.debug("stopped polling during device removal") - await super().async_will_remove_from_hass() + @property + def should_poll(self) -> bool: + """Return True if we need to poll for state changes.""" + return self._use_custom_polling - async def _refresh(self, time): + @periodic(_REFRESH_INTERVAL) + async def _refresh(self): """Call async_update at a constrained random interval.""" - if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + if self.device.available and self.device.gateway.config.allow_polling: self.debug("polling for updated state") await self.async_update() - self.async_write_ha_state() + self.maybe_emit_state_changed_event() else: self.debug( "skipping polling for updated state, available: %s, allow polled requests: %s", - self._zha_device.available, - self.hass.data[DATA_ZHA].allow_polling, + self.device.available, + self.device.gateway.config.allow_polling, ) -class DeviceCounterSensor(BaseZhaEntity, SensorEntity): +class DeviceCounterSensor(BaseEntity): """Device counter sensor.""" - _attr_should_poll = True + PLATFORM = Platform.SENSOR + _REFRESH_INTERVAL = (30, 45) + __polling_interval: int + _use_custom_polling: bool = True _attr_state_class: SensorStateClass = SensorStateClass.TOTAL _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False @classmethod - def create_entity( + def create_platform_entity( cls, unique_id: str, - zha_device: ZHADevice, + zha_device: Device, counter_groups: str, counter_group: str, counter: str, @@ -299,67 +304,126 @@ def create_entity( def __init__( self, unique_id: str, - zha_device: ZHADevice, + zha_device: Device, counter_groups: str, counter_group: str, counter: str, **kwargs: Any, ) -> None: """Init this sensor.""" - super().__init__(unique_id, zha_device, **kwargs) - state: State = self._zha_device.gateway.application_controller.state + super().__init__(unique_id, **kwargs) + self._name = f"{zha_device.name} {counter_group} {counter}" + self._device: Device = zha_device + state: State = self._device.gateway.application_controller.state self._zigpy_counter: Counter = ( getattr(state, counter_groups).get(counter_group, {}).get(counter, None) ) + self._zigpy_counter_groups: str = counter_groups + self._zigpy_counter_group: str = counter_group self._attr_name: str = self._zigpy_counter.name - self.remove_future: asyncio.Future - - @property - def available(self) -> bool: - """Return entity availability.""" - return self._zha_device.available - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - self.remove_future = self.hass.loop.create_future() - self._zha_device.gateway.register_entity_reference( - self._zha_device.ieee, - self.entity_id, - self._zha_device, - {}, - self.device_info, - self.remove_future, + self._tracked_tasks.append( + self._device.gateway.async_create_background_task( + self._refresh(), + name=f"sensor_state_poller_{self.unique_id}_{self.__class__.__name__}", + eager_start=True, + untracked=True, + ) + ) + self.debug( + "started polling with refresh interval of %s", + getattr(self, "__polling_interval"), ) + # we double create these in discovery tests because we reissue the create calls to count and prove them out + if self.unique_id not in self._device.platform_entities: + self._device.platform_entities[self.unique_id] = self - async def async_will_remove_from_hass(self) -> None: - """Disconnect entity object when removed.""" - await super().async_will_remove_from_hass() - self.zha_device.gateway.remove_entity_reference(self) - self.remove_future.set_result(True) + @property + def name(self) -> str: + """Return the name of the platform entity.""" + return self._name @property - def native_value(self) -> StateType: + def native_value(self) -> int | None: """Return the state of the entity.""" return self._zigpy_counter.value + @property + def available(self) -> bool: + """Return entity availability.""" + return self._device.available + + @property + def device(self) -> Device: + """Return the device.""" + return self._device + async def async_update(self) -> None: """Retrieve latest state.""" - self.async_write_ha_state() + self.maybe_emit_state_changed_event() + + @periodic(_REFRESH_INTERVAL) + async def _refresh(self): + """Call async_update at a constrained random interval.""" + if self._device.available and self._device.gateway.config.allow_polling: + self.debug("polling for updated state") + await self.async_update() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._device.available, + self._device.gateway.config.allow_polling, + ) + + def get_identifiers(self) -> dict[str, str | int]: + """Return a dict with the information necessary to identify this entity.""" + return { + "unique_id": self.unique_id, + "platform": self.PLATFORM, + "device_ieee": self._device.ieee, + } + + def get_state(self) -> dict[str, Any]: + """Return the state for this sensor.""" + response = super().get_state() + response["state"] = self._zigpy_counter.value + return response + + def to_json(self) -> dict: + """Return a JSON representation of the platform entity.""" + json = super().to_json() + json["name"] = self._attr_name + json["device_ieee"] = str(self._device.ieee) + json["available"] = self.available + json["counter"] = self._zigpy_counter.name + json["counter_value"] = self._zigpy_counter.value + json["counter_groups"] = self._zigpy_counter_groups + json["counter_group"] = self._zigpy_counter_group + return json -# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnumSensor(Sensor): """Sensor with value from enum.""" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM _enum: type[enum.Enum] - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def __init__( + self, + unique_id: str, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._attr_options = [e.name for e in self._enum] + + def _init_from_quirks_metadata(self, entity_metadata: ZCLEnumMetadata) -> None: """Init this entity from the quirks metadata.""" - ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access - sensor_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata - self._attribute_name = sensor_metadata.attribute_name - self._enum = sensor_metadata.enum + PlatformEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access + self._attribute_name = entity_metadata.attribute_name + self._enum = entity_metadata.enum def formatter(self, value: int) -> str | None: """Use name of enum.""" @@ -372,7 +436,6 @@ def formatter(self, value: int) -> str | None: manufacturers="Digi", stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AnalogInput(Sensor): """Sensor that displays analog input values.""" @@ -381,7 +444,6 @@ class AnalogInput(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -392,11 +454,12 @@ class Battery(Sensor): _attr_native_unit_of_measurement = PERCENTAGE @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -405,12 +468,12 @@ def create_entity( battery_percent_remaining attribute, but zha-device-handlers takes care of it so create the entity regardless """ - if zha_device.is_mains_powered: + if device.is_mains_powered: return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) @staticmethod - def formatter(value: int) -> int | None: + def formatter(value: int) -> int | None: # pylint: disable=arguments-differ """Return the state of the entity.""" # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ if not isinstance(value, numbers.Number) or value == -1 or value == 255: @@ -418,20 +481,19 @@ def formatter(value: int) -> int | None: value = round(value / 2) return value - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attrs for battery sensors.""" - state_attrs = {} + def get_state(self) -> dict[str, Any]: + """Return the state for battery sensors.""" + response = super().get_state() battery_size = self._cluster_handler.cluster.get("battery_size") if battery_size is not None: - state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") + response["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") battery_quantity = self._cluster_handler.cluster.get("battery_quantity") if battery_quantity is not None: - state_attrs["battery_quantity"] = battery_quantity + response["battery_quantity"] = battery_quantity battery_voltage = self._cluster_handler.cluster.get("battery_voltage") if battery_voltage is not None: - state_attrs["battery_voltage"] = round(battery_voltage / 10, 2) - return state_attrs + response["battery_voltage"] = round(battery_voltage / 10, 2) + return response @MULTI_MATCH( @@ -439,7 +501,6 @@ def extra_state_attributes(self) -> dict[str, Any]: stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, models={"VZM31-SN", "SP 234", "outletv4"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurement(PollableSensor): """Active power measurement.""" @@ -450,24 +511,20 @@ class ElectricalMeasurement(PollableSensor): _attr_native_unit_of_measurement: str = UnitOfPower.WATT _div_mul_prefix: str | None = "ac_power" - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attrs for sensor.""" - attrs = {} + def get_state(self) -> dict[str, Any]: + """Return the state for this sensor.""" + response = super().get_state() if self._cluster_handler.measurement_type is not None: - attrs["measurement_type"] = self._cluster_handler.measurement_type + response["measurement_type"] = self._cluster_handler.measurement_type max_attr_name = f"{self._attribute_name}_max" + if not hasattr(self._cluster_handler.cluster.AttributeDefs, max_attr_name): + return response - try: - max_v = self._cluster_handler.cluster.get(max_attr_name) - except KeyError: - pass - else: - if max_v is not None: - attrs[max_attr_name] = str(self.formatter(max_v)) + if (max_v := self._cluster_handler.cluster.get(max_attr_name)) is not None: + response[max_attr_name] = self.formatter(max_v) - return attrs + return response def formatter(self, value: int) -> int | float: """Return 'normalized' value.""" @@ -489,7 +546,6 @@ def formatter(self, value: int) -> int | float: cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class PolledElectricalMeasurement(ElectricalMeasurement): """Polled active power measurement.""" @@ -497,7 +553,6 @@ class PolledElectricalMeasurement(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementApparentPower(PolledElectricalMeasurement): """Apparent power measurement.""" @@ -510,7 +565,6 @@ class ElectricalMeasurementApparentPower(PolledElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementRMSCurrent(PolledElectricalMeasurement): """RMS current measurement.""" @@ -523,7 +577,6 @@ class ElectricalMeasurementRMSCurrent(PolledElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementRMSVoltage(PolledElectricalMeasurement): """RMS Voltage measurement.""" @@ -536,7 +589,6 @@ class ElectricalMeasurementRMSVoltage(PolledElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementFrequency(PolledElectricalMeasurement): """Frequency measurement.""" @@ -550,7 +602,6 @@ class ElectricalMeasurementFrequency(PolledElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): """Power Factor measurement.""" @@ -570,7 +621,6 @@ class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Humidity(Sensor): """Humidity sensor.""" @@ -582,7 +632,6 @@ class Humidity(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SoilMoisture(Sensor): """Soil Moisture sensor.""" @@ -595,7 +644,6 @@ class SoilMoisture(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class LeafWetness(Sensor): """Leaf Wetness sensor.""" @@ -608,7 +656,6 @@ class LeafWetness(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Illuminance(Sensor): """Illuminance Sensor.""" @@ -627,19 +674,20 @@ def formatter(self, value: int) -> int | None: @dataclass(frozen=True, kw_only=True) -class SmartEnergyMeteringEntityDescription(SensorEntityDescription): +class SmartEnergyMeteringEntityDescription: """Dataclass that describes a Zigbee smart energy metering entity.""" key: str = "instantaneous_demand" state_class: SensorStateClass | None = SensorStateClass.MEASUREMENT scale: int = 1 + native_unit_of_measurement: str | None = None + device_class: SensorDeviceClass | None = None @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergyMetering(PollableSensor): """Metering sensor.""" @@ -708,46 +756,40 @@ class SmartEnergyMetering(PollableSensor): def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) entity_description = self._ENTITY_DESCRIPTION_MAP.get( self._cluster_handler.unit_of_measurement ) if entity_description is not None: self.entity_description = entity_description + self._attr_device_class = entity_description.device_class + self._attr_state_class = entity_description.state_class def formatter(self, value: int) -> int | float: """Pass through cluster handler formatter.""" return self._cluster_handler.demand_formatter(value) - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attrs for battery sensors.""" - attrs = {} + def get_state(self) -> dict[str, Any]: + """Return state for this sensor.""" + response = super().get_state() if self._cluster_handler.device_type is not None: - attrs["device_type"] = self._cluster_handler.device_type - if (status := self._cluster_handler.status) is not None: + response["device_type"] = self._cluster_handler.device_type + if (status := self._cluster_handler.metering_status) is not None: if isinstance(status, enum.IntFlag): - attrs["status"] = str( + response["status"] = str( status.name if status.name is not None else status.value ) else: - attrs["status"] = str(status)[len(status.__class__.__name__) + 1 :] - return attrs - - @property - def native_value(self) -> StateType: - """Return the state of the entity.""" - state = super().native_value - if hasattr(self, "entity_description") and state is not None: - return float(state) * self.entity_description.scale - - return state + response["status"] = str(status)[len(status.__class__.__name__) + 1 :] + response["zcl_unit_of_measurement"] = self._cluster_handler.unit_of_measurement + return response @dataclass(frozen=True, kw_only=True) @@ -762,7 +804,6 @@ class SmartEnergySummationEntityDescription(SmartEnergyMeteringEntityDescription cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergySummation(SmartEnergyMetering): """Smart Energy Metering summation sensor.""" @@ -845,7 +886,6 @@ def formatter(self, value: int) -> int | float: models={"TS011F", "ZLinky_TIC", "TICMeter"}, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" @@ -856,7 +896,6 @@ class PolledSmartEnergySummation(SmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC", "TICMeter"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" @@ -870,7 +909,6 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC", "TICMeter"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" @@ -884,7 +922,6 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC", "TICMeter"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" @@ -898,7 +935,6 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC", "TICMeter"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" @@ -912,7 +948,6 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC", "TICMeter"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" @@ -926,7 +961,6 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC", "TICMeter"}, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" @@ -939,7 +973,6 @@ class Tier6SmartEnergySummation(PolledSmartEnergySummation): @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergySummationReceived(PolledSmartEnergySummation): """Smart Energy Metering summation received sensor.""" @@ -949,11 +982,12 @@ class SmartEnergySummationReceived(PolledSmartEnergySummation): _attr_translation_key: str = "summation_received" @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -967,11 +1001,12 @@ def create_entity( """ if cluster_handlers[0].cluster.get(cls._attribute_name) is None: return None - return super().create_entity(unique_id, zha_device, cluster_handlers, **kwargs) + return super().create_platform_entity( + unique_id, cluster_handlers, endpoint, device, **kwargs + ) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Pressure(Sensor): """Pressure sensor.""" @@ -983,7 +1018,6 @@ class Pressure(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class Temperature(Sensor): """Temperature Sensor.""" @@ -995,7 +1029,6 @@ class Temperature(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class DeviceTemperature(Sensor): """Device Temperature Sensor.""" @@ -1009,7 +1042,6 @@ class DeviceTemperature(Sensor): @MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" @@ -1022,7 +1054,6 @@ class CarbonDioxideConcentration(Sensor): @MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" @@ -1036,7 +1067,6 @@ class CarbonMonoxideConcentration(Sensor): @MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") @MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class VOCLevel(Sensor): """VOC Level sensor.""" @@ -1053,7 +1083,6 @@ class VOCLevel(Sensor): models="lumi.airmonitor.acn01", stop_on_match_group="voc_level", ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class PPBVOCLevel(Sensor): """VOC Level sensor.""" @@ -1068,7 +1097,6 @@ class PPBVOCLevel(Sensor): @MULTI_MATCH(cluster_handler_names="pm25") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" @@ -1081,7 +1109,6 @@ class PM25(Sensor): @MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -1097,7 +1124,6 @@ class FormaldehydeConcentration(Sensor): cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class ThermostatHVACAction(Sensor): """Thermostat HVAC action sensor.""" @@ -1105,11 +1131,12 @@ class ThermostatHVACAction(Sensor): _attr_translation_key: str = "hvac_action" @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -1117,7 +1144,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) @property def native_value(self) -> str | None: @@ -1181,13 +1208,24 @@ def _pi_demand_action(self) -> HVACAction: return HVACAction.IDLE return HVACAction.OFF + def get_state(self) -> dict: + """Return the current HVAC action.""" + response = super().get_state() + if ( + self._cluster_handler.pi_heating_demand is None + and self._cluster_handler.pi_cooling_demand is None + ): + response["state"] = self._rm_rs_action + else: + response["state"] = self._pi_demand_action + return response + @MULTI_MATCH( cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, manufacturers="Sinope Technologies", stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SinopeHVACAction(ThermostatHVACAction): """Sinope Thermostat HVAC action sensor.""" @@ -1217,11 +1255,9 @@ def _rm_rs_action(self) -> HVACAction: @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class RSSISensor(Sensor): """RSSI sensor for a device.""" - _attribute_name = "rssi" _unique_id_suffix = "rssi" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.SIGNAL_STRENGTH @@ -1232,11 +1268,12 @@ class RSSISensor(Sensor): _attr_translation_key: str = "rssi" @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -1244,22 +1281,26 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ key = f"{CLUSTER_HANDLER_BASIC}_{cls._unique_id_suffix}" - if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): + if PLATFORM_ENTITIES.prevent_entity_creation(Platform.SENSOR, device.ieee, key): return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) @property - def native_value(self) -> StateType: + def native_value(self) -> str | int | float | None: """Return the state of the entity.""" - return getattr(self._zha_device.device, self._attribute_name) + return getattr(self._device.device, self._unique_id_suffix) + + def get_state(self) -> dict: + """Return the state of the sensor.""" + response = super().get_state() + response["state"] = getattr(self.device.device, self._unique_id_suffix) + return response @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class LQISensor(RSSISensor): """LQI sensor for a device.""" - _attribute_name = "lqi" _unique_id_suffix = "lqi" _attr_device_class = None _attr_native_unit_of_measurement = None @@ -1272,7 +1313,6 @@ class LQISensor(RSSISensor): "_TZE200_htnnfasr", }, ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class TimeLeft(Sensor): """Sensor that displays time left value.""" @@ -1285,7 +1325,6 @@ class TimeLeft(Sensor): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class IkeaDeviceRunTime(Sensor): """Sensor that displays device run time (in minutes).""" @@ -1299,7 +1338,6 @@ class IkeaDeviceRunTime(Sensor): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") -# pylint: disable-next=hass-invalid-inheritance # needs fixing class IkeaFilterRunTime(Sensor): """Sensor that displays run time of the current filter (in minutes).""" @@ -1320,7 +1358,6 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederLastFeedingSource(EnumSensor): """Sensor that displays the last feeding source of pet feeder.""" @@ -1332,7 +1369,6 @@ class AqaraPetFeederLastFeedingSource(EnumSensor): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederLastFeedingSize(Sensor): """Sensor that displays the last feeding size of the pet feeder.""" @@ -1343,7 +1379,6 @@ class AqaraPetFeederLastFeedingSize(Sensor): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederPortionsDispensed(Sensor): """Sensor that displays the number of portions dispensed by the pet feeder.""" @@ -1355,7 +1390,6 @@ class AqaraPetFeederPortionsDispensed(Sensor): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederWeightDispensed(Sensor): """Sensor that displays the weight dispensed by the pet feeder.""" @@ -1368,7 +1402,6 @@ class AqaraPetFeederWeightDispensed(Sensor): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraSmokeDensityDbm(Sensor): """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" @@ -1389,7 +1422,6 @@ class SonoffIlluminationStates(types.enum8): @MULTI_MATCH(cluster_handler_names="sonoff_manufacturer", models={"SNZB-06P"}) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SonoffPresenceSenorIlluminationStatus(EnumSensor): """Sensor that displays the illumination status the last time peresence was detected.""" @@ -1401,7 +1433,6 @@ class SonoffPresenceSenorIlluminationStatus(EnumSensor): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class PiHeatingDemand(Sensor): """Sensor that displays the percentage of heating power demanded. @@ -1427,7 +1458,6 @@ class SetpointChangeSourceEnum(types.enum8): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class SetpointChangeSource(EnumSensor): """Sensor that displays the source of the setpoint change. @@ -1443,7 +1473,6 @@ class SetpointChangeSource(EnumSensor): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class WindowCoveringTypeSensor(EnumSensor): """Sensor that displays the type of a cover device.""" @@ -1458,7 +1487,6 @@ class WindowCoveringTypeSensor(EnumSensor): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_BASIC, models={"lumi.curtain.agl001"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraCurtainMotorPowerSourceSensor(EnumSensor): """Sensor that displays the power source of the Aqara E1 curtain motor device.""" @@ -1482,7 +1510,6 @@ class AqaraE1HookState(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraCurtainHookStateSensor(EnumSensor): """Representation of a ZHA curtain mode configuration entity.""" diff --git a/zha/application/platforms/sensor/const.py b/zha/application/platforms/sensor/const.py new file mode 100644 index 00000000..70644788 --- /dev/null +++ b/zha/application/platforms/sensor/const.py @@ -0,0 +1,392 @@ +"""Constants for the sensor platform.""" + +import enum + + +class SensorStateClass(enum.StrEnum): + """State class for sensors.""" + + MEASUREMENT = "measurement" + """The state represents a measurement in present time.""" + + 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""" + + +class SensorDeviceClass(enum.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 + """ + + # Numerical device classes, these should be aligned with NumberDeviceClass + APPARENT_POWER = "apparent_power" + """Apparent power. + + Unit of measurement: `VA` + """ + + AQI = "aqi" + """Air Quality Index. + + Unit of measurement: `None` + """ + + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" + """Atmospheric pressure. + + Unit of measurement: `UnitOfPressure` units + """ + + BATTERY = "battery" + """Percentage of battery that is left. + + Unit of measurement: `%` + """ + + CO = "carbon_monoxide" + """Carbon Monoxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ + + CO2 = "carbon_dioxide" + """Carbon Dioxide gas concentration. + + Unit of measurement: `ppm` (parts per million) + """ + + 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` + """ + + ENERGY = "energy" + """Energy. + + Use this device class for sensors measuring energy consumption, for example + electric energy consumption. + Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + 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: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + """ + + FREQUENCY = "frequency" + """Frequency. + + Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + """ + + GAS = "gas" + """Gas. + + Unit of measurement: + - SI / metric: `m³` + - USCS / imperial: `ft³`, `CCF` + """ + + 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: `µg/m³` + """ + + NITROGEN_MONOXIDE = "nitrogen_monoxide" + """Amount of NO. + + Unit of measurement: `µg/m³` + """ + + NITROUS_OXIDE = "nitrous_oxide" + """Amount of N2O. + + Unit of measurement: `µg/m³` + """ + + OZONE = "ozone" + """Amount of O3. + + Unit of measurement: `µ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³` + """ + + POWER_FACTOR = "power_factor" + """Power factor. + + Unit of measurement: `%`, `None` + """ + + POWER = "power" + """Power. + + Unit of measurement: `W`, `kW` + """ + + 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` + - `Pa`, `hPa`, `kPa` + - `inHg` + - `psi` + """ + + REACTIVE_POWER = "reactive_power" + """Reactive power. + + Unit of measurement: `var` + """ + + 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` + - Beaufort: `Beaufort` + """ + + SULPHUR_DIOXIDE = "sulphur_dioxide" + """Amount of SO2. + + Unit of measurement: `µg/m³` + """ + + TEMPERATURE = "temperature" + """Temperature. + + Unit of measurement: `°C`, `°F`, `K` + """ + + VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" + """Amount of VOC. + + Unit of measurement: `µg/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` + """ + + VOLUME = "volume" + """Generic volume. + + Unit of measurement: `VOLUME_*` units + - SI / metric: `mL`, `L`, `m³` + - USCS / imperial: `ft³`, `CCF`, `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`, `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`, `L/min` + - USCS / imperial: `ft³/min`, `gal/min` + """ + + WATER = "water" + """Water. + + Unit of measurement: + - SI / metric: `m³`, `L` + - USCS / imperial: `ft³`, `CCF`, `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_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` + """ + + +NON_NUMERIC_DEVICE_CLASSES = { + SensorDeviceClass.DATE, + SensorDeviceClass.ENUM, + SensorDeviceClass.TIMESTAMP, +} diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index 59a468d2..b6e03a4f 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -2,30 +2,15 @@ from __future__ import annotations -from collections.abc import Callable +import asyncio +from enum import IntFlag import functools -from typing import TYPE_CHECKING, Any, cast - -from homeassistant.components.siren import ( - ATTR_DURATION, - ATTR_TONE, - ATTR_VOLUME_LEVEL, - SirenEntity, - SirenEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later +from typing import TYPE_CHECKING, Any, Final, cast + from zigpy.zcl.clusters.security import IasWd as WD -from .core import discovery -from .core.cluster_handlers.security import IasWdClusterHandler -from .core.const import ( - CLUSTER_HANDLER_IAS_WD, - SIGNAL_ADD_ENTITIES, +from zha.application import Platform +from zha.application.const import ( WARNING_DEVICE_MODE_BURGLAR, WARNING_DEVICE_MODE_EMERGENCY, WARNING_DEVICE_MODE_EMERGENCY_PANIC, @@ -38,51 +23,49 @@ WARNING_DEVICE_STROBE_NO, Strobe, ) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity +from zha.application.platforms import PlatformEntity +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD +from zha.zigbee.cluster_handlers.security import IasWdClusterHandler if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint -MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) +MULTI_MATCH = functools.partial(PLATFORM_ENTITIES.multipass_match, Platform.SIREN) DEFAULT_DURATION = 5 # seconds +ATTR_AVAILABLE_TONES: Final[str] = "available_tones" +ATTR_DURATION: Final[str] = "duration" +ATTR_VOLUME_LEVEL: Final[str] = "volume_level" +ATTR_TONE: Final[str] = "tone" + -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation siren from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.SIREN] +class SirenEntityFeature(IntFlag): + """Supported features of the siren entity.""" - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - ), - ) - config_entry.async_on_unload(unsub) + TURN_ON = 1 + TURN_OFF = 2 + TONES = 4 + VOLUME_SET = 8 + DURATION = 16 @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHASiren(ZhaEntity, SirenEntity): +class Siren(PlatformEntity): """Representation of a ZHA siren.""" + PLATFORM = Platform.SIREN _attr_name: str = "Siren" def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], - **kwargs, + endpoint: Endpoint, + device: Device, + **kwargs: Any, ) -> None: """Init this siren.""" self._attr_supported_features = ( @@ -100,17 +83,17 @@ def __init__( WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler: IasWdClusterHandler = cast( IasWdClusterHandler, cluster_handlers[0] ) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) self._attr_is_on: bool = False - self._off_listener: Callable[[], None] | None = None + self._off_listener: asyncio.TimerHandle | None = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" if self._off_listener: - self._off_listener() + self._off_listener.cancel() self._off_listener = None tone_cache = self._cluster_handler.data_cache.get( WD.Warning.WarningMode.__name__ @@ -154,24 +137,36 @@ async def async_turn_on(self, **kwargs: Any) -> None: strobe_intensity=strobe_level, ) self._attr_is_on = True - self._off_listener = async_call_later( - self._zha_device.hass, siren_duration, self.async_set_off + self._off_listener = asyncio.get_running_loop().call_later( + siren_duration, self.async_set_off ) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn off siren.""" await self._cluster_handler.issue_start_warning( mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO ) self._attr_is_on = False - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - @callback - def async_set_off(self, _) -> None: + def async_set_off(self) -> None: """Set is_on to False and write HA state.""" self._attr_is_on = False if self._off_listener: - self._off_listener() + self._off_listener.cancel() self._off_listener = None - self.async_write_ha_state() + self.maybe_emit_state_changed_event() + + def to_json(self) -> dict: + """Return JSON representation of the siren.""" + json = super().to_json() + json[ATTR_AVAILABLE_TONES] = self._attr_available_tones + json["supported_features"] = self._attr_supported_features + return json + + def get_state(self) -> dict: + """Get the state of the siren.""" + response = super().get_state() + response["state"] = self._attr_is_on + return response diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 5edd8aaf..7152263c 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -2,84 +2,64 @@ from __future__ import annotations +from abc import ABC import functools import logging -from typing import TYPE_CHECKING, Any, Self - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, EntityCategory, Platform -from homeassistant.core import HomeAssistant, State, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from typing import TYPE_CHECKING, Any, Self, cast + from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF -from zigpy.quirks.v2 import EntityMetadata, SwitchMetadata +from zigpy.quirks.v2 import SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status -from .core import discovery -from .core.const import ( +from zha.application import Platform +from zha.application.const import ENTITY_METADATA +from zha.application.platforms import ( + BaseEntity, + EntityCategory, + GroupEntity, + PlatformEntity, +) +from zha.application.registries import PLATFORM_ENTITIES +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_BASIC, CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - QUIRK_METADATA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, ) -from .core.helpers import get_zha_data -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity, ZhaGroupEntity +from zha.zigbee.cluster_handlers.general import OnOffClusterHandler +from zha.zigbee.group import Group if TYPE_CHECKING: - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint -STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) -GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.SWITCH) +STRICT_MATCH = functools.partial(PLATFORM_ENTITIES.strict_match, Platform.SWITCH) +GROUP_MATCH = functools.partial(PLATFORM_ENTITIES.group_match, Platform.SWITCH) CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.SWITCH + PLATFORM_ENTITIES.config_diagnostic_match, Platform.SWITCH ) _LOGGER = logging.getLogger(__name__) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation switch from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.SWITCH] - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create - ), - ) - config_entry.async_on_unload(unsub) - +class BaseSwitch(BaseEntity, ABC): + """Common base class for zhawss switches.""" -@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) -class Switch(ZhaEntity, SwitchEntity): - """ZHA switch.""" - - _attr_translation_key = "switch" + PLATFORM = Platform.SWITCH def __init__( self, - unique_id: str, - zha_device: ZHADevice, - cluster_handlers: list[ClusterHandler], + *args: Any, **kwargs: Any, - ) -> None: - """Initialize the ZHA switch.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._on_off_cluster_handler = self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + ): + """Initialize the switch.""" + self._on_off_cluster_handler: OnOffClusterHandler + super().__init__(*args, **kwargs) @property def is_on(self) -> bool: @@ -88,89 +68,111 @@ def is_on(self) -> bool: return False return self._on_off_cluster_handler.on_off - async def async_turn_on(self, **kwargs: Any) -> None: + # TODO revert this once group entities use cluster handlers + async def async_turn_on(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity on.""" await self._on_off_cluster_handler.turn_on() - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity off.""" await self._on_off_cluster_handler.turn_off() - self.async_write_ha_state() - - @callback - def async_set_state(self, attr_id: int, attr_name: str, value: Any): - """Handle state update from cluster handler.""" - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._on_off_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) + def get_state(self) -> dict: + """Return the state of the switch.""" + response = super().get_state() + response["state"] = self.is_on + return response - async def async_update(self) -> None: - """Attempt to retrieve on off state from the switch.""" - self.debug("Polling current state") - await self._on_off_cluster_handler.get_attribute_value( - "on_off", from_cache=False - ) +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) +class Switch(PlatformEntity, BaseSwitch): + """ZHA switch.""" -@GROUP_MATCH() -class SwitchGroup(ZhaGroupEntity, SwitchEntity): - """Representation of a switch group.""" + _attr_translation_key = "switch" def __init__( self, - entity_ids: list[str], unique_id: str, - group_id: int, - zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: + """Initialize the ZHA switch.""" + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._on_off_cluster_handler: OnOffClusterHandler = cast( + OnOffClusterHandler, self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + ) + self._on_off_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle state update from cluster handler.""" + if event.attribute_name == OnOff.AttributeDefs.on_off.name: + self.maybe_emit_state_changed_event() + + +@GROUP_MATCH() +class SwitchGroup(GroupEntity, BaseSwitch): + """Representation of a switch group.""" + + def __init__(self, group: Group): """Initialize a switch group.""" - super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) - self._available: bool + super().__init__(group) self._state: bool - group = self.zha_device.gateway.get_group(self._group_id) - self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id] + self._on_off_cluster_handler = group.zigpy_group.endpoint[OnOff.cluster_id] + self.update() @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" return bool(self._state) - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity on.""" result = await self._on_off_cluster_handler.on() - if result[1] is not Status.SUCCESS: + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return self._state = True - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity off.""" result = await self._on_off_cluster_handler.off() - if result[1] is not Status.SUCCESS: + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: return self._state = False - self.async_write_ha_state() - - async def async_update(self) -> None: - """Query all members and determine the switch group state.""" - all_states = [self.hass.states.get(x) for x in self._entity_ids] - states: list[State] = list(filter(None, all_states)) - on_states = [state for state in states if state.state == STATE_ON] + self.maybe_emit_state_changed_event() + + def update(self, _: Any | None = None) -> None: + """Query all members and determine the light group state.""" + self.debug("Updating switch group entity state") + platform_entities = self._group.get_platform_entities(self.PLATFORM) + all_entities = [entity.to_json() for entity in platform_entities] + all_states = [entity["state"] for entity in all_entities] + self.debug( + "All platform entity states for group entity members: %s", all_states + ) + on_states = [state for state in all_states if state["state"]] self._state = len(on_states) > 0 - self._available = any(state.state != STATE_UNAVAILABLE for state in states) + self._available = any(entity.available for entity in platform_entities) + + self.maybe_emit_state_changed_event() -class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): +class SwitchConfigurationEntity(PlatformEntity): """Representation of a ZHA switch configuration entity.""" + PLATFORM = Platform.SWITCH + _attr_entity_category = EntityCategory.CONFIG _attribute_name: str _inverter_attribute_name: str | None = None @@ -179,11 +181,12 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): _on_value: int = 1 @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -191,7 +194,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if QUIRK_METADATA not in kwargs and ( + if ENTITY_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -203,44 +206,44 @@ def create_entity( ) return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) def __init__( self, unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Init this number configuration entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] - if QUIRK_METADATA in kwargs: - self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + if ENTITY_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[ENTITY_METADATA]) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + self._cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) - def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + def _init_from_quirks_metadata(self, entity_metadata: SwitchMetadata) -> None: """Init this entity from the quirks metadata.""" super()._init_from_quirks_metadata(entity_metadata) - switch_metadata: SwitchMetadata = entity_metadata.entity_metadata - self._attribute_name = switch_metadata.attribute_name - if switch_metadata.invert_attribute_name: - self._inverter_attribute_name = switch_metadata.invert_attribute_name - if switch_metadata.force_inverted: - self._force_inverted = switch_metadata.force_inverted - self._off_value = switch_metadata.off_value - self._on_value = switch_metadata.on_value - - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state - ) - - @callback - def async_set_state(self, attr_id: int, attr_name: str, value: Any): + self._attribute_name = entity_metadata.attribute_name + if entity_metadata.invert_attribute_name: + self._inverter_attribute_name = entity_metadata.invert_attribute_name + if entity_metadata.force_inverted: + self._force_inverted = entity_metadata.force_inverted + self._off_value = entity_metadata.off_value + self._on_value = entity_metadata.on_value + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: """Handle state update from cluster handler.""" - self.async_write_ha_state() + if event.attribute_name == self._attribute_name: + self.maybe_emit_state_changed_event() @property def inverted(self) -> bool: @@ -273,26 +276,37 @@ async def async_turn_on_off(self, state: bool) -> None: await self._cluster_handler.write_attributes_safe( {self._attribute_name: self._off_value} ) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity on.""" await self.async_turn_on_off(True) - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity off.""" await self.async_turn_on_off(False) async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" self.debug("Polling current state") - value = await self._cluster_handler.get_attribute_value( - self._attribute_name, from_cache=False - ) - await self._cluster_handler.get_attribute_value( - self._inverter_attribute_name, from_cache=False + results = await self._cluster_handler.get_attributes( + [ + self._attribute_name, + self._inverter_attribute_name, + ], + from_cache=False, + only_cache=False, ) - self.debug("read value=%s, inverted=%s", value, self.inverted) + + self.debug("read values=%s", results) + self.maybe_emit_state_changed_event() + + def get_state(self) -> dict: + """Return the state of the switch.""" + response = super().get_state() + response["state"] = self.is_on + response["inverted"] = self.inverted + return response @CONFIG_DIAGNOSTIC_MATCH( @@ -301,7 +315,7 @@ async def async_update(self) -> None: "_TZE200_b6wax7g0", }, ) -class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEntity): +class OnOffWindowDetectionFunctionConfigurationEntity(SwitchConfigurationEntity): """Representation of a ZHA window detection configuration entity.""" _unique_id_suffix = "on_off_window_opened_detection" @@ -313,7 +327,7 @@ class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEnti @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac02"} ) -class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): +class P1MotionTriggerIndicatorSwitch(SwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" _unique_id_suffix = "trigger_indicator" @@ -325,7 +339,7 @@ class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): cluster_handler_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) -class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): +class XiaomiPlugPowerOutageMemorySwitch(SwitchConfigurationEntity): """Representation of a ZHA power outage memory configuration entity.""" _unique_id_suffix = "power_outage_memory" @@ -338,7 +352,7 @@ class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001", "SML002", "SML003", "SML004"}, ) -class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): +class HueMotionTriggerIndicatorSwitch(SwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" _unique_id_suffix = "trigger_indicator" @@ -350,7 +364,7 @@ class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class ChildLock(ZHASwitchConfigurationEntity): +class ChildLock(SwitchConfigurationEntity): """ZHA BinarySensor.""" _unique_id_suffix = "child_lock" @@ -362,7 +376,7 @@ class ChildLock(ZHASwitchConfigurationEntity): cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class DisableLed(ZHASwitchConfigurationEntity): +class DisableLed(SwitchConfigurationEntity): """ZHA BinarySensor.""" _unique_id_suffix = "disable_led" @@ -373,7 +387,7 @@ class DisableLed(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliInvertSwitch(ZHASwitchConfigurationEntity): +class InovelliInvertSwitch(SwitchConfigurationEntity): """Inovelli invert switch control.""" _unique_id_suffix = "invert_switch" @@ -384,7 +398,7 @@ class InovelliInvertSwitch(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): +class InovelliSmartBulbMode(SwitchConfigurationEntity): """Inovelli smart bulb mode control.""" _unique_id_suffix = "smart_bulb_mode" @@ -395,7 +409,7 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, models={"VZM35-SN"} ) -class InovelliSmartFanMode(ZHASwitchConfigurationEntity): +class InovelliSmartFanMode(SwitchConfigurationEntity): """Inovelli smart fan mode control.""" _unique_id_suffix = "smart_fan_mode" @@ -406,7 +420,7 @@ class InovelliSmartFanMode(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): +class InovelliDoubleTapUpEnabled(SwitchConfigurationEntity): """Inovelli double tap up enabled.""" _unique_id_suffix = "double_tap_up_enabled" @@ -417,7 +431,7 @@ class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): +class InovelliDoubleTapDownEnabled(SwitchConfigurationEntity): """Inovelli double tap down enabled.""" _unique_id_suffix = "double_tap_down_enabled" @@ -428,7 +442,7 @@ class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): +class InovelliAuxSwitchScenes(SwitchConfigurationEntity): """Inovelli unique aux switch scenes.""" _unique_id_suffix = "aux_switch_scenes" @@ -439,7 +453,7 @@ class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): +class InovelliBindingOffToOnSyncLevel(SwitchConfigurationEntity): """Inovelli send move to level with on/off to bound devices.""" _unique_id_suffix = "binding_off_to_on_sync_level" @@ -450,7 +464,7 @@ class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliLocalProtection(ZHASwitchConfigurationEntity): +class InovelliLocalProtection(SwitchConfigurationEntity): """Inovelli local protection control.""" _unique_id_suffix = "local_protection" @@ -461,7 +475,7 @@ class InovelliLocalProtection(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): +class InovelliOnOffLEDMode(SwitchConfigurationEntity): """Inovelli only 1 LED mode control.""" _unique_id_suffix = "on_off_led_mode" @@ -472,7 +486,7 @@ class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): +class InovelliFirmwareProgressLED(SwitchConfigurationEntity): """Inovelli firmware progress LED control.""" _unique_id_suffix = "firmware_progress_led" @@ -483,7 +497,7 @@ class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): +class InovelliRelayClickInOnOffMode(SwitchConfigurationEntity): """Inovelli relay click in on off mode control.""" _unique_id_suffix = "relay_click_in_on_off_mode" @@ -494,7 +508,7 @@ class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntity): +class InovelliDisableDoubleTapClearNotificationsMode(SwitchConfigurationEntity): """Inovelli disable clear notifications double tap control.""" _unique_id_suffix = "disable_clear_notifications_double_tap" @@ -505,7 +519,7 @@ class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntit @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): +class AqaraPetFeederLEDIndicator(SwitchConfigurationEntity): """Representation of a LED indicator configuration entity.""" _unique_id_suffix = "disable_led_indicator" @@ -518,7 +532,7 @@ class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): +class AqaraPetFeederChildLock(SwitchConfigurationEntity): """Representation of a child lock configuration entity.""" _unique_id_suffix = "child_lock" @@ -530,7 +544,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_ON_OFF, quirk_ids=TUYA_PLUG_ONOFF ) -class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): +class TuyaChildLockSwitch(SwitchConfigurationEntity): """Representation of a child lock configuration entity.""" _unique_id_suffix = "child_lock" @@ -542,7 +556,7 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): +class AqaraThermostatWindowDetection(SwitchConfigurationEntity): """Representation of an Aqara thermostat window detection configuration entity.""" _unique_id_suffix = "window_detection" @@ -553,7 +567,7 @@ class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): +class AqaraThermostatValveDetection(SwitchConfigurationEntity): """Representation of an Aqara thermostat valve detection configuration entity.""" _unique_id_suffix = "valve_detection" @@ -564,7 +578,7 @@ class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): +class AqaraThermostatChildLock(SwitchConfigurationEntity): """Representation of an Aqara thermostat child lock configuration entity.""" _unique_id_suffix = "child_lock" @@ -576,7 +590,7 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): +class AqaraHeartbeatIndicator(SwitchConfigurationEntity): """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "heartbeat_indicator" @@ -588,7 +602,7 @@ class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): +class AqaraLinkageAlarm(SwitchConfigurationEntity): """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "linkage_alarm" @@ -600,7 +614,7 @@ class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): +class AqaraBuzzerManualMute(SwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "buzzer_manual_mute" @@ -612,7 +626,7 @@ class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): +class AqaraBuzzerManualAlarm(SwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "buzzer_manual_alarm" @@ -622,7 +636,7 @@ class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COVER) -class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): +class WindowCoveringInversionSwitch(SwitchConfigurationEntity): """Representation of a switch that controls inversion for window covering devices. This is necessary because this cluster uses 2 attributes to control inversion. @@ -634,11 +648,12 @@ class WindowCoveringInversionSwitch(ZHASwitchConfigurationEntity): _attr_icon: str = "mdi:arrow-up-down" @classmethod - def create_entity( - cls, + def create_platform_entity( + cls: type[Self], unique_id: str, - zha_device: ZHADevice, cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> Self | None: """Entity Factory. @@ -667,7 +682,7 @@ def create_entity( ) return None - return cls(unique_id, zha_device, cluster_handlers, **kwargs) + return cls(unique_id, cluster_handlers, endpoint, device, **kwargs) @property def is_on(self) -> bool: @@ -696,7 +711,7 @@ async def async_update(self) -> None: from_cache=False, only_cache=False, ) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() async def _async_on_off(self, invert: bool) -> None: """Turn the entity on or off.""" @@ -719,7 +734,7 @@ async def _async_on_off(self, invert: bool) -> None: @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.curtain.agl001"} ) -class AqaraE1CurtainMotorHooksLockedSwitch(ZHASwitchConfigurationEntity): +class AqaraE1CurtainMotorHooksLockedSwitch(SwitchConfigurationEntity): """Representation of a switch that controls whether the curtain motor hooks are locked.""" _unique_id_suffix = "hooks_lock" diff --git a/zha/application/platforms/update.py b/zha/application/platforms/update.py index fba93ff4..69eef815 100644 --- a/zha/application/platforms/update.py +++ b/zha/application/platforms/update.py @@ -2,82 +2,61 @@ from __future__ import annotations +from enum import IntFlag, StrEnum import functools import logging import math -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Final, final -from homeassistant.components.update import ( - UpdateDeviceClass, - UpdateEntity, - UpdateEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from awesomeversion import AwesomeVersion, AwesomeVersionCompareException from zigpy.ota import OtaImageWithMetadata from zigpy.zcl.clusters.general import Ota from zigpy.zcl.foundation import Status -from .core import discovery -from .core.const import CLUSTER_HANDLER_OTA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED -from .core.helpers import get_zha_data, get_zha_gateway -from .core.registries import ZHA_ENTITIES -from .entity import ZhaEntity +from zha.application import Platform +from zha.application.platforms import EntityCategory, PlatformEntity +from zha.application.registries import PLATFORM_ENTITIES +from zha.decorators import callback +from zha.exceptions import ZHAException +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_OTA, +) +from zha.zigbee.endpoint import Endpoint if TYPE_CHECKING: - from zigpy.application import ControllerApplication + # from zigpy.application import ControllerApplication - from .core.cluster_handlers import ClusterHandler - from .core.device import ZHADevice + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device _LOGGER = logging.getLogger(__name__) CONFIG_DIAGNOSTIC_MATCH = functools.partial( - ZHA_ENTITIES.config_diagnostic_match, Platform.UPDATE + PLATFORM_ENTITIES.config_diagnostic_match, Platform.UPDATE ) +# pylint: disable=unused-argument +# pylint: disable=pointless-string-statement +"""TODO do this in discovery? +zha_data = get_zha_data(hass) +entities_to_create = zha_data.platforms[Platform.UPDATE] -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Zigbee Home Automation update from config entry.""" - zha_data = get_zha_data(hass) - entities_to_create = zha_data.platforms[Platform.UPDATE] - - coordinator = ZHAFirmwareUpdateCoordinator( - hass, get_zha_gateway(hass).application_controller - ) - - unsub = async_dispatcher_connect( - hass, - SIGNAL_ADD_ENTITIES, - functools.partial( - discovery.async_add_entities, - async_add_entities, - entities_to_create, - coordinator=coordinator, - ), - ) - config_entry.async_on_unload(unsub) - +coordinator = ZHAFirmwareUpdateCoordinator( + hass, get_zha_gateway(hass).application_controller +) +""" +# pylint: disable=pointless-string-statement +"""TODO find a solution for this class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module - """Firmware update coordinator that broadcasts updates network-wide.""" + #Firmware update coordinator that broadcasts updates network-wide. def __init__( self, hass: HomeAssistant, controller_application: ControllerApplication ) -> None: - """Initialize the coordinator.""" + #Initialize the coordinator. super().__init__( hass, _LOGGER, @@ -87,17 +66,46 @@ def __init__( self.controller_application = controller_application async def async_update_data(self) -> None: - """Fetch the latest firmware update data.""" + #Fetch the latest firmware update data. # Broadcast to all devices await self.controller_application.ota.broadcast_notify(jitter=100) +""" + + +class UpdateDeviceClass(StrEnum): + """Device class for update.""" + + FIRMWARE = "firmware" + + +class UpdateEntityFeature(IntFlag): + """Supported features of the update entity.""" + + INSTALL = 1 + SPECIFIC_VERSION = 2 + PROGRESS = 4 + BACKUP = 8 + RELEASE_NOTES = 16 + + +SERVICE_INSTALL: Final = "install" + +ATTR_BACKUP: Final = "backup" +ATTR_INSTALLED_VERSION: Final = "installed_version" +ATTR_IN_PROGRESS: Final = "in_progress" +ATTR_LATEST_VERSION: Final = "latest_version" +ATTR_RELEASE_SUMMARY: Final = "release_summary" +ATTR_RELEASE_URL: Final = "release_url" +ATTR_VERSION: Final = "version" +# old base classes: CoordinatorEntity[ZHAFirmwareUpdateCoordinator], UpdateEntity @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_OTA) -class ZHAFirmwareUpdateEntity( - ZhaEntity, CoordinatorEntity[ZHAFirmwareUpdateCoordinator], UpdateEntity -): +class FirmwareUpdateEntity(PlatformEntity): """Representation of a ZHA firmware update entity.""" + PLATFORM: Final = Platform.UPDATE + _unique_id_suffix = "firmware_update" _attr_entity_category = EntityCategory.CONFIG _attr_device_class = UpdateDeviceClass.FIRMWARE @@ -106,18 +114,23 @@ class ZHAFirmwareUpdateEntity( | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.SPECIFIC_VERSION ) + _attr_installed_version: str | None = None + _attr_in_progress: bool | int = False + _attr_latest_version: str | None = None + _attr_release_summary: str | None = None + _attr_release_url: str | None = None def __init__( self, unique_id: str, - zha_device: ZHADevice, - channels: list[ClusterHandler], - coordinator: ZHAFirmwareUpdateCoordinator, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, **kwargs: Any, ) -> None: """Initialize the ZHA update entity.""" - super().__init__(unique_id, zha_device, channels, **kwargs) - CoordinatorEntity.__init__(self, coordinator) + super().__init__(unique_id, cluster_handlers, endpoint, device, **kwargs) + # CoordinatorEntity.__init__(self, coordinator) self._ota_cluster_handler: ClusterHandler = self.cluster_handlers[ CLUSTER_HANDLER_OTA @@ -126,6 +139,116 @@ def __init__( self._attr_latest_version = self._attr_installed_version self._latest_firmware: OtaImageWithMetadata | None = None + self.device.device.add_listener(self) + self._ota_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self._attr_installed_version + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress. + + Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used. + + Can either return a boolean (True if in progress, False if not) + or an integer to indicate the progress in from 0 to 100%. + """ + return self._attr_in_progress + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + return self._attr_latest_version + + @property + def release_summary(self) -> str | None: + """Summary of the release notes or changelog. + + This is not suitable for long changelogs, but merely suitable + for a short excerpt update description of max 255 characters. + """ + return self._attr_release_summary + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + return self._attr_release_url + + @property + def supported_features(self) -> UpdateEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + @property + @final + def state(self) -> bool | None: + """Return the entity state.""" + if (installed_version := self.installed_version) is None or ( + latest_version := self.latest_version + ) is None: + return None + + if latest_version == installed_version: + return False + + try: + newer = _version_is_newer(latest_version, installed_version) + return True if newer else False + except AwesomeVersionCompareException: + # Can't compare versions, already tried exact match + return True + + @final + @property + def state_attributes(self) -> dict[str, Any] | None: + """Return state attributes.""" + if (release_summary := self.release_summary) is not None: + release_summary = release_summary[:255] + + # If entity supports progress, return the in_progress value. + # Otherwise, we use the internal progress value. + if UpdateEntityFeature.PROGRESS in self.supported_features: + in_progress = self.in_progress + else: + in_progress = self._attr_in_progress + + installed_version = self.installed_version + latest_version = self.latest_version + + return { + ATTR_INSTALLED_VERSION: installed_version, + ATTR_IN_PROGRESS: in_progress, + ATTR_LATEST_VERSION: latest_version, + ATTR_RELEASE_SUMMARY: release_summary, + ATTR_RELEASE_URL: self.release_url, + } + + @final + async def async_install_with_progress( + self, version: str | None, backup: bool + ) -> None: + """Install update and handle progress if needed. + + Handles setting the in_progress state in case the entity doesn't + support it natively. + """ + if UpdateEntityFeature.PROGRESS not in self.supported_features: + self._attr_in_progress = True + self.maybe_emit_state_changed_event() + + try: + await self.async_install(version, backup) + finally: + # No matter what happens, we always stop progress in the end + self._attr_in_progress = False + self.maybe_emit_state_changed_event() + def _get_cluster_version(self) -> str | None: """Synchronize current file version with the cluster.""" @@ -139,12 +262,14 @@ def _get_cluster_version(self) -> str | None: return None - @callback - def attribute_updated(self, attrid: int, name: str, value: Any) -> None: + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: """Handle attribute updates on the OTA cluster.""" - if attrid == Ota.AttributeDefs.current_file_version.id: - self._attr_installed_version = f"0x{value:08x}" - self.async_write_ha_state() + if event.attribute_id == Ota.AttributeDefs.current_file_version.id: + self._attr_installed_version = f"0x{event.attribute_value:08x}" + self.maybe_emit_state_changed_event() @callback def device_ota_update_available( @@ -158,9 +283,8 @@ def device_ota_update_available( if image.metadata.changelog: self._attr_release_summary = image.metadata.changelog - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - @callback def _update_progress(self, current: int, total: int, progress: float) -> None: """Update install progress on event.""" # If we are not supposed to be updating, do nothing @@ -169,7 +293,7 @@ def _update_progress(self, current: int, total: int, progress: float) -> None: # Remap progress to 2-100 to avoid 0 and 1 self._attr_in_progress = int(math.ceil(2 + 98 * progress / 100)) - self.async_write_ha_state() + self.maybe_emit_state_changed_event() async def async_install( self, version: str | None, backup: bool, **kwargs: Any @@ -179,47 +303,60 @@ async def async_install( # Set the progress to an indeterminate state self._attr_in_progress = True - self.async_write_ha_state() + self.maybe_emit_state_changed_event() try: - result = await self.zha_device.device.update_firmware( + result = await self.device.device.update_firmware( image=self._latest_firmware, progress_callback=self._update_progress, ) except Exception as ex: - raise HomeAssistantError(f"Update was not successful: {ex}") from ex + raise ZHAException(f"Update was not successful: {ex}") from ex # If we tried to install firmware that is no longer compatible with the device, # bail out if result == Status.NO_IMAGE_AVAILABLE: + self._attr_in_progress = False self._attr_latest_version = self._attr_installed_version - self.async_write_ha_state() + self.maybe_emit_state_changed_event() # If the update finished but was not successful, we should also throw an error if result != Status.SUCCESS: - raise HomeAssistantError(f"Update was not successful: {result}") + raise ZHAException(f"Update was not successful: {result}") # Clear the state self._latest_firmware = None self._attr_in_progress = False - self.async_write_ha_state() + self.maybe_emit_state_changed_event() - async def async_added_to_hass(self) -> None: - """Call when entity is added.""" - await super().async_added_to_hass() - - # OTA events are sent by the device - self.zha_device.device.add_listener(self) - self.async_accept_signal( - self._ota_cluster_handler, SIGNAL_ATTR_UPDATED, self.attribute_updated - ) - - async def async_will_remove_from_hass(self) -> None: + async def on_remove(self) -> None: """Call when entity will be removed.""" - await super().async_will_remove_from_hass() + await super().on_remove() self._attr_in_progress = False async def async_update(self) -> None: """Update the entity.""" - await CoordinatorEntity.async_update(self) + # await CoordinatorEntity.async_update(self) await super().async_update() + + def get_state(self): + """Get the state for the entity.""" + response = super().get_state() + response["state"] = self.state + response.update(self.state_attributes) + return response + + def to_json(self): + """Return entity in JSON format.""" + response = super().to_json() + response["entity_category"] = self._attr_entity_category + response["device_class"] = self._attr_device_class + response["supported_features"] = self._attr_supported_features + response.update(self.state_attributes) + return response + + +@functools.lru_cache(maxsize=256) +def _version_is_newer(latest_version: str, installed_version: str) -> bool: + """Return True if version is newer.""" + return AwesomeVersion(latest_version) > installed_version diff --git a/zha/application/registries.py b/zha/application/registries.py index cc0324e8..69b0a547 100644 --- a/zha/application/registries.py +++ b/zha/application/registries.py @@ -6,34 +6,24 @@ from collections.abc import Callable import dataclasses from operator import attrgetter -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING import attr -from homeassistant.const import Platform from zigpy import zcl import zigpy.profiles.zha import zigpy.profiles.zll from zigpy.types.named import EUI64 -from .decorators import DictRegistry, NestedDictRegistry, SetRegistry +from zha.application import Platform if TYPE_CHECKING: - from ..application.entity import ZhaEntity, ZhaGroupEntity - from .cluster_handlers import ClientClusterHandler, ClusterHandler + from zha.application.platforms import GroupEntity, PlatformEntity + from zha.zigbee.cluster_handlers import ClusterHandler -_ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) -_ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) - GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] -IKEA_AIR_PURIFIER_CLUSTER = 0xFC7D -PHILLIPS_REMOTE_CLUSTER = 0xFC00 -SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 -SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45 -TUYA_MANUFACTURER_CLUSTER = 0xEF00 -VOC_LEVEL_CLUSTER = 0x042E REMOTE_DEVICE_TYPES = { zigpy.profiles.zha.PROFILE_ID: [ @@ -73,8 +63,6 @@ zcl.clusters.security.IasAce.cluster_id: Platform.ALARM_CONTROL_PANEL, } -BINDABLE_CLUSTERS = SetRegistry() - DEVICE_CLASS = { zigpy.profiles.zha.PROFILE_ID: { SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE: Platform.DEVICE_TRACKER, @@ -106,14 +94,6 @@ } DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) -CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() -CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClientClusterHandler]] = ( - DictRegistry() -) -ZIGBEE_CLUSTER_HANDLER_REGISTRY: NestedDictRegistry[type[ClusterHandler]] = ( - NestedDictRegistry() -) - WEIGHT_ATTR = attrgetter("weight") @@ -268,48 +248,50 @@ def _matched( class EntityClassAndClusterHandlers: """Container for entity class and corresponding cluster handlers.""" - entity_class: type[ZhaEntity] + entity_class: type[PlatformEntity] claimed_cluster_handlers: list[ClusterHandler] -class ZHAEntityRegistry: +class PlatformEntityRegistry: """Cluster handler to ZHA Entity mapping.""" def __init__(self) -> None: """Initialize Registry instance.""" - self._strict_registry: dict[Platform, dict[MatchRule, type[ZhaEntity]]] = ( + self._strict_registry: dict[Platform, dict[MatchRule, type[PlatformEntity]]] = ( collections.defaultdict(dict) ) self._multi_entity_registry: dict[ - Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, + dict[int | str | None, dict[MatchRule, list[type[PlatformEntity]]]], ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) self._config_diagnostic_entity_registry: dict[ - Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, + dict[int | str | None, dict[MatchRule, list[type[PlatformEntity]]]], ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) - self._group_registry: dict[str, type[ZhaGroupEntity]] = {} + self._group_registry: dict[str, type[GroupEntity]] = {} self.single_device_matches: dict[Platform, dict[EUI64, list[str]]] = ( collections.defaultdict(lambda: collections.defaultdict(list)) ) def get_entity( self, - component: Platform, + platform: Platform, manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], quirk_id: str | None, - default: type[ZhaEntity] | None = None, - ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: + default: type[PlatformEntity] | None = None, + ) -> tuple[type[PlatformEntity] | None, list[ClusterHandler]]: """Match a ZHA ClusterHandler to a ZHA Entity class.""" - matches = self._strict_registry[component] + matches = self._strict_registry[platform] for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): if match.strict_matched(manufacturer, model, cluster_handlers, quirk_id): claimed = match.claim_cluster_handlers(cluster_handlers) - return self._strict_registry[component][match], claimed + return self._strict_registry[platform][match], claimed return default, [] @@ -327,7 +309,7 @@ def get_multi_entity( collections.defaultdict(list) ) all_claimed: set[ClusterHandler] = set() - for component, stop_match_groups in self._multi_entity_registry.items(): + for platform, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: @@ -339,7 +321,7 @@ def get_multi_entity( ent_n_cluster_handlers = EntityClassAndClusterHandlers( ent_class, claimed ) - result[component].append(ent_n_cluster_handlers) + result[platform].append(ent_n_cluster_handlers) all_claimed |= set(claimed) if stop_match_grp: break @@ -361,7 +343,7 @@ def get_config_diagnostic_entity( ) all_claimed: set[ClusterHandler] = set() for ( - component, + platform, stop_match_groups, ) in self._config_diagnostic_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): @@ -375,27 +357,27 @@ def get_config_diagnostic_entity( ent_n_cluster_handlers = EntityClassAndClusterHandlers( ent_class, claimed ) - result[component].append(ent_n_cluster_handlers) + result[platform].append(ent_n_cluster_handlers) all_claimed |= set(claimed) if stop_match_grp: break return result, list(all_claimed) - def get_group_entity(self, component: str) -> type[ZhaGroupEntity] | None: + def get_group_entity(self, platform: str) -> type[GroupEntity] | None: """Match a ZHA group to a ZHA Entity class.""" - return self._group_registry.get(component) + return self._group_registry.get(platform) def strict_match( self, - component: Platform, + platform: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, quirk_ids: set[str] | str | None = None, - ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: + ) -> Callable[[type[PlatformEntity]], type[PlatformEntity]]: """Decorate a strict match rule.""" rule = MatchRule( @@ -407,19 +389,19 @@ def strict_match( quirk_ids, ) - def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: + def decorator(zha_ent: type[PlatformEntity]) -> type[PlatformEntity]: """Register a strict match rule. All non-empty fields of a match rule must match. """ - self._strict_registry[component][rule] = zha_ent + self._strict_registry[platform][rule] = zha_ent return zha_ent return decorator def multipass_match( self, - component: Platform, + platform: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -427,7 +409,7 @@ def multipass_match( aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, quirk_ids: set[str] | str | None = None, - ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: + ) -> Callable[[type[PlatformEntity]], type[PlatformEntity]]: """Decorate a loose match rule.""" rule = MatchRule( @@ -439,13 +421,13 @@ def multipass_match( quirk_ids, ) - def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: + def decorator(zha_entity: type[PlatformEntity]) -> type[PlatformEntity]: """Register a loose match rule. All non empty fields of a match rule must match. """ # group the rules by cluster handlers - self._multi_entity_registry[component][stop_on_match_group][rule].append( + self._multi_entity_registry[platform][stop_on_match_group][rule].append( zha_entity ) return zha_entity @@ -454,7 +436,7 @@ def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: def config_diagnostic_match( self, - component: Platform, + platform: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -462,7 +444,7 @@ def config_diagnostic_match( aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, quirk_ids: set[str] | str | None = None, - ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: + ) -> Callable[[type[PlatformEntity]], type[PlatformEntity]]: """Decorate a loose match rule.""" rule = MatchRule( @@ -474,13 +456,13 @@ def config_diagnostic_match( quirk_ids, ) - def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: + def decorator(zha_entity: type[PlatformEntity]) -> type[PlatformEntity]: """Register a loose match rule. All non-empty fields of a match rule must match. """ # group the rules by cluster handlers - self._config_diagnostic_entity_registry[component][stop_on_match_group][ + self._config_diagnostic_entity_registry[platform][stop_on_match_group][ rule ].append(zha_entity) return zha_entity @@ -488,13 +470,13 @@ def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: return decorator def group_match( - self, component: Platform - ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: + self, platform: Platform + ) -> Callable[[type[GroupEntity]], type[GroupEntity]]: """Decorate a group match rule.""" - def decorator(zha_ent: _ZhaGroupEntityT) -> _ZhaGroupEntityT: + def decorator(zha_ent: type[GroupEntity]) -> type[GroupEntity]: """Register a group match rule.""" - self._group_registry[component] = zha_ent + self._group_registry[platform] = zha_ent return zha_ent return decorator @@ -515,4 +497,4 @@ def clean_up(self) -> None: ) -ZHA_ENTITIES = ZHAEntityRegistry() +PLATFORM_ENTITIES = PlatformEntityRegistry() diff --git a/zha/async_.py b/zha/async_.py new file mode 100644 index 00000000..67e21f82 --- /dev/null +++ b/zha/async_.py @@ -0,0 +1,572 @@ +"""Async utilities for Zigbee Home Automation.""" + +import asyncio +from asyncio import AbstractEventLoop, Future, Semaphore, Task, gather, get_running_loop +from collections.abc import Awaitable, Callable, Collection, Coroutine, Iterable +from dataclasses import dataclass +import enum +import functools +from functools import cached_property +import logging +import time +from typing import ( + TYPE_CHECKING, + Any, + Final, + Generic, + ParamSpec, + TypeVar, + cast, + overload, +) + +from zigpy.types.named import EUI64 + +from zha.decorators import callback + +_T = TypeVar("_T") +_R = TypeVar("_R") +_R_co = TypeVar("_R_co", covariant=True) +_P = ParamSpec("_P") +BLOCK_LOG_TIMEOUT: Final[int] = 60 + +_LOGGER = logging.getLogger(__name__) + + +def create_eager_task( + coro: Coroutine[Any, Any, _T], + *, + name: str | None = None, + loop: AbstractEventLoop | None = None, +) -> Task[_T]: + """Create a task from a coroutine and schedule it to run immediately.""" + return Task( + coro, + loop=loop or get_running_loop(), + name=name, + eager_start=True, + ) + + +async def gather_with_limited_concurrency( + limit: int, *tasks: Any, return_exceptions: bool = False +) -> Any: + """Wrap asyncio.gather to limit the number of concurrent tasks. + + From: https://stackoverflow.com/a/61478547/9127614 + """ + semaphore = Semaphore(limit) + + async def sem_task(task: Awaitable[Any]) -> Any: + async with semaphore: + return await task + + return await gather( + *(create_eager_task(sem_task(task)) for task in tasks), + return_exceptions=return_exceptions, + ) + + +def cancelling(task: Future[Any]) -> bool: + """Return True if task is cancelling.""" + return bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) + + +@enum.unique +class ZHAJobType(enum.Enum): + """Represent a job type.""" + + Coroutinefunction = 1 + Callback = 2 + Executor = 3 + + +class ZHAJob(Generic[_P, _R_co]): + """Represent a job to be run later. + + We check the callable type in advance + so we can avoid checking it every time + we run the job. + """ + + def __init__( + self, + target: Callable[_P, _R_co], + name: str | None = None, + *, + cancel_on_shutdown: bool | None = None, + job_type: ZHAJobType | None = None, + ) -> None: + """Create a job object.""" + self.target = target + self.name = name + self._cancel_on_shutdown = cancel_on_shutdown + self._job_type = job_type + + @cached_property + def job_type(self) -> ZHAJobType: + """Return the job type.""" + return self._job_type or get_zhajob_callable_job_type(self.target) + + @property + def cancel_on_shutdown(self) -> bool | None: + """Return if the job should be cancelled on shutdown.""" + return self._cancel_on_shutdown + + def __repr__(self) -> str: + """Return the job.""" + return f"" + + +@dataclass(frozen=True) +class ZHAJobWithArgs: + """Container for a ZHAJob and arguments.""" + + job: ZHAJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + +def get_zhajob_callable_job_type(target: Callable[..., Any]) -> ZHAJobType: + """Determine the job type from the callable.""" + # Check for partials to properly determine if coroutine function + check_target = target + while isinstance(check_target, functools.partial): + check_target = check_target.func + + if asyncio.iscoroutinefunction(check_target): + return ZHAJobType.Coroutinefunction + if is_callback(check_target): + return ZHAJobType.Callback + if asyncio.iscoroutine(check_target): + raise ValueError("Coroutine not allowed to be passed to ZHAJob") + return ZHAJobType.Executor + + +def is_callback(func: Callable[..., Any]) -> bool: + """Check if function is safe to be called in the event loop.""" + return getattr(func, "_zha_callback", False) is True + + +class AsyncUtilMixin: + """Mixin for dealing with async stuff.""" + + def __init__(self, *args, **kw_args) -> None: + """Initialize the async mixin.""" + self.loop = asyncio.get_running_loop() + self._tracked_completable_tasks: set[asyncio.Task] = set() + self._device_init_tasks: dict[EUI64, asyncio.Task] = {} + self._background_tasks: set[asyncio.Future[Any]] = set() + self._untracked_background_tasks: set[asyncio.Future[Any]] = set() + super().__init__(*args, **kw_args) + + async def async_block_till_done(self, wait_background_tasks: bool = False) -> None: + """Block until all pending work is done.""" + # To flush out any call_soon_threadsafe + await asyncio.sleep(0) + start_time: float | None = None + current_task = asyncio.current_task() + while tasks := [ + task + for task in ( + self._tracked_completable_tasks | self._background_tasks + if wait_background_tasks + else self._tracked_completable_tasks + ) + if task is not current_task and not cancelling(task) + ]: + await self._await_and_log_pending(tasks) + + if start_time is None: + # Avoid calling monotonic() until we know + # we may need to start logging blocked tasks. + start_time = 0 + elif start_time == 0: + # If we have waited twice then we set the start + # time + start_time = time.monotonic() + elif time.monotonic() - start_time > BLOCK_LOG_TIMEOUT: + # We have waited at least three loops and new tasks + # continue to block. At this point we start + # logging all waiting tasks. + for task in tasks: + _LOGGER.debug("Waiting for task: %s", task) + + async def _await_and_log_pending( + self, pending: Collection[asyncio.Future[Any]] + ) -> None: + """Await and log tasks that take a long time.""" + wait_time = 0 + while pending: + _, pending = await asyncio.wait(pending, timeout=BLOCK_LOG_TIMEOUT) + if not pending: + return + wait_time += BLOCK_LOG_TIMEOUT + for task in pending: + _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + + def track_task(self, task: asyncio.Task) -> None: + """Create a tracked task.""" + self._tracked_completable_tasks.add(task) + task.add_done_callback(self._tracked_completable_tasks.remove) + + def add_job( + self, target: Callable[..., Any] | Coroutine[Any, Any, Any], *args: Any + ) -> None: + """Add a job to be executed by the event loop or by an executor. + + If the job is either a coroutine or decorated with @callback, it will be + run by the event loop, if not it will be run by an executor. + + target: target to call. + args: parameters for method to call. + """ + if target is None: + raise ValueError("Don't call add_job with None") + if asyncio.iscoroutine(target): + self.loop.call_soon_threadsafe( + functools.partial(self.async_create_task, target, eager_start=True) + ) + return + if TYPE_CHECKING: + target = cast(Callable[..., Any], target) + self.loop.call_soon_threadsafe( + functools.partial( + self.async_add_job, ZHAJob(target), *args, eager_start=True + ) + ) + + def _cancel_cancellable_timers(self) -> None: + """Cancel timer handles marked as cancellable.""" + # pylint: disable-next=protected-access + handles: Iterable[asyncio.TimerHandle] = self.loop._scheduled # type: ignore[attr-defined] + for handle in handles: + if ( + not handle.cancelled() + and (args := handle._args) # pylint: disable=protected-access + and type(job := args[0]) is ZHAJob + and job.cancel_on_shutdown + ): + handle.cancel() + + @overload + @callback + def async_add_job( + self, + target: Callable[..., Coroutine[Any, Any, _R]], + *args: Any, + eager_start: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @overload + @callback + def async_add_job( + self, + target: Callable[..., Coroutine[Any, Any, _R] | _R], + *args: Any, + eager_start: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @overload + @callback + def async_add_job( + self, + target: Coroutine[Any, Any, _R], + *args: Any, + eager_start: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @callback + def async_add_job( + self, + target: Callable[..., Coroutine[Any, Any, _R] | _R] | Coroutine[Any, Any, _R], + *args: Any, + eager_start: bool = False, + ) -> asyncio.Future[_R] | None: + """Add a job to be executed by the event loop or by an executor. + + If the job is either a coroutine or decorated with @callback, it will be + run by the event loop, if not it will be run by an executor. + + This method must be run in the event loop. + + target: target to call. + args: parameters for method to call. + """ + if target is None: + raise ValueError("Don't call async_add_job with None") + + if asyncio.iscoroutine(target): + return self.async_create_task(target, eager_start=eager_start) + + # This code path is performance sensitive and uses + # if TYPE_CHECKING to avoid the overhead of constructing + # the type used for the cast. For history see: + # https://github.com/home-assistant/core/pull/71960 + if TYPE_CHECKING: + target = cast(Callable[..., Coroutine[Any, Any, _R] | _R], target) + return self.async_add_zha_job(ZHAJob(target), *args, eager_start=eager_start) + + @overload + @callback + def async_add_zha_job( + self, + zhajob: ZHAJob[..., Coroutine[Any, Any, _R]], + *args: Any, + eager_start: bool = False, + background: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @overload + @callback + def async_add_zha_job( + self, + zhajob: ZHAJob[..., Coroutine[Any, Any, _R] | _R], + *args: Any, + eager_start: bool = False, + background: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @callback + def async_add_zha_job( + self, + zhajob: ZHAJob[..., Coroutine[Any, Any, _R] | _R], + *args: Any, + eager_start: bool = False, + background: bool = False, + ) -> asyncio.Future[_R] | None: + """Add a ZHAJob from within the event loop. + + If eager_start is True, coroutine functions will be scheduled eagerly. + If background is True, the task will created as a background task. + + This method must be run in the event loop. + zhajob: ZHAJob to call. + args: parameters for method to call. + """ + task: asyncio.Future[_R] + # This code path is performance sensitive and uses + # if TYPE_CHECKING to avoid the overhead of constructing + # the type used for the cast. For history see: + # https://github.com/home-assistant/core/pull/71960 + if zhajob.job_type is ZHAJobType.Coroutinefunction: + if TYPE_CHECKING: + zhajob.target = cast( + Callable[..., Coroutine[Any, Any, _R]], zhajob.target + ) + # Use loop.create_task + # to avoid the extra function call in asyncio.create_task. + if eager_start: + task = create_eager_task( + zhajob.target(*args), + name=zhajob.name, + loop=self.loop, + ) + if task.done(): + return task + else: + task = self.loop.create_task(zhajob.target(*args), name=zhajob.name) + elif zhajob.job_type is ZHAJobType.Callback: + if TYPE_CHECKING: + zhajob.target = cast(Callable[..., _R], zhajob.target) + self.loop.call_soon(zhajob.target, *args) + return None + else: + if TYPE_CHECKING: + zhajob.target = cast(Callable[..., _R], zhajob.target) + task = self.loop.run_in_executor(None, zhajob.target, *args) + + task_bucket = ( + self._background_tasks if background else self._tracked_completable_tasks + ) + task_bucket.add(task) + task.add_done_callback(task_bucket.remove) + + return task + + def create_task( + self, target: Coroutine[Any, Any, Any], name: str | None = None + ) -> None: + """Add task to the executor pool. + + target: target to call. + """ + self.loop.call_soon_threadsafe( + functools.partial(self.async_create_task, target, name, eager_start=True) + ) + + @callback + def async_create_task( + self, + target: Coroutine[Any, Any, _R], + name: str | None = None, + eager_start: bool = False, + ) -> asyncio.Task[_R]: + """Create a task from within the event loop. + + This method must be run in the event loop. If you are using this in your + integration, use the create task methods on the config entry instead. + + target: target to call. + """ + if eager_start: + task = create_eager_task(target, name=name, loop=self.loop) + if task.done(): + return task + else: + # Use loop.create_task + # to avoid the extra function call in asyncio.create_task. + task = self.loop.create_task(target, name=name) + self._tracked_completable_tasks.add(task) + task.add_done_callback(self._tracked_completable_tasks.remove) + return task + + @callback + def async_create_background_task( + self, + target: Coroutine[Any, Any, _R], + name: str, + eager_start: bool = False, + untracked: bool = False, + ) -> asyncio.Task[_R]: + """Create a task from within the event loop. + + This type of task is for background tasks that usually run for + the lifetime of Home Assistant or an integration's setup. + + A background task is different from a normal task: + + - Will not block startup + - Will be automatically cancelled on shutdown + - Calls to async_block_till_done will not wait for completion + + If you are using this in your integration, use the create task + methods on the config entry instead. + + This method must be run in the event loop. + """ + if eager_start: + task = create_eager_task(target, name=name, loop=self.loop) + if task.done(): + return task + else: + # Use loop.create_task + # to avoid the extra function call in asyncio.create_task. + task = self.loop.create_task(target, name=name) + + task_bucket = ( + self._untracked_background_tasks if untracked else self._background_tasks + ) + task_bucket.add(task) + task.add_done_callback(task_bucket.remove) + return task + + @callback + def async_add_executor_job( + self, target: Callable[..., _T], *args: Any + ) -> asyncio.Future[_T]: + """Add an executor job from within the event loop.""" + task = self.loop.run_in_executor(None, target, *args) + self._tracked_completable_tasks.add(task) + task.add_done_callback(self._tracked_completable_tasks.remove) + + return task + + @callback + def async_add_import_executor_job( + self, target: Callable[..., _T], *args: Any + ) -> asyncio.Future[_T]: + """Add an import executor job from within the event loop.""" + task = self.loop.run_in_executor(self.import_executor, target, *args) + self._tracked_completable_tasks.add(task) + task.add_done_callback(self._tracked_completable_tasks.remove) + return task + + @overload + @callback + def async_run_zha_job( + self, + zhajob: ZHAJob[..., Coroutine[Any, Any, _R]], + *args: Any, + background: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @overload + @callback + def async_run_zha_job( + self, + zhajob: ZHAJob[..., Coroutine[Any, Any, _R] | _R], + *args: Any, + background: bool = False, + ) -> asyncio.Future[_R] | None: ... + + @callback + def async_run_zha_job( + self, + zhajob: ZHAJob[..., Coroutine[Any, Any, _R] | _R], + *args: Any, + background: bool = False, + ) -> asyncio.Future[_R] | None: + """Run a ZHAJob from within the event loop. + + This method must be run in the event loop. + + If background is True, the task will created as a background task. + + zhajob: ZHAJob + args: parameters for method to call. + """ + # This code path is performance sensitive and uses + # if TYPE_CHECKING to avoid the overhead of constructing + # the type used for the cast. For history see: + # https://github.com/home-assistant/core/pull/71960 + if zhajob.job_type is ZHAJobType.Callback: + if TYPE_CHECKING: + zhajob.target = cast(Callable[..., _R], zhajob.target) + zhajob.target(*args) + return None + + return self.async_add_zha_job( + zhajob, *args, eager_start=True, background=background + ) + + @overload + @callback + def async_run_job( + self, target: Callable[..., Coroutine[Any, Any, _R]], *args: Any + ) -> asyncio.Future[_R] | None: ... + + @overload + @callback + def async_run_job( + self, target: Callable[..., Coroutine[Any, Any, _R] | _R], *args: Any + ) -> asyncio.Future[_R] | None: ... + + @overload + @callback + def async_run_job( + self, target: Coroutine[Any, Any, _R], *args: Any + ) -> asyncio.Future[_R] | None: ... + + @callback + def async_run_job( + self, + target: Callable[..., Coroutine[Any, Any, _R] | _R] | Coroutine[Any, Any, _R], + *args: Any, + ) -> asyncio.Future[_R] | None: + """Run a job from within the event loop. + + This method must be run in the event loop. + + target: target to call. + args: parameters for method to call. + """ + if asyncio.iscoroutine(target): + return self.async_create_task(target, eager_start=True) + + # This code path is performance sensitive and uses + # if TYPE_CHECKING to avoid the overhead of constructing + # the type used for the cast. For history see: + # https://github.com/home-assistant/core/pull/71960 + if TYPE_CHECKING: + target = cast(Callable[..., Coroutine[Any, Any, _R] | _R], target) + return self.async_run_zha_job(ZHAJob(target), *args) diff --git a/zha/const.py b/zha/const.py new file mode 100644 index 00000000..c96c47da --- /dev/null +++ b/zha/const.py @@ -0,0 +1,19 @@ +"""Constants for Zigbee Home Automation.""" + +from enum import StrEnum +from typing import Final + +STATE_CHANGED: Final[str] = "state_changed" +EVENT: Final[str] = "event" +EVENT_TYPE: Final[str] = "event_type" + +MESSAGE_TYPE: Final[str] = "message_type" + + +class EventTypes(StrEnum): + """WS event types.""" + + CONTROLLER_EVENT = "controller_event" + PLATFORM_ENTITY_EVENT = "platform_entity_event" + RAW_ZCL_EVENT = "raw_zcl_event" + DEVICE_EVENT = "device_event" diff --git a/zha/debounce.py b/zha/debounce.py new file mode 100644 index 00000000..d93f1cb4 --- /dev/null +++ b/zha/debounce.py @@ -0,0 +1,183 @@ +"""Debounce helper.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from logging import Logger +from typing import TYPE_CHECKING, Generic, TypeVar + +from zha.async_ import ZHAJob + +if TYPE_CHECKING: + from zha.application.gateway import Gateway + +_R_co = TypeVar("_R_co", covariant=True) + + +class Debouncer(Generic[_R_co]): + """Class to rate limit calls to a specific command.""" + + def __init__( + self, + gateway: Gateway, + logger: Logger, + *, + cooldown: float, + immediate: bool, + function: Callable[[], _R_co] | None = None, + background: bool = False, + ) -> None: + """Initialize debounce. + + immediate: indicate if the function needs to be called right away and + wait until executing next invocation. + function: optional and can be instantiated later. + """ + self.gateway = gateway + self.logger = logger + self._function = function + self.cooldown = cooldown + self.immediate = immediate + self._timer_task: asyncio.TimerHandle | None = None + self._execute_at_end_of_timer: bool = False + self._execute_lock = asyncio.Lock() + self._background = background + self._job: ZHAJob[[], _R_co] | None = ( + None + if function is None + else ZHAJob( + function, f"debouncer cooldown={cooldown}, immediate={immediate}" + ) + ) + self._shutdown_requested = False + + @property + def function(self) -> Callable[[], _R_co] | None: + """Return the function being wrapped by the Debouncer.""" + return self._function + + @function.setter + def function(self, function: Callable[[], _R_co]) -> None: + """Update the function being wrapped by the Debouncer.""" + self._function = function + if self._job is None or function != self._job.target: + self._job = ZHAJob( + function, + f"debouncer cooldown={self.cooldown}, immediate={self.immediate}", + ) + + def async_schedule_call(self) -> None: + """Schedule a call to the function.""" + if self._async_schedule_or_call_now(): + self._execute_at_end_of_timer = True + self._on_debounce() + + def _async_schedule_or_call_now(self) -> bool: + """Check if a call should be scheduled. + + Returns True if the function should be called immediately. + + Returns False if there is nothing to do. + """ + if self._shutdown_requested: + self.logger.debug("Debouncer call ignored as shutdown has been requested.") + return False + + if self._timer_task: + if not self._execute_at_end_of_timer: + self._execute_at_end_of_timer = True + + return False + + # Locked means a call is in progress. Any call is good, so abort. + if self._execute_lock.locked(): + return False + + if not self.immediate: + self._execute_at_end_of_timer = True + self._schedule_timer() + return False + + return True + + async def async_call(self) -> None: + """Call the function.""" + if not self._async_schedule_or_call_now(): + return + + async with self._execute_lock: + # Abort if timer got set while we're waiting for the lock. + if self._timer_task: + return + + assert self._job is not None + try: + if task := self.gateway.async_run_zha_job( + self._job, background=self._background + ): + await task + finally: + self._schedule_timer() + + async def _handle_timer_finish(self) -> None: + """Handle a finished timer.""" + assert self._job is not None + + self._execute_at_end_of_timer = False + + # Locked means a call is in progress. Any call is good, so abort. + if self._execute_lock.locked(): + return + + async with self._execute_lock: + # Abort if timer got set while we're waiting for the lock. + if self._timer_task: + return + + try: + if task := self.gateway.async_run_zha_job( + self._job, background=self._background + ): + await task + except Exception: # pylint: disable=broad-except + self.logger.exception("Unexpected exception from %s", self.function) + finally: + # Schedule a new timer to prevent new runs during cooldown + self._schedule_timer() + + def async_shutdown(self) -> None: + """Cancel any scheduled call, and prevent new runs.""" + self._shutdown_requested = True + self.async_cancel() + + def async_cancel(self) -> None: + """Cancel any scheduled call.""" + if self._timer_task: + self._timer_task.cancel() + self._timer_task = None + + self._execute_at_end_of_timer = False + + def _on_debounce(self) -> None: + """Create job task, but only if pending.""" + self._timer_task = None + if not self._execute_at_end_of_timer: + return + self._execute_at_end_of_timer = False + name = f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}" + if not self._background: + self.gateway.async_create_task( + self._handle_timer_finish(), name, eager_start=True + ) + return + self.gateway.async_create_background_task( + self._handle_timer_finish(), name, eager_start=True + ) + + def _schedule_timer(self) -> None: + """Schedule a timer.""" + if not self._shutdown_requested: + self._timer_task = self.gateway.loop.call_later( + self.cooldown, self._on_debounce + ) diff --git a/zha/decorators.py b/zha/decorators.py new file mode 100644 index 00000000..ca61dc53 --- /dev/null +++ b/zha/decorators.py @@ -0,0 +1,108 @@ +"""Decorators for zha.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable, Coroutine +import logging +import random +from typing import Any, TypeVar + +_LOGGER = logging.getLogger(__name__) +_TypeT = TypeVar("_TypeT", bound=type[Any]) + + +class DictRegistry(dict[int | str, _TypeT]): + """Dict Registry of items.""" + + def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + self[name] = cluster_handler + return cluster_handler + + return decorator + + +class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): + """Dict Registry of multiple items per key.""" + + def register( + self, name: int | str, sub_name: int | str | None = None + ) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific and a quirk name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + if name not in self: + self[name] = {} + self[name][sub_name] = cluster_handler + return cluster_handler + + return decorator + + +class SetRegistry(set[int | str]): + """Set Registry of items.""" + + def register(self, name: int | str) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + self.add(name) + return cluster_handler + + return decorator + + +def periodic(refresh_interval: tuple, run_immediately=False) -> Callable: + """Make a method with periodic refresh.""" + + def scheduler(func: Callable) -> Callable[[Any, Any], Coroutine[Any, Any, None]]: + async def wrapper(*args: Any, **kwargs: Any) -> None: + sleep_time = random.randint(*refresh_interval) + method_info = f"[{func.__module__}::{func.__qualname__}]" + setattr(args[0], "__polling_interval", sleep_time) + _LOGGER.debug( + "Sleep time for periodic task: %s is %s seconds", + method_info, + sleep_time, + ) + if not run_immediately: + await asyncio.sleep(sleep_time) + while True: + try: + _LOGGER.debug( + "[%s] executing periodic task %s", + asyncio.current_task(), + method_info, + ) + await func(*args, **kwargs) + except asyncio.CancelledError: + _LOGGER.debug( + "[%s] Periodic task %s cancelled", + asyncio.current_task(), + method_info, + ) + break + except Exception as ex: # pylint: disable=broad-except + _LOGGER.warning( + "[%s] Failed to poll using method %s", + asyncio.current_task(), + method_info, + exc_info=ex, + ) + await asyncio.sleep(sleep_time) + + return wrapper + + return scheduler + + +def callback(func: Callable[..., Any]) -> Callable[..., Any]: + """Annotation to mark method as safe to call from within the event loop.""" + setattr(func, "_zha_callback", True) + return func diff --git a/zha/event.py b/zha/event.py new file mode 100644 index 00000000..143e6a48 --- /dev/null +++ b/zha/event.py @@ -0,0 +1,93 @@ +"""Provide Event base classes for zhaws.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import inspect +import logging +from typing import Any + +_LOGGER = logging.getLogger(__package__) + + +class EventBase: + """Base class for event handling and emitting objects.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize event base.""" + super().__init__(*args, **kwargs) + self._listeners: dict[str, list[Callable]] = {} + self._event_tasks: list[asyncio.Task] = [] + self._golbal_listeners: list[Callable] = [] + + def on_event( # pylint: disable=invalid-name + self, event_name: str, callback: Callable + ) -> Callable: + """Register an event callback.""" + listeners: list = self._listeners.setdefault(event_name, []) + listeners.append(callback) + + def unsubscribe() -> None: + """Unsubscribe listeners.""" + if callback in listeners: + listeners.remove(callback) + + return unsubscribe + + def on_all_events( # pylint: disable=invalid-name + self, callback: Callable + ) -> Callable: + """Register a callback for all events.""" + self._golbal_listeners.append(callback) + + def unsubscribe() -> None: + """Unsubscribe listeners.""" + if callback in self._golbal_listeners: + self._golbal_listeners.remove(callback) + + return unsubscribe + + def once(self, event_name: str, callback: Callable) -> Callable: + """Listen for an event exactly once.""" + + def event_listener(data: dict) -> None: + unsub() + callback(data) + + unsub = self.on_event(event_name, event_listener) + + return unsub + + def emit(self, event_name: str, data=None) -> None: + """Run all callbacks for an event.""" + for listener in [*self._listeners.get(event_name, []), *self._golbal_listeners]: + if inspect.iscoroutinefunction(listener): + if data is None: + task = asyncio.create_task(listener()) + self._event_tasks.append(task) + task.add_done_callback(self._event_tasks.remove) + else: + task = asyncio.create_task(listener(data)) + self._event_tasks.append(task) + task.add_done_callback(self._event_tasks.remove) + elif data is None: + listener() + else: + listener(data) + + def _handle_event_protocol(self, event) -> None: + """Process an event based on event protocol.""" + _LOGGER.debug( + "(%s) handling event protocol for event: %s", self.__class__.__name__, event + ) + handler = getattr(self, f"handle_{event.event.replace(' ', '_')}", None) + if handler is None: + _LOGGER.warning("Received unknown event: %s", event) + return + if inspect.iscoroutinefunction(handler): + task = asyncio.create_task(handler(event)) + self._event_tasks.append(task) + task.add_done_callback(self._event_tasks.remove) + else: + handler(event) diff --git a/zha/exceptions.py b/zha/exceptions.py new file mode 100644 index 00000000..b77264c0 --- /dev/null +++ b/zha/exceptions.py @@ -0,0 +1,5 @@ +"""Exceptions for Zigbee Home Automation.""" + + +class ZHAException(Exception): + """Base ZHA exception.""" diff --git a/zha/mixins.py b/zha/mixins.py new file mode 100644 index 00000000..476ab7b6 --- /dev/null +++ b/zha/mixins.py @@ -0,0 +1,28 @@ +"""Mixin classes for Zigbee Home Automation.""" + +import logging +from typing import Any + + +class LogMixin: + """Log helper.""" + + def log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: + """Log with level.""" + raise NotImplementedError + + def debug(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Debug level log.""" + return self.log(logging.DEBUG, msg, *args, **kwargs) + + def info(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Info level log.""" + return self.log(logging.INFO, msg, *args, **kwargs) + + def warning(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Warning method log.""" + return self.log(logging.WARNING, msg, *args, **kwargs) + + def error(self, msg: str, *args: Any, **kwargs: Any) -> None: + """Error level log.""" + return self.log(logging.ERROR, msg, *args, **kwargs) diff --git a/zha/units.py b/zha/units.py new file mode 100644 index 00000000..02d23bce --- /dev/null +++ b/zha/units.py @@ -0,0 +1,167 @@ +"""Units of measure for Zigbee Home Automation.""" + +from enum import Enum, StrEnum +from typing import Final + + +class UnitOfTemperature(StrEnum): + """Temperature units.""" + + CELSIUS = "°C" + FAHRENHEIT = "°F" + KELVIN = "K" + + +class UnitOfMass(StrEnum): + """Mass units.""" + + GRAMS = "g" + KILOGRAMS = "kg" + MILLIGRAMS = "mg" + MICROGRAMS = "µg" + OUNCES = "oz" + POUNDS = "lb" + STONES = "st" + + +class UnitOfPressure(StrEnum): + """Pressure units.""" + + PA = "Pa" + HPA = "hPa" + KPA = "kPa" + BAR = "bar" + CBAR = "cbar" + MBAR = "mbar" + MMHG = "mmHg" + INHG = "inHg" + PSI = "psi" + + +class UnitOfPower(StrEnum): + """Power units.""" + + WATT = "W" + KILO_WATT = "kW" + BTU_PER_HOUR = "BTU/h" + + +class UnitOfApparentPower(StrEnum): + """Apparent power units.""" + + VOLT_AMPERE = "VA" + + +class UnitOfElectricCurrent(StrEnum): + """Electric current units.""" + + MILLIAMPERE = "mA" + AMPERE = "A" + + +# Electric_potential units +class UnitOfElectricPotential(StrEnum): + """Electric potential units.""" + + MILLIVOLT = "mV" + VOLT = "V" + + +class UnitOfFrequency(StrEnum): + """Frequency units.""" + + HERTZ = "Hz" + KILOHERTZ = "kHz" + MEGAHERTZ = "MHz" + GIGAHERTZ = "GHz" + + +class UnitOfVolumeFlowRate(StrEnum): + """Volume flow rate units.""" + + CUBIC_METERS_PER_HOUR = "m³/h" + CUBIC_FEET_PER_MINUTE = "ft³/min" + LITERS_PER_MINUTE = "L/min" + GALLONS_PER_MINUTE = "gal/min" + + +class UnitOfVolume(StrEnum): + """Volume units.""" + + CUBIC_FEET = "ft³" + CENTUM_CUBIC_FEET = "CCF" + CUBIC_METERS = "m³" + LITERS = "L" + MILLILITERS = "mL" + GALLONS = "gal" + """Assumed to be US gallons in conversion utilities. + + British/Imperial gallons are not yet supported""" + FLUID_OUNCES = "fl. oz." + """Assumed to be US fluid ounces in conversion utilities. + + British/Imperial fluid ounces are not yet supported""" + + +class UnitOfTime(StrEnum): + """Time units.""" + + MICROSECONDS = "μs" + MILLISECONDS = "ms" + SECONDS = "s" + MINUTES = "min" + HOURS = "h" + DAYS = "d" + WEEKS = "w" + MONTHS = "m" + YEARS = "y" + + +class UnitOfEnergy(StrEnum): + """Energy units.""" + + GIGA_JOULE = "GJ" + KILO_WATT_HOUR = "kWh" + MEGA_JOULE = "MJ" + MEGA_WATT_HOUR = "MWh" + WATT_HOUR = "Wh" + + +# Concentration units +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" + +# Signal_strength units +SIGNAL_STRENGTH_DECIBELS: Final = "dB" +SIGNAL_STRENGTH_DECIBELS_MILLIWATT: Final = "dBm" + +# Light units +LIGHT_LUX: Final = "lx" + +# Percentage units +PERCENTAGE: Final[str] = "%" + + +UNITS_OF_MEASURE = { + UnitOfApparentPower.__name__: UnitOfApparentPower, + UnitOfPower.__name__: UnitOfPower, + UnitOfEnergy.__name__: UnitOfEnergy, + UnitOfElectricCurrent.__name__: UnitOfElectricCurrent, + UnitOfElectricPotential.__name__: UnitOfElectricPotential, + UnitOfTemperature.__name__: UnitOfTemperature, + UnitOfTime.__name__: UnitOfTime, + UnitOfFrequency.__name__: UnitOfFrequency, + UnitOfPressure.__name__: UnitOfPressure, + UnitOfVolume.__name__: UnitOfVolume, + UnitOfVolumeFlowRate.__name__: UnitOfVolumeFlowRate, + UnitOfMass.__name__: UnitOfMass, +} + + +def validate_unit(external_unit: Enum) -> Enum: + """Validate and return a unit of measure.""" + return UNITS_OF_MEASURE[type(external_unit).__name__](external_unit.value) diff --git a/zha/zigbee/__init__.py b/zha/zigbee/__init__.py index 755eac3c..6e582eee 100644 --- a/zha/zigbee/__init__.py +++ b/zha/zigbee/__init__.py @@ -1,6 +1 @@ -"""Core module for Zigbee Home Automation.""" - -from .device import ZHADevice -from .gateway import ZHAGateway - -__all__ = ["ZHADevice", "ZHAGateway"] +"""Zigbee module for Zigbee Home Automation.""" diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index d8400880..d8c98d39 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -4,15 +4,12 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterator import contextlib +from dataclasses import dataclass from enum import Enum import functools import logging -from typing import TYPE_CHECKING, Any, ParamSpec, TypedDict +from typing import TYPE_CHECKING, Any, Final, ParamSpec, TypedDict -from homeassistant.const import ATTR_COMMAND -from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_send import zigpy.exceptions import zigpy.util import zigpy.zcl @@ -23,28 +20,34 @@ ZCLAttributeDef, ) -from ..const import ( - ATTR_ARGS, - ATTR_ATTRIBUTE_ID, - ATTR_ATTRIBUTE_NAME, - ATTR_CLUSTER_ID, - ATTR_PARAMS, - ATTR_TYPE, - ATTR_UNIQUE_ID, - ATTR_VALUE, - CLUSTER_HANDLER_ZDO, - REPORT_CONFIG_ATTR_PER_REQ, - SIGNAL_ATTR_UPDATED, +from zha.application.const import ( ZHA_CLUSTER_HANDLER_MSG, ZHA_CLUSTER_HANDLER_MSG_BIND, ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, - ZHA_CLUSTER_HANDLER_MSG_DATA, - ZHA_CLUSTER_HANDLER_READS_PER_REQ, ) -from ..helpers import LogMixin, safe_read +from zha.application.helpers import safe_read +from zha.event import EventBase +from zha.exceptions import ZHAException +from zha.mixins import LogMixin +from zha.zigbee.cluster_handlers.const import ( + ARGS, + ATTRIBUTE_ID, + ATTRIBUTE_NAME, + ATTRIBUTE_VALUE, + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_EVENT, + CLUSTER_HANDLER_ZDO, + CLUSTER_ID, + CLUSTER_READS_PER_REQ, + COMMAND, + PARAMS, + REPORT_CONFIG_ATTR_PER_REQ, + SIGNAL_ATTR_UPDATED, + UNIQUE_ID, +) if TYPE_CHECKING: - from ..endpoint import Endpoint + from zha.zigbee.endpoint import Endpoint _LOGGER = logging.getLogger(__name__) RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3) @@ -58,20 +61,18 @@ @contextlib.contextmanager def wrap_zigpy_exceptions() -> Iterator[None]: - """Wrap zigpy exceptions in `HomeAssistantError` exceptions.""" + """Wrap zigpy exceptions in `ZHAException` exceptions.""" try: yield except TimeoutError as exc: - raise HomeAssistantError( - "Failed to send request: device did not respond" - ) from exc + raise ZHAException("Failed to send request: device did not respond") from exc except zigpy.exceptions.ZigbeeException as exc: message = "Failed to send request" if str(exc): message = f"{message}: {exc}" - raise HomeAssistantError(message) from exc + raise ZHAException(message) from exc def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]: @@ -120,7 +121,44 @@ class ClusterHandlerStatus(Enum): INITIALIZED = 3 -class ClusterHandler(LogMixin): +@dataclass(kw_only=True, frozen=True) +class ClusterAttributeUpdatedEvent: + """Event to signal that a cluster attribute has been updated.""" + + attribute_id: int + attribute_name: str + attribute_value: Any + cluster_handler_unique_id: str + cluster_id: int + event_type: Final[str] = CLUSTER_HANDLER_EVENT + event: Final[str] = CLUSTER_HANDLER_ATTRIBUTE_UPDATED + + +@dataclass(kw_only=True, frozen=True) +class ClusterBindEvent: + """Event generated when the cluster is bound.""" + + cluster_name: str + cluster_id: int + success: bool + cluster_handler_unique_id: str + event_type: Final[str] = ZHA_CLUSTER_HANDLER_MSG + event: Final[str] = ZHA_CLUSTER_HANDLER_MSG_BIND + + +@dataclass(kw_only=True, frozen=True) +class ClusterConfigureReportingEvent: + """Event generates when a cluster configures attribute reporting.""" + + cluster_name: str + cluster_id: int + attributes: dict[str, dict[str, Any]] + cluster_handler_unique_id: str + event_type: Final[str] = ZHA_CLUSTER_HANDLER_MSG + event: Final[str] = ZHA_CLUSTER_HANDLER_MSG_CFG_RPT + + +class ClusterHandler(LogMixin, EventBase): """Base cluster handler for a Zigbee cluster.""" REPORT_CONFIG: tuple[AttrReportConfig, ...] = () @@ -133,23 +171,24 @@ class ClusterHandler(LogMixin): def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize ClusterHandler.""" + super().__init__() self._generic_id = f"cluster_handler_0x{cluster.cluster_id:04x}" self._endpoint: Endpoint = endpoint - self._cluster = cluster - self._id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" - unique_id = endpoint.unique_id.replace("-", ":") - self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" + self._cluster: zigpy.zcl.Cluster = cluster + self._id: str = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + unique_id: str = endpoint.unique_id.replace("-", ":") + self._unique_id: str = f"{unique_id}:0x{cluster.cluster_id:04x}" if not hasattr(self, "_value_attribute") and self.REPORT_CONFIG: attr_def: ZCLAttributeDef = self.cluster.attributes_by_name[ self.REPORT_CONFIG[0]["attr"] ] - self.value_attribute = attr_def.id - self._status = ClusterHandlerStatus.CREATED + self.value_attribute = attr_def.name + self._status: ClusterHandlerStatus = ClusterHandlerStatus.CREATED self._cluster.add_listener(self) - self.data_cache: dict[str, Enum] = {} + self.data_cache: dict[str, Any] = {} @classmethod - def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: + def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: # pylint: disable=unused-argument """Filter the cluster match for specific devices.""" return True @@ -159,17 +198,17 @@ def id(self) -> str: return self._id @property - def generic_id(self): + def generic_id(self) -> str: """Return the generic id for this cluster handler.""" return self._generic_id @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id for this cluster handler.""" return self._unique_id @property - def cluster(self): + def cluster(self) -> zigpy.zcl.Cluster: """Return the zigpy cluster for this cluster handler.""" return self._cluster @@ -179,7 +218,7 @@ def name(self) -> str: return self.cluster.ep_attribute or self._generic_id @property - def status(self): + def status(self) -> ClusterHandlerStatus: """Return the status of the cluster handler.""" return self._status @@ -187,12 +226,11 @@ 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.""" - self._endpoint.async_send_signal(signal, *args) + def emit_propagated_event(self, signal: str, *args: Any) -> None: + """Send a signal through dispatcher.""" + self._endpoint.emit_propagated_event(signal, *args) - async def bind(self): + async def bind(self) -> None: """Bind a zigbee cluster. This also swallows ZigbeeException exceptions that are thrown when @@ -201,17 +239,14 @@ async def bind(self): try: res = await self.cluster.bind() self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) - async_dispatcher_send( - self._endpoint.device.hass, - ZHA_CLUSTER_HANDLER_MSG, - { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - "cluster_name": self.cluster.name, - "cluster_id": self.cluster.cluster_id, - "success": res[0] == 0, - }, - }, + self._endpoint.device.emit( + ZHA_CLUSTER_HANDLER_MSG_BIND, + ClusterBindEvent( + cluster_name=self.cluster.name, + cluster_id=self.cluster.cluster_id, + cluster_handler_unique_id=self.unique_id, + success=res[0] == 0, + ), ) except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( @@ -220,17 +255,14 @@ async def bind(self): str(ex), exc_info=ex, ) - async_dispatcher_send( - self._endpoint.device.hass, - ZHA_CLUSTER_HANDLER_MSG, - { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_BIND, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - "cluster_name": self.cluster.name, - "cluster_id": self.cluster.cluster_id, - "success": False, - }, - }, + self._endpoint.device.emit( + ZHA_CLUSTER_HANDLER_MSG_BIND, + ClusterBindEvent( + cluster_name=self.cluster.name, + cluster_id=self.cluster.cluster_id, + cluster_handler_unique_id=self.unique_id, + success=False, + ), ) async def configure_reporting(self) -> None: @@ -286,17 +318,14 @@ async def configure_reporting(self) -> None: rest[REPORT_CONFIG_ATTR_PER_REQ:], ) - async_dispatcher_send( - self._endpoint.device.hass, - ZHA_CLUSTER_HANDLER_MSG, - { - ATTR_TYPE: ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, - ZHA_CLUSTER_HANDLER_MSG_DATA: { - "cluster_name": self.cluster.name, - "cluster_id": self.cluster.cluster_id, - "attributes": event_data, - }, - }, + self._endpoint.device.emit( + ZHA_CLUSTER_HANDLER_MSG_CFG_RPT, + ClusterConfigureReportingEvent( + cluster_name=self.cluster.name, + cluster_id=self.cluster.cluster_id, + cluster_handler_unique_id=self.unique_id, + attributes=event_data, + ), ) def _configure_reporting_status( @@ -325,18 +354,19 @@ def _configure_reporting_status( res, ) # 2.5.8.1.3 Status Field - # The status field specifies the status of the Configure Reporting operation attempted on this attribute, as detailed in 2.5.7.3. - # Note that attribute status records are not included for successfully configured attributes, in order to save bandwidth. - # In the case of successful configuration of all attributes, only a single attribute status record SHALL be included in the command, - # with the status field set to SUCCESS and the direction and attribute identifier fields omitted. + # The status field specifies the status of the Configure Reporting operation attempted on this attribute, + # as detailed in 2.5.7.3. Note that attribute status records are not included for successfully configured + # attributes, in order to save bandwidth. In the case of successful configuration of all attributes, + # only a single attribute status record SHALL be included in the command, with the status field set to + # SUCCESS and the direction and attribute identifier fields omitted. for attr in attrs: event_data[attr]["status"] = Status.SUCCESS.name return for record in res: - event_data[self.cluster.find_attribute(record.attrid).name][ - "status" - ] = record.status.name + event_data[self.cluster.find_attribute(record.attrid).name]["status"] = ( + record.status.name + ) failed = [ self.cluster.find_attribute(record.attrid).name for record in res @@ -416,11 +446,9 @@ async def async_initialize(self, from_cache: bool) -> None: self.debug("finished cluster handler initialization") self._status = ClusterHandlerStatus.INITIALIZED - @callback - def cluster_command(self, tsn, command_id, args): + def cluster_command(self, tsn, command_id, args) -> None: """Handle commands received to this cluster.""" - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" attr_name = self._get_attribute_name(attrid) @@ -431,20 +459,26 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: attr_name, value, ) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - attrid, - attr_name, - value, + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=attr_name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) - @callback - def zdo_command(self, *args, **kwargs): + def zdo_command(self, *args, **kwargs) -> None: """Handle ZDO commands on this cluster.""" - @callback def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None: - """Relay events to hass.""" + """Compatibility method for emitting ZHA events from quirks until they are updated.""" + self.emit_zha_event(command, arg) + + def emit_zha_event(self, command: str, arg: list | dict | CommandSchema) -> None: + """Relay events to listeners.""" args: list | dict if isinstance(arg, CommandSchema): @@ -455,20 +489,20 @@ def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None args = arg params = {} else: - raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}") + raise TypeError(f"Unexpected emit_zha_event {command!r} argument: {arg!r}") - self._endpoint.send_event( + self._endpoint.emit_zha_event( { - ATTR_UNIQUE_ID: self.unique_id, - ATTR_CLUSTER_ID: self.cluster.cluster_id, - ATTR_COMMAND: command, + UNIQUE_ID: self.unique_id, + CLUSTER_ID: self.cluster.cluster_id, + COMMAND: command, # Maintain backwards compatibility with the old zigpy response format - ATTR_ARGS: args, - ATTR_PARAMS: params, + ARGS: args, + PARAMS: params, } ) - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest state from cluster.""" def _get_attribute_name(self, attrid: int) -> str | int: @@ -477,7 +511,7 @@ def _get_attribute_name(self, attrid: int) -> str | int: return self.cluster.attributes[attrid].name - async def get_attribute_value(self, attribute, from_cache=True): + async def get_attribute_value(self, attribute, from_cache=True) -> Any: """Get the value for an attribute.""" manufacturer = None manufacturer_code = self._endpoint.device.manufacturer_code @@ -504,8 +538,8 @@ async def _get_attributes( manufacturer_code = self._endpoint.device.manufacturer_code if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: manufacturer = manufacturer_code - chunk = attributes[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] - rest = attributes[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] + chunk = attributes[:CLUSTER_READS_PER_REQ] + rest = attributes[CLUSTER_READS_PER_REQ:] result = {} while chunk: try: @@ -526,8 +560,8 @@ async def _get_attributes( ) if raise_exceptions: raise - chunk = rest[:ZHA_CLUSTER_HANDLER_READS_PER_REQ] - rest = rest[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] + chunk = rest[:CLUSTER_READS_PER_REQ] + rest = rest[CLUSTER_READS_PER_REQ:] return result get_attributes = functools.partialmethod(_get_attributes, False) @@ -538,7 +572,6 @@ async def write_attributes_safe( """Wrap `write_attributes` to throw an exception on attribute write failure.""" res = await self.write_attributes(attributes, manufacturer=manufacturer) - for record in res[0]: if record.status != Status.SUCCESS: try: @@ -548,11 +581,33 @@ async def write_attributes_safe( name = f"0x{record.attrid:04x}" value = "unknown" - raise HomeAssistantError( + raise ZHAException( f"Failed to write attribute {name}={value}: {record.status}", ) - def log(self, level, msg, *args, **kwargs): + def to_json(self) -> dict: + """Return JSON representation of this cluster handler.""" + json = { + "class_name": self.__class__.__name__, + "generic_id": self._generic_id, + "endpoint_id": self._endpoint.id, + "cluster": { + "id": self._cluster.cluster_id, + "name": self._cluster.name, + "type": "client" if self._cluster.is_client else "server", + "commands": self._cluster.commands, + }, + "id": self._id, + "unique_id": self._unique_id, + "status": self._status.name, + } + + if hasattr(self, "value_attribute"): + json["value_attribute"] = self.value_attribute + + return json + + def log(self, level, msg, *args, **kwargs) -> None: """Log a message.""" msg = f"[%s:%s]: {msg}" args = (self._endpoint.device.nwk, self._id) + args @@ -600,15 +655,13 @@ def status(self): """Return the status of the cluster handler.""" return self._status - @callback def device_announce(self, zigpy_device): """Device announce handler.""" - @callback def permit_duration(self, duration): """Permit handler.""" - async def async_initialize(self, from_cache): + async def async_initialize(self, from_cache): # pylint: disable=unused-argument """Initialize cluster handler.""" self._status = ClusterHandlerStatus.INITIALIZED @@ -626,7 +679,6 @@ def log(self, level, msg, *args, **kwargs): class ClientClusterHandler(ClusterHandler): """ClusterHandler for Zigbee client (output) clusters.""" - @callback def attribute_updated(self, attrid: int, value: Any, timestamp: Any) -> None: """Handle an attribute updated on this cluster.""" super().attribute_updated(attrid, value, timestamp) @@ -636,20 +688,19 @@ def attribute_updated(self, attrid: int, value: Any, timestamp: Any) -> None: except KeyError: attr_name = "Unknown" - self.zha_send_event( + self.emit_zha_event( SIGNAL_ATTR_UPDATED, { - ATTR_ATTRIBUTE_ID: attrid, - ATTR_ATTRIBUTE_NAME: attr_name, - ATTR_VALUE: value, + ATTRIBUTE_ID: attrid, + ATTRIBUTE_NAME: attr_name, + ATTRIBUTE_VALUE: value, }, ) - @callback def cluster_command(self, tsn, command_id, args): """Handle a cluster command received on this cluster.""" if ( self._cluster.server_commands is not None and self._cluster.server_commands.get(command_id) is not None ): - self.zha_send_event(self._cluster.server_commands[command_id].name, args) + self.emit_zha_event(self._cluster.server_commands[command_id].name, args) diff --git a/zha/zigbee/cluster_handlers/closures.py b/zha/zigbee/cluster_handlers/closures.py index f2b7654a..f5c4b627 100644 --- a/zha/zigbee/cluster_handlers/closures.py +++ b/zha/zigbee/cluster_handlers/closures.py @@ -4,20 +4,27 @@ from typing import Any -from homeassistant.core import callback import zigpy.types as t from zigpy.zcl.clusters.closures import ConfigStatus, DoorLock, Shade, WindowCovering -from .. import registries -from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED -from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +from zha.zigbee.cluster_handlers import ( + AttrReportConfig, + ClientClusterHandler, + ClusterAttributeUpdatedEvent, + ClusterHandler, + registries, +) +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + REPORT_CONFIG_IMMEDIATE, +) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DoorLock.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(DoorLock.cluster_id) class DoorLockClusterHandler(ClusterHandler): """Door lock cluster handler.""" - _value_attribute = 0 + _value_attribute: str = DoorLock.AttributeDefs.lock_state.name REPORT_CONFIG = ( AttrReportConfig( attr=DoorLock.AttributeDefs.lock_state.name, @@ -31,14 +38,17 @@ async def async_update(self): DoorLock.AttributeDefs.lock_state.name, from_cache=True ) if result is not None: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - DoorLock.AttributeDefs.lock_state.id, - DoorLock.AttributeDefs.lock_state.name, - result, + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=DoorLock.AttributeDefs.lock_state.id, + attribute_name=DoorLock.AttributeDefs.lock_state.name, + attribute_value=result, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) - @callback def cluster_command(self, tsn, command_id, args): """Handle a cluster command received on this cluster.""" @@ -51,7 +61,7 @@ def cluster_command(self, tsn, command_id, args): command_name = self._cluster.client_commands[command_id].name if command_name == DoorLock.ClientCommandDefs.operation_event_notification.name: - self.zha_send_event( + self.emit_zha_event( command_name, { "source": args[0].name, @@ -60,16 +70,22 @@ def cluster_command(self, tsn, command_id, args): }, ) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from lock cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - if attrid == self._value_attribute: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + if attr_name == self._value_attribute: + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=attr_name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) async def async_set_user_code(self, code_slot: int, user_code: str) -> None: @@ -120,7 +136,7 @@ async def async_get_user_type(self, code_slot: int) -> str: return result -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Shade.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Shade.cluster_id) class ShadeClusterHandler(ClusterHandler): """Shade cluster handler.""" @@ -131,7 +147,7 @@ class WindowCoveringClientClusterHandler(ClientClusterHandler): @registries.BINDABLE_CLUSTERS.register(WindowCovering.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(WindowCovering.cluster_id) class WindowCoveringClusterHandler(ClusterHandler): """Window cluster handler.""" @@ -178,8 +194,7 @@ async def async_update(self): is not None ): # the 100 - value is because we need to invert the value before giving it to the entity - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.attribute_updated( WindowCovering.AttributeDefs.current_position_lift_percentage.id, WindowCovering.AttributeDefs.current_position_lift_percentage.name, 100 @@ -195,8 +210,7 @@ async def async_update(self): is not None ): # the 100 - value is because we need to invert the value before giving it to the entity - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.attribute_updated( WindowCovering.AttributeDefs.current_position_tilt_percentage.id, WindowCovering.AttributeDefs.current_position_tilt_percentage.name, 100 diff --git a/zha/zigbee/cluster_handlers/const.py b/zha/zigbee/cluster_handlers/const.py new file mode 100644 index 00000000..119a3600 --- /dev/null +++ b/zha/zigbee/cluster_handlers/const.py @@ -0,0 +1,110 @@ +"""Constants for the cluster_handlers module.""" + +from typing import Final + +REPORT_CONFIG_ATTR_PER_REQ: Final[int] = 3 +REPORT_CONFIG_MAX_INT: Final[int] = 900 +REPORT_CONFIG_MAX_INT_BATTERY_SAVE: Final[int] = 10800 +REPORT_CONFIG_MIN_INT: Final[int] = 30 +REPORT_CONFIG_MIN_INT_ASAP: Final[int] = 1 +REPORT_CONFIG_MIN_INT_IMMEDIATE: Final[int] = 0 +REPORT_CONFIG_MIN_INT_OP: Final[int] = 5 +REPORT_CONFIG_MIN_INT_BATTERY_SAVE: Final[int] = 3600 +REPORT_CONFIG_RPT_CHANGE: Final[int] = 1 +REPORT_CONFIG_DEFAULT: tuple[int, int, int] = ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_ASAP: tuple[int, int, int] = ( + REPORT_CONFIG_MIN_INT_ASAP, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_BATTERY_SAVE: tuple[int, int, int] = ( + REPORT_CONFIG_MIN_INT_BATTERY_SAVE, + REPORT_CONFIG_MAX_INT_BATTERY_SAVE, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_IMMEDIATE: tuple[int, int, int] = ( + REPORT_CONFIG_MIN_INT_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +REPORT_CONFIG_OP: tuple[int, int, int] = ( + REPORT_CONFIG_MIN_INT_OP, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, +) +CLUSTER_READS_PER_REQ: Final[int] = 5 + +CLUSTER_HANDLER_ACCELEROMETER: Final[str] = "accelerometer" +CLUSTER_HANDLER_BINARY_INPUT: Final[str] = "binary_input" +CLUSTER_HANDLER_ANALOG_INPUT: Final[str] = "analog_input" +CLUSTER_HANDLER_ANALOG_OUTPUT: Final[str] = "analog_output" +CLUSTER_HANDLER_ATTRIBUTE: Final[str] = "attribute" +CLUSTER_HANDLER_BASIC: Final[str] = "basic" +CLUSTER_HANDLER_COLOR: Final[str] = "light_color" +CLUSTER_HANDLER_COVER: Final[str] = "window_covering" +CLUSTER_HANDLER_DEVICE_TEMPERATURE: Final[str] = "device_temperature" +CLUSTER_HANDLER_DOORLOCK: Final[str] = "door_lock" +CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT: Final[str] = "electrical_measurement" +CLUSTER_HANDLER_EVENT_RELAY: Final[str] = "event_relay" +CLUSTER_HANDLER_FAN: Final[str] = "fan" +CLUSTER_HANDLER_HUMIDITY: Final[str] = "humidity" +CLUSTER_HANDLER_HUE_OCCUPANCY: Final[str] = "philips_occupancy" +CLUSTER_HANDLER_SOIL_MOISTURE: Final[str] = "soil_moisture" +CLUSTER_HANDLER_LEAF_WETNESS: Final[str] = "leaf_wetness" +CLUSTER_HANDLER_IAS_ACE: Final[str] = "ias_ace" +CLUSTER_HANDLER_IAS_WD: Final[str] = "ias_wd" +CLUSTER_HANDLER_IDENTIFY: Final[str] = "identify" +CLUSTER_HANDLER_ILLUMINANCE: Final[str] = "illuminance" +CLUSTER_HANDLER_LEVEL: Final[str] = "level" +CLUSTER_HANDLER_MULTISTATE_INPUT: Final[str] = "multistate_input" +CLUSTER_HANDLER_OCCUPANCY: Final[str] = "occupancy" +CLUSTER_HANDLER_ON_OFF: Final[str] = "on_off" +CLUSTER_HANDLER_OTA: Final[str] = "ota" +CLUSTER_HANDLER_POWER_CONFIGURATION: Final[str] = "power" +CLUSTER_HANDLER_PRESSURE: Final[str] = "pressure" +CLUSTER_HANDLER_SHADE: Final[str] = "shade" +CLUSTER_HANDLER_SMARTENERGY_METERING: Final[str] = "smartenergy_metering" +CLUSTER_HANDLER_TEMPERATURE: Final[str] = "temperature" +CLUSTER_HANDLER_THERMOSTAT: Final[str] = "thermostat" +CLUSTER_HANDLER_ZDO: Final[str] = "zdo" +CLUSTER_HANDLER_ZONE: Final[str] = "ias_zone" +ZONE: Final[str] = CLUSTER_HANDLER_ZONE +CLUSTER_HANDLER_INOVELLI = "inovelli_vzm31sn_cluster" + +AQARA_OPPLE_CLUSTER: Final[int] = 0xFCC0 +IKEA_AIR_PURIFIER_CLUSTER: Final[int] = 0xFC7D +IKEA_REMOTE_CLUSTER: Final[int] = 0xFC80 +INOVELLI_CLUSTER: Final[int] = 0xFC31 +OSRAM_BUTTON_CLUSTER: Final[int] = 0xFD00 +PHILLIPS_REMOTE_CLUSTER: Final[int] = 0xFC00 +SMARTTHINGS_ACCELERATION_CLUSTER: Final[int] = 0xFC02 +SMARTTHINGS_HUMIDITY_CLUSTER: Final[int] = 0xFC45 +SONOFF_CLUSTER: Final[int] = 0xFC11 +TUYA_MANUFACTURER_CLUSTER: Final[int] = 0xEF00 +VOC_LEVEL_CLUSTER: Final[int] = 0x042E + +CLUSTER_HANDLER_EVENT: Final[str] = "cluster_handler_event" +CLUSTER_HANDLER_ATTRIBUTE_UPDATED: Final[str] = "cluster_handler_attribute_updated" +CLUSTER_HANDLER_STATE_CHANGED: Final[str] = "cluster_handler_state_changed" + +ATTRIBUTE_ID: Final[str] = "attribute_id" +ATTRIBUTE_NAME: Final[str] = "attribute_name" +ATTRIBUTE_VALUE: Final[str] = "attribute_value" + +UNIQUE_ID: Final[str] = "unique_id" +CLUSTER_ID: Final[str] = "cluster_id" +COMMAND: Final[str] = "command" +ARGS: Final[str] = "args" +PARAMS: Final[str] = "params" + +SIGNAL_ATTR_UPDATED: Final[str] = "attribute_updated" +SIGNAL_MOVE_LEVEL: Final[str] = "move_level" +SIGNAL_REMOVE: Final[str] = "remove" +SIGNAL_SET_LEVEL: Final[str] = "set_level" +SIGNAL_STATE_ATTR: Final[str] = "update_state_attribute" +SIGNAL_UPDATE_DEVICE: Final[str] = "{}_zha_update_device" +UNKNOWN: Final[str] = "unknown" diff --git a/zha/zigbee/cluster_handlers/general.py b/zha/zigbee/cluster_handlers/general.py index 3245f8f5..b1f7e31b 100644 --- a/zha/zigbee/cluster_handlers/general.py +++ b/zha/zigbee/cluster_handlers/general.py @@ -2,12 +2,11 @@ from __future__ import annotations +import asyncio from collections.abc import Coroutine -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final -from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.event import async_call_later from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF import zigpy.exceptions import zigpy.types as t @@ -44,37 +43,48 @@ ) from zigpy.zcl.foundation import Status -from .. import registries -from ..const import ( +from zha.exceptions import ZHAException +from zha.zigbee.cluster_handlers import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + AttrReportConfig, + ClientClusterHandler, + ClusterAttributeUpdatedEvent, + ClusterHandler, + parse_and_log_command, + registries, +) +from zha.zigbee.cluster_handlers.const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_BATTERY_SAVE, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, - SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from . import ( - AttrReportConfig, - ClientClusterHandler, - ClusterHandler, - parse_and_log_command, -) -from .helpers import is_hue_motion_sensor +from zha.zigbee.cluster_handlers.helpers import is_hue_motion_sensor if TYPE_CHECKING: - from ..endpoint import Endpoint + from zha.zigbee.endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Alarms.cluster_id) +@dataclass(frozen=True, kw_only=True) +class LevelChangeEvent: + """Event to signal that a cluster attribute has been updated.""" + + level: int + event: str + event_type: Final[str] = "cluster_handler_event" + + +@registries.CLUSTER_HANDLER_REGISTRY.register(Alarms.cluster_id) class AlarmsClusterHandler(ClusterHandler): """Alarms cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInput.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogInput.cluster_id) class AnalogInputClusterHandler(ClusterHandler): """Analog Input cluster handler.""" @@ -87,7 +97,7 @@ class AnalogInputClusterHandler(ClusterHandler): @registries.BINDABLE_CLUSTERS.register(AnalogOutput.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutput.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogOutput.cluster_id) class AnalogOutputClusterHandler(ClusterHandler): """Analog Output cluster handler.""" @@ -153,8 +163,14 @@ async def async_set_present_value(self, value: float) -> None: {AnalogOutput.AttributeDefs.present_value.name: value} ) + async def async_update(self): + """Update cluster value attribute.""" + await self.get_attribute_value( + AnalogOutput.AttributeDefs.present_value.name, from_cache=False + ) + -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValue.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogValue.cluster_id) class AnalogValueClusterHandler(ClusterHandler): """Analog Value cluster handler.""" @@ -166,13 +182,13 @@ class AnalogValueClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceControl.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(ApplianceControl.cluster_id) class ApplianceControlClusterHandler(ClusterHandler): """Appliance Control cluster handler.""" @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(Basic.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Basic.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Basic.cluster_id) class BasicClusterHandler(ClusterHandler): """Cluster handler to interact with the basic cluster.""" @@ -207,7 +223,7 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: self.ZCL_INIT_ATTRS["power_source"] = True -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInput.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryInput.cluster_id) class BinaryInputClusterHandler(ClusterHandler): """Binary Input cluster handler.""" @@ -219,7 +235,7 @@ class BinaryInputClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutput.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryOutput.cluster_id) class BinaryOutputClusterHandler(ClusterHandler): """Binary Output cluster handler.""" @@ -231,7 +247,7 @@ class BinaryOutputClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValue.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryValue.cluster_id) class BinaryValueClusterHandler(ClusterHandler): """Binary Value cluster handler.""" @@ -243,12 +259,12 @@ class BinaryValueClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Commissioning.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Commissioning.cluster_id) class CommissioningClusterHandler(ClusterHandler): """Commissioning cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceTemperature.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(DeviceTemperature.cluster_id) class DeviceTemperatureClusterHandler(ClusterHandler): """Device Temperature cluster handler.""" @@ -260,33 +276,32 @@ class DeviceTemperatureClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GreenPowerProxy.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(GreenPowerProxy.cluster_id) class GreenPowerProxyClusterHandler(ClusterHandler): """Green Power Proxy cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Groups.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Groups.cluster_id) class GroupsClusterHandler(ClusterHandler): """Groups cluster handler.""" BIND: bool = False -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Identify.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Identify.cluster_id) class IdentifyClusterHandler(ClusterHandler): """Identify cluster handler.""" BIND: bool = False - @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) if cmd == Identify.ServerCommandDefs.trigger_effect.name: - self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) + self.emit_propagated_event(f"{self.unique_id}_{cmd}", args[0]) @registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) @@ -295,7 +310,7 @@ class LevelControlClientClusterHandler(ClientClusterHandler): @registries.BINDABLE_CLUSTERS.register(LevelControl.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(LevelControl.cluster_id) class LevelControlClusterHandler(ClusterHandler): """Cluster handler for the LevelControl Zigbee cluster.""" @@ -320,7 +335,6 @@ def current_level(self) -> int | None: """Return cached value of the current_level attribute.""" return self.cluster.get(LevelControl.AttributeDefs.current_level.name) - @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) @@ -348,7 +362,6 @@ def cluster_command(self, tsn, command_id, args): SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1] ) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" self.debug("received attribute: %s update with value: %s", attrid, value) @@ -357,10 +370,16 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: def dispatch_level_change(self, command, level): """Dispatch level change.""" - self.async_send_signal(f"{self.unique_id}_{command}", level) + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + LevelChangeEvent( + level=level, + event=f"cluster_handler_{command}", + ), + ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInput.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateInput.cluster_id) class MultistateInputClusterHandler(ClusterHandler): """Multistate Input cluster handler.""" @@ -372,7 +391,7 @@ class MultistateInputClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutput.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateOutput.cluster_id) class MultistateOutputClusterHandler(ClusterHandler): """Multistate Output cluster handler.""" @@ -384,7 +403,7 @@ class MultistateOutputClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValue.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateValue.cluster_id) class MultistateValueClusterHandler(ClusterHandler): """Multistate Value cluster handler.""" @@ -402,7 +421,7 @@ class OnOffClientClusterHandler(ClientClusterHandler): @registries.BINDABLE_CLUSTERS.register(OnOff.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(OnOff.cluster_id) class OnOffClusterHandler(ClusterHandler): """Cluster handler for the OnOff Zigbee cluster.""" @@ -418,7 +437,7 @@ class OnOffClusterHandler(ClusterHandler): def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: """Initialize OnOffClusterHandler.""" super().__init__(cluster, endpoint) - self._off_listener = None + self._off_listener: asyncio.TimerHandle | None = None if endpoint.device.quirk_id == TUYA_PLUG_ONOFF: self.ZCL_INIT_ATTRS = self.ZCL_INIT_ATTRS.copy() @@ -444,17 +463,16 @@ async def turn_on(self) -> None: """Turn the on off cluster on.""" result = await self.on() if result[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to turn on: {result[1]}") + raise ZHAException(f"Failed to turn on: {result[1]}") self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.true) async def turn_off(self) -> None: """Turn the on off cluster off.""" result = await self.off() if result[1] is not Status.SUCCESS: - raise HomeAssistantError(f"Failed to turn off: {result[1]}") + raise ZHAException(f"Failed to turn off: {result[1]}") self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) - @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" cmd = parse_and_log_command(self, tsn, command_id, args) @@ -475,13 +493,13 @@ def cluster_command(self, tsn, command_id, args): # 0 is always accept 1 is only accept when already on if should_accept == 0 or (should_accept == 1 and bool(self.on_off)): if self._off_listener is not None: - self._off_listener() + self._off_listener.cancel() self._off_listener = None self.cluster.update_attribute( OnOff.AttributeDefs.on_off.id, t.Bool.true ) if on_time > 0: - self._off_listener = async_call_later( + self._off_listener = asyncio.get_running_loop().call_later( self._endpoint.device.hass, (on_time / 10), # value is in 10ths of a second self.set_to_off, @@ -491,21 +509,23 @@ def cluster_command(self, tsn, command_id, args): OnOff.AttributeDefs.on_off.id, not bool(self.on_off) ) - @callback def set_to_off(self, *_): """Set the state to off.""" self._off_listener = None self.cluster.update_attribute(OnOff.AttributeDefs.on_off.id, t.Bool.false) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" if attrid == OnOff.AttributeDefs.on_off.id: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - attrid, - OnOff.AttributeDefs.on_off.name, - value, + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=OnOff.AttributeDefs.on_off.name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) async def async_update(self): @@ -515,17 +535,16 @@ async def async_update(self): from_cache = not self._endpoint.device.is_mains_powered self.debug("attempting to update onoff state - from cache: %s", from_cache) await self.get_attribute_value( - OnOff.AttributeDefs.on_off.id, from_cache=from_cache + OnOff.AttributeDefs.on_off.name, from_cache=from_cache ) - await super().async_update() -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OnOffConfiguration.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(OnOffConfiguration.cluster_id) class OnOffConfigurationClusterHandler(ClusterHandler): """OnOff Configuration cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) class OtaClusterHandler(ClusterHandler): """OTA cluster handler.""" @@ -557,7 +576,6 @@ def current_file_version(self) -> int | None: """Return cached value of current_file_version attribute.""" return self.cluster.get(Ota.AttributeDefs.current_file_version.name) - @callback def cluster_command( self, tsn: int, command_id: int, args: list[Any] | None ) -> None: @@ -575,18 +593,18 @@ def cluster_command( self.cluster.update_attribute( Ota.AttributeDefs.current_file_version.id, current_file_version ) - self.async_send_signal( + self.emit_propagated_event( SIGNAL_UPDATE_DEVICE.format(signal_id), current_file_version ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) class PartitionClusterHandler(ClusterHandler): """Partition cluster handler.""" @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(PollControl.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PollControl.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(PollControl.cluster_id) class PollControlClusterHandler(ClusterHandler): """Poll Control cluster handler.""" @@ -603,7 +621,6 @@ async def async_configure_cluster_handler_specific(self) -> None: {PollControl.AttributeDefs.checkin_interval.name: self.CHECKIN_INTERVAL} ) - @callback def cluster_command( self, tsn: int, command_id: int, args: list[Any] | None ) -> None: @@ -614,7 +631,7 @@ def cluster_command( cmd_name = command_id self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args) - self.zha_send_event(cmd_name, args) + self.emit_zha_event(cmd_name, args) if cmd_name == PollControl.ClientCommandDefs.checkin.name: self.cluster.create_catching_task(self.check_in_response(tsn)) @@ -625,13 +642,12 @@ async def check_in_response(self, tsn: int) -> None: await self.set_long_poll_interval(self.LONG_POLL) await self.fast_poll_stop() - @callback def skip_manufacturer_id(self, manufacturer_code: int) -> None: """Block a specific manufacturer id from changing default polling.""" self._IGNORED_MANUFACTURER_ID.add(manufacturer_code) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerConfiguration.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(PowerConfiguration.cluster_id) class PowerConfigurationClusterHandler(ClusterHandler): """Cluster handler for the zigbee power configuration cluster.""" @@ -657,12 +673,12 @@ def async_initialize_cluster_handler_specific(self, from_cache: bool) -> Corouti ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PowerProfile.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(PowerProfile.cluster_id) class PowerProfileClusterHandler(ClusterHandler): """Power Profile cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RSSILocation.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(RSSILocation.cluster_id) class RSSILocationClusterHandler(ClusterHandler): """RSSI Location cluster handler.""" @@ -672,11 +688,11 @@ class ScenesClientClusterHandler(ClientClusterHandler): """Scenes cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Scenes.cluster_id) class ScenesClusterHandler(ClusterHandler): """Scenes cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Time.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Time.cluster_id) class TimeClusterHandler(ClusterHandler): """Time cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/helpers.py b/zha/zigbee/cluster_handlers/helpers.py index 46557bf2..21ec8b5e 100644 --- a/zha/zigbee/cluster_handlers/helpers.py +++ b/zha/zigbee/cluster_handlers/helpers.py @@ -1,6 +1,6 @@ """Helpers for use with ZHA Zigbee cluster handlers.""" -from . import ClusterHandler +from zha.zigbee.cluster_handlers import ClusterHandler def is_hue_motion_sensor(cluster_handler: ClusterHandler) -> bool: diff --git a/zha/zigbee/cluster_handlers/homeautomation.py b/zha/zigbee/cluster_handlers/homeautomation.py index b287cb98..57067c9d 100644 --- a/zha/zigbee/cluster_handlers/homeautomation.py +++ b/zha/zigbee/cluster_handlers/homeautomation.py @@ -13,37 +13,35 @@ MeterIdentification, ) -from .. import registries -from ..const import ( +from zha.zigbee.cluster_handlers import AttrReportConfig, ClusterHandler, registries +from zha.zigbee.cluster_handlers.const import ( CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP, - SIGNAL_ATTR_UPDATED, ) -from . import AttrReportConfig, ClusterHandler -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceEventAlerts.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(ApplianceEventAlerts.cluster_id) class ApplianceEventAlertsClusterHandler(ClusterHandler): """Appliance Event Alerts cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceIdentification.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(ApplianceIdentification.cluster_id) class ApplianceIdentificationClusterHandler(ClusterHandler): """Appliance Identification cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ApplianceStatistics.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(ApplianceStatistics.cluster_id) class ApplianceStatisticsClusterHandler(ClusterHandler): """Appliance Statistics cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Diagnostic.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Diagnostic.cluster_id) class DiagnosticClusterHandler(ClusterHandler): """Diagnostic cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(ElectricalMeasurement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(ElectricalMeasurement.cluster_id) class ElectricalMeasurementClusterHandler(ClusterHandler): """Cluster handler that polls active power level.""" @@ -128,8 +126,7 @@ async def async_update(self): result = await self.get_attributes(attrs, from_cache=False, only_cache=False) if result: for attr, value in result.items(): - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.attribute_updated( self.cluster.find_attribute(attr).id, attr, value, @@ -231,6 +228,6 @@ def measurement_type(self) -> str | None: ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MeterIdentification.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MeterIdentification.cluster_id) class MeterIdentificationClusterHandler(ClusterHandler): """Metering Identification cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/hvac.py b/zha/zigbee/cluster_handlers/hvac.py index a0d66a92..450d1461 100644 --- a/zha/zigbee/cluster_handlers/hvac.py +++ b/zha/zigbee/cluster_handlers/hvac.py @@ -8,7 +8,6 @@ from typing import Any -from homeassistant.core import callback from zigpy.zcl.clusters.hvac import ( Dehumidification, Fan, @@ -17,30 +16,34 @@ UserInterface, ) -from .. import registries -from ..const import ( +from zha.zigbee.cluster_handlers import ( + AttrReportConfig, + ClusterAttributeUpdatedEvent, + ClusterHandler, + registries, +) +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_OP, - SIGNAL_ATTR_UPDATED, ) -from . import AttrReportConfig, ClusterHandler REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Dehumidification.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Dehumidification.cluster_id) class DehumidificationClusterHandler(ClusterHandler): """Dehumidification cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Fan.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Fan.cluster_id) class FanClusterHandler(ClusterHandler): """Fan cluster handler.""" - _value_attribute = 0 + _value_attribute: str = Fan.AttributeDefs.fan_mode.name REPORT_CONFIG = ( AttrReportConfig(attr=Fan.AttributeDefs.fan_mode.name, config=REPORT_CONFIG_OP), @@ -67,7 +70,6 @@ async def async_update(self) -> None: Fan.AttributeDefs.fan_mode.name, from_cache=False ) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from fan cluster.""" attr_name = self._get_attribute_name(attrid) @@ -75,17 +77,24 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attr_name == "fan_mode": - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=attr_name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Pump.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Pump.cluster_id) class PumpClusterHandler(ClusterHandler): """Pump cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Thermostat.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Thermostat.cluster_id) class ThermostatClusterHandler(ClusterHandler): """Thermostat cluster handler.""" @@ -283,18 +292,21 @@ def unoccupied_heating_setpoint(self) -> int | None: Thermostat.AttributeDefs.unoccupied_heating_setpoint.name ) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update cluster.""" attr_name = self._get_attribute_name(attrid) self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - attrid, - attr_name, - value, + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=attr_name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) async def async_set_operation_mode(self, mode) -> bool: @@ -339,7 +351,7 @@ async def get_occupancy(self) -> bool | None: return bool(self.occupancy) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(UserInterface.cluster_id) class UserInterfaceClusterHandler(ClusterHandler): """User interface (thermostat) cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/lighting.py b/zha/zigbee/cluster_handlers/lighting.py index 6caa150c..e9f034f0 100644 --- a/zha/zigbee/cluster_handlers/lighting.py +++ b/zha/zigbee/cluster_handlers/lighting.py @@ -2,15 +2,20 @@ from __future__ import annotations -from homeassistant.backports.functools import cached_property +from functools import cached_property + from zigpy.zcl.clusters.lighting import Ballast, Color -from .. import registries -from ..const import REPORT_CONFIG_DEFAULT -from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +from zha.zigbee.cluster_handlers import ( + AttrReportConfig, + ClientClusterHandler, + ClusterHandler, + registries, +) +from zha.zigbee.cluster_handlers.const import REPORT_CONFIG_DEFAULT -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Ballast.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Ballast.cluster_id) class BallastClusterHandler(ClusterHandler): """Ballast cluster handler.""" @@ -21,7 +26,7 @@ class ColorClientClusterHandler(ClientClusterHandler): @registries.BINDABLE_CLUSTERS.register(Color.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Color.cluster_id) class ColorClusterHandler(ClusterHandler): """Color cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/lightlink.py b/zha/zigbee/cluster_handlers/lightlink.py index 85ec6905..4322b789 100644 --- a/zha/zigbee/cluster_handlers/lightlink.py +++ b/zha/zigbee/cluster_handlers/lightlink.py @@ -4,12 +4,11 @@ from zigpy.zcl.clusters.lightlink import LightLink from zigpy.zcl.foundation import GENERAL_COMMANDS, GeneralCommand -from .. import registries -from . import ClusterHandler, ClusterHandlerStatus +from zha.zigbee.cluster_handlers import ClusterHandler, ClusterHandlerStatus, registries @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(LightLink.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LightLink.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(LightLink.cluster_id) class LightLinkClusterHandler(ClusterHandler): """Lightlink cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/manufacturerspecific.py b/zha/zigbee/cluster_handlers/manufacturerspecific.py index cc7f7052..409c8831 100644 --- a/zha/zigbee/cluster_handlers/manufacturerspecific.py +++ b/zha/zigbee/cluster_handlers/manufacturerspecific.py @@ -5,37 +5,50 @@ import logging from typing import TYPE_CHECKING, Any -from homeassistant.core import callback from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, XIAOMI_AQARA_VIBRATION_AQ1 import zigpy.zcl from zigpy.zcl.clusters.closures import DoorLock -from .. import registries -from ..const import ( - ATTR_ATTRIBUTE_ID, - ATTR_ATTRIBUTE_NAME, - ATTR_VALUE, +from zha.zigbee.cluster_handlers import ( + AttrReportConfig, + ClientClusterHandler, + ClusterAttributeUpdatedEvent, + ClusterHandler, + registries, +) +from zha.zigbee.cluster_handlers.const import ( + AQARA_OPPLE_CLUSTER, + ATTRIBUTE_ID, + ATTRIBUTE_NAME, + ATTRIBUTE_VALUE, + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + IKEA_AIR_PURIFIER_CLUSTER, + IKEA_REMOTE_CLUSTER, + INOVELLI_CLUSTER, + OSRAM_BUTTON_CLUSTER, + PHILLIPS_REMOTE_CLUSTER, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, + SMARTTHINGS_ACCELERATION_CLUSTER, + SMARTTHINGS_HUMIDITY_CLUSTER, + SONOFF_CLUSTER, + TUYA_MANUFACTURER_CLUSTER, UNKNOWN, ) -from . import AttrReportConfig, ClientClusterHandler, ClusterHandler -from .general import MultistateInputClusterHandler +from zha.zigbee.cluster_handlers.general import MultistateInputClusterHandler if TYPE_CHECKING: - from ..endpoint import Endpoint + from zha.zigbee.endpoint import Endpoint _LOGGER = logging.getLogger(__name__) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - registries.SMARTTHINGS_HUMIDITY_CLUSTER -) +@registries.CLUSTER_HANDLER_REGISTRY.register(SMARTTHINGS_HUMIDITY_CLUSTER) class SmartThingsHumidityClusterHandler(ClusterHandler): """Smart Things Humidity cluster handler.""" @@ -47,26 +60,24 @@ class SmartThingsHumidityClusterHandler(ClusterHandler): ) -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFD00) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFD00) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(OSRAM_BUTTON_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(OSRAM_BUTTON_CLUSTER) class OsramButtonClusterHandler(ClusterHandler): """Osram button cluster handler.""" REPORT_CONFIG = () -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(registries.PHILLIPS_REMOTE_CLUSTER) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(PHILLIPS_REMOTE_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(PHILLIPS_REMOTE_CLUSTER) class PhillipsRemoteClusterHandler(ClusterHandler): """Phillips remote cluster handler.""" REPORT_CONFIG = () -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.TUYA_MANUFACTURER_CLUSTER) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - registries.TUYA_MANUFACTURER_CLUSTER -) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(TUYA_MANUFACTURER_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(TUYA_MANUFACTURER_CLUSTER) class TuyaClusterHandler(ClusterHandler): """Cluster handler for the Tuya manufacturer Zigbee cluster.""" @@ -82,8 +93,8 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: } -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFCC0) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFCC0) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(AQARA_OPPLE_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(AQARA_OPPLE_CLUSTER) class OppleRemoteClusterHandler(ClusterHandler): """Opple cluster handler.""" @@ -169,7 +180,7 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: "hand_open": True, } - async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: # pylint: disable=unused-argument """Initialize cluster handler specific.""" if self.cluster.endpoint.model in ("lumi.motion.ac02", "lumi.motion.agl04"): interval = self.cluster.get("detection_interval", self.cluster.get(0x0102)) @@ -178,9 +189,7 @@ async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> N self.cluster.endpoint.ias_zone.reset_s = int(interval) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - registries.SMARTTHINGS_ACCELERATION_CLUSTER -) +@registries.CLUSTER_HANDLER_REGISTRY.register(SMARTTHINGS_ACCELERATION_CLUSTER) class SmartThingsAccelerationClusterHandler(ClusterHandler): """Smart Things Acceleration cluster handler.""" @@ -200,7 +209,6 @@ def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: "SmartThings", ) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" try: @@ -208,39 +216,41 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: except KeyError: attr_name = UNKNOWN - if attrid == self.value_attribute: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - attrid, - attr_name, - value, + if attr_name == self.value_attribute: + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=attr_name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) return - self.zha_send_event( + self.emit_zha_event( SIGNAL_ATTR_UPDATED, { - ATTR_ATTRIBUTE_ID: attrid, - ATTR_ATTRIBUTE_NAME: attr_name, - ATTR_VALUE: value, + ATTRIBUTE_ID: attrid, + ATTRIBUTE_NAME: attr_name, + ATTRIBUTE_VALUE: value, }, ) -@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +@registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(INOVELLI_CLUSTER) class InovelliNotificationClientClusterHandler(ClientClusterHandler): """Inovelli Notification cluster handler.""" - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle an attribute updated on this cluster.""" - @callback def cluster_command(self, tsn, command_id, args): """Handle a cluster command received on this cluster.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC31) +@registries.CLUSTER_HANDLER_REGISTRY.register(INOVELLI_CLUSTER) class InovelliConfigEntityClusterHandler(ClusterHandler): """Inovelli Configuration Entity cluster handler.""" @@ -334,7 +344,7 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: "smart_fan_led_display_levels": True, } - async def issue_all_led_effect( + async def issue_all_led_effect( # pylint: disable=unused-argument self, effect_type: AllLEDEffectType | int = AllLEDEffectType.Fast_Blink, color: int = 200, @@ -349,7 +359,7 @@ async def issue_all_led_effect( await self.led_effect(effect_type, color, level, duration, expect_reply=False) - async def issue_individual_led_effect( + async def issue_individual_led_effect( # pylint: disable=too-many-arguments,unused-argument self, led_number: int = 1, effect_type: SingleLEDEffectType | int = SingleLEDEffectType.Fast_Blink, @@ -368,10 +378,8 @@ async def issue_individual_led_effect( ) -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - registries.IKEA_AIR_PURIFIER_CLUSTER -) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IKEA_AIR_PURIFIER_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(IKEA_AIR_PURIFIER_CLUSTER) class IkeaAirPurifierClusterHandler(ClusterHandler): """IKEA Air Purifier cluster handler.""" @@ -405,7 +413,6 @@ async def async_update(self) -> None: """Retrieve latest state.""" await self.get_attribute_value("fan_mode", from_cache=False) - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute update from fan cluster.""" attr_name = self._get_attribute_name(attrid) @@ -413,28 +420,26 @@ def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attr_name == "fan_mode": - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value - ) + self.attribute_updated(attrid, attr_name, value) -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC80) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC80) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IKEA_REMOTE_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(IKEA_REMOTE_CLUSTER) class IkeaRemoteClusterHandler(ClusterHandler): """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( +@registries.CLUSTER_HANDLER_REGISTRY.register( DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 ) class XiaomiVibrationAQ1ClusterHandler(MultistateInputClusterHandler): """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" -@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(0xFC11) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(0xFC11) +@registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(SONOFF_CLUSTER) +@registries.CLUSTER_HANDLER_REGISTRY.register(SONOFF_CLUSTER) class SonoffPresenceSenorClusterHandler(ClusterHandler): """SonoffPresenceSensor cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/measurement.py b/zha/zigbee/cluster_handlers/measurement.py index 768de8c4..3f56e12b 100644 --- a/zha/zigbee/cluster_handlers/measurement.py +++ b/zha/zigbee/cluster_handlers/measurement.py @@ -21,21 +21,23 @@ TemperatureMeasurement, ) -from .. import registries -from ..const import ( +from zha.zigbee.cluster_handlers import AttrReportConfig, ClusterHandler, registries +from zha.zigbee.cluster_handlers.const import ( REPORT_CONFIG_DEFAULT, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) -from . import AttrReportConfig, ClusterHandler -from .helpers import is_hue_motion_sensor, is_sonoff_presence_sensor +from zha.zigbee.cluster_handlers.helpers import ( + is_hue_motion_sensor, + is_sonoff_presence_sensor, +) if TYPE_CHECKING: - from ..endpoint import Endpoint + from zha.zigbee.endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(FlowMeasurement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(FlowMeasurement.cluster_id) class FlowMeasurementClusterHandler(ClusterHandler): """Flow Measurement cluster handler.""" @@ -47,7 +49,7 @@ class FlowMeasurementClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceLevelSensing.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(IlluminanceLevelSensing.cluster_id) class IlluminanceLevelSensingClusterHandler(ClusterHandler): """Illuminance Level Sensing cluster handler.""" @@ -59,7 +61,7 @@ class IlluminanceLevelSensingClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IlluminanceMeasurement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(IlluminanceMeasurement.cluster_id) class IlluminanceMeasurementClusterHandler(ClusterHandler): """Illuminance Measurement cluster handler.""" @@ -71,7 +73,7 @@ class IlluminanceMeasurementClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(OccupancySensing.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(OccupancySensing.cluster_id) class OccupancySensingClusterHandler(ClusterHandler): """Occupancy Sensing cluster handler.""" @@ -94,7 +96,7 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: self.ZCL_INIT_ATTRS["ultrasonic_u_to_o_threshold"] = True -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PressureMeasurement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(PressureMeasurement.cluster_id) class PressureMeasurementClusterHandler(ClusterHandler): """Pressure measurement cluster handler.""" @@ -106,7 +108,7 @@ class PressureMeasurementClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(RelativeHumidity.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(RelativeHumidity.cluster_id) class RelativeHumidityClusterHandler(ClusterHandler): """Relative Humidity measurement cluster handler.""" @@ -118,7 +120,7 @@ class RelativeHumidityClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(SoilMoisture.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(SoilMoisture.cluster_id) class SoilMoistureClusterHandler(ClusterHandler): """Soil Moisture measurement cluster handler.""" @@ -130,7 +132,7 @@ class SoilMoistureClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(LeafWetness.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(LeafWetness.cluster_id) class LeafWetnessClusterHandler(ClusterHandler): """Leaf Wetness measurement cluster handler.""" @@ -142,7 +144,7 @@ class LeafWetnessClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(TemperatureMeasurement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(TemperatureMeasurement.cluster_id) class TemperatureMeasurementClusterHandler(ClusterHandler): """Temperature measurement cluster handler.""" @@ -154,9 +156,7 @@ class TemperatureMeasurementClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - CarbonMonoxideConcentration.cluster_id -) +@registries.CLUSTER_HANDLER_REGISTRY.register(CarbonMonoxideConcentration.cluster_id) class CarbonMonoxideConcentrationClusterHandler(ClusterHandler): """Carbon Monoxide measurement cluster handler.""" @@ -168,9 +168,7 @@ class CarbonMonoxideConcentrationClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - CarbonDioxideConcentration.cluster_id -) +@registries.CLUSTER_HANDLER_REGISTRY.register(CarbonDioxideConcentration.cluster_id) class CarbonDioxideConcentrationClusterHandler(ClusterHandler): """Carbon Dioxide measurement cluster handler.""" @@ -182,7 +180,7 @@ class CarbonDioxideConcentrationClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(PM25.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(PM25.cluster_id) class PM25ClusterHandler(ClusterHandler): """Particulate Matter 2.5 microns or less measurement cluster handler.""" @@ -194,9 +192,7 @@ class PM25ClusterHandler(ClusterHandler): ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - FormaldehydeConcentration.cluster_id -) +@registries.CLUSTER_HANDLER_REGISTRY.register(FormaldehydeConcentration.cluster_id) class FormaldehydeConcentrationClusterHandler(ClusterHandler): """Formaldehyde measurement cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/protocol.py b/zha/zigbee/cluster_handlers/protocol.py index e1e3d7a5..fd312035 100644 --- a/zha/zigbee/cluster_handlers/protocol.py +++ b/zha/zigbee/cluster_handlers/protocol.py @@ -23,107 +23,104 @@ MultistateValueRegular, ) -from .. import registries -from . import ClusterHandler +from zha.zigbee.cluster_handlers import ClusterHandler, registries -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogInputExtended.cluster_id) class AnalogInputExtendedClusterHandler(ClusterHandler): """Analog Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogInputRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogInputRegular.cluster_id) class AnalogInputRegularClusterHandler(ClusterHandler): """Analog Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogOutputExtended.cluster_id) class AnalogOutputExtendedClusterHandler(ClusterHandler): """Analog Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogOutputRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogOutputRegular.cluster_id) class AnalogOutputRegularClusterHandler(ClusterHandler): """Analog Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogValueExtended.cluster_id) class AnalogValueExtendedClusterHandler(ClusterHandler): """Analog Value Extended edition cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AnalogValueRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(AnalogValueRegular.cluster_id) class AnalogValueRegularClusterHandler(ClusterHandler): """Analog Value Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BacnetProtocolTunnel.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BacnetProtocolTunnel.cluster_id) class BacnetProtocolTunnelClusterHandler(ClusterHandler): """Bacnet Protocol Tunnel cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryInputExtended.cluster_id) class BinaryInputExtendedClusterHandler(ClusterHandler): """Binary Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryInputRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryInputRegular.cluster_id) class BinaryInputRegularClusterHandler(ClusterHandler): """Binary Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryOutputExtended.cluster_id) class BinaryOutputExtendedClusterHandler(ClusterHandler): """Binary Output Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryOutputRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryOutputRegular.cluster_id) class BinaryOutputRegularClusterHandler(ClusterHandler): """Binary Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryValueExtended.cluster_id) class BinaryValueExtendedClusterHandler(ClusterHandler): """Binary Value Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(BinaryValueRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(BinaryValueRegular.cluster_id) class BinaryValueRegularClusterHandler(ClusterHandler): """Binary Value Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(GenericTunnel.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(GenericTunnel.cluster_id) class GenericTunnelClusterHandler(ClusterHandler): """Generic Tunnel cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateInputExtended.cluster_id) class MultiStateInputExtendedClusterHandler(ClusterHandler): """Multistate Input Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateInputRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateInputRegular.cluster_id) class MultiStateInputRegularClusterHandler(ClusterHandler): """Multistate Input Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( - MultistateOutputExtended.cluster_id -) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateOutputExtended.cluster_id) class MultiStateOutputExtendedClusterHandler(ClusterHandler): """Multistate Output Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateOutputRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateOutputRegular.cluster_id) class MultiStateOutputRegularClusterHandler(ClusterHandler): """Multistate Output Regular cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueExtended.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateValueExtended.cluster_id) class MultiStateValueExtendedClusterHandler(ClusterHandler): """Multistate Value Extended cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MultistateValueRegular.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MultistateValueRegular.cluster_id) class MultiStateValueRegularClusterHandler(ClusterHandler): """Multistate Value Regular cluster handler.""" diff --git a/zha/zigbee/cluster_handlers/registries.py b/zha/zigbee/cluster_handlers/registries.py new file mode 100644 index 00000000..07c8dc85 --- /dev/null +++ b/zha/zigbee/cluster_handlers/registries.py @@ -0,0 +1,13 @@ +"""Mapping registries for zha cluster handlers.""" + +from zha.decorators import DictRegistry, NestedDictRegistry, SetRegistry +from zha.zigbee.cluster_handlers import ClientClusterHandler, ClusterHandler + +BINDABLE_CLUSTERS = SetRegistry() +CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() +CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClientClusterHandler]] = ( + DictRegistry() +) +CLUSTER_HANDLER_REGISTRY: NestedDictRegistry[type[ClusterHandler]] = ( + NestedDictRegistry() +) diff --git a/zha/zigbee/cluster_handlers/security.py b/zha/zigbee/cluster_handlers/security.py index eb8e24aa..47e29177 100644 --- a/zha/zigbee/cluster_handlers/security.py +++ b/zha/zigbee/cluster_handlers/security.py @@ -7,32 +7,48 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Any +import dataclasses +from typing import TYPE_CHECKING, Any, Final -from homeassistant.core import callback -from homeassistant.exceptions import HomeAssistantError import zigpy.zcl -from zigpy.zcl.clusters.security import IasAce as AceCluster, IasWd, IasZone - -from .. import registries -from ..const import ( - SIGNAL_ATTR_UPDATED, - WARNING_DEVICE_MODE_EMERGENCY, - WARNING_DEVICE_SOUND_HIGH, - WARNING_DEVICE_SQUAWK_MODE_ARMED, - WARNING_DEVICE_STROBE_HIGH, - WARNING_DEVICE_STROBE_YES, +from zigpy.zcl.clusters.security import ( + IasAce as AceCluster, + IasWd, + IasZone, + Squawk, + Strobe, + StrobeLevel, + WarningType, +) + +from zha.exceptions import ZHAException +from zha.zigbee.cluster_handlers import ( + ClusterAttributeUpdatedEvent, + ClusterHandler, + ClusterHandlerStatus, + registries, +) +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_STATE_CHANGED, ) -from . import ClusterHandler, ClusterHandlerStatus if TYPE_CHECKING: - from ..endpoint import Endpoint + from zha.zigbee.endpoint import Endpoint SIGNAL_ARMED_STATE_CHANGED = "zha_armed_state_changed" SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) +@dataclasses.dataclass(frozen=True, kw_only=True) +class ClusterHandlerStateChangedEvent: + """Event to signal that a cluster attribute has been updated.""" + + event_type: Final[str] = "cluster_handler_event" + event: Final[str] = "cluster_handler_state_changed" + + +@registries.CLUSTER_HANDLER_REGISTRY.register(AceCluster.cluster_id) class IasAceClusterHandler(ClusterHandler): """IAS Ancillary Control Equipment cluster handler.""" @@ -44,7 +60,7 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: AceCluster.ServerCommandDefs.bypass.id: self._bypass, AceCluster.ServerCommandDefs.emergency.id: self._emergency, AceCluster.ServerCommandDefs.fire.id: self._fire, - AceCluster.ServerCommandDefs.panic.id: self._panic, + AceCluster.ServerCommandDefs.panic.id: self.panic, AceCluster.ServerCommandDefs.get_zone_id_map.id: self._get_zone_id_map, AceCluster.ServerCommandDefs.get_zone_info.id: self._get_zone_info, AceCluster.ServerCommandDefs.get_panel_status.id: self._send_panel_status_response, @@ -68,7 +84,6 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: # where do we store this to handle restarts self.alarm_status: AceCluster.AlarmStatus = AceCluster.AlarmStatus.No_Alarm - @callback def cluster_command(self, tsn, command_id, args) -> None: """Handle commands received to this cluster.""" self.debug( @@ -80,7 +95,7 @@ def arm(self, arm_mode: int, code: str | None, zone_id: int) -> None: """Handle the IAS ACE arm command.""" mode = AceCluster.ArmMode(arm_mode) - self.zha_send_event( + self.emit_zha_event( AceCluster.ServerCommandDefs.arm.name, { "arm_mode": mode.value, @@ -91,15 +106,15 @@ def arm(self, arm_mode: int, code: str | None, zone_id: int) -> None: ) zigbee_reply = self.arm_map[mode](code) - self._endpoint.device.hass.async_create_task(zigbee_reply) + self._endpoint.device.gateway.async_create_task(zigbee_reply) if self.invalid_tries >= self.max_invalid_tries: self.alarm_status = AceCluster.AlarmStatus.Emergency self.armed_state = AceCluster.PanelStatus.In_Alarm - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}") + self.emit_propagated_event(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}") else: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ARMED_STATE_CHANGED}") - self._send_panel_status_changed() + self.emit_propagated_event(f"{self.unique_id}_{SIGNAL_ARMED_STATE_CHANGED}") + self._emit_panel_status_changed() def _disarm(self, code: str): """Test the code and disarm the panel if the code is correct.""" @@ -176,7 +191,7 @@ def _handle_arm( def _bypass(self, zone_list, code) -> None: """Handle the IAS ACE bypass command.""" - self.zha_send_event( + self.emit_zha_event( AceCluster.ServerCommandDefs.bypass.name, {"zone_list": zone_list, "code": code}, ) @@ -189,7 +204,7 @@ def _fire(self) -> None: """Handle the IAS ACE fire command.""" self._set_alarm(AceCluster.AlarmStatus.Fire) - def _panic(self) -> None: + def panic(self) -> None: """Handle the IAS ACE panic command.""" self._set_alarm(AceCluster.AlarmStatus.Emergency_Panic) @@ -197,8 +212,7 @@ def _set_alarm(self, status: AceCluster.AlarmStatus) -> None: """Set the specified alarm status.""" self.alarm_status = status self.armed_state = AceCluster.PanelStatus.In_Alarm - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ALARM_TRIGGERED}") - self._send_panel_status_changed() + self._emit_panel_status_changed() def _get_zone_id_map(self): """Handle the IAS ACE zone id map command.""" @@ -214,9 +228,9 @@ def _send_panel_status_response(self) -> None: AceCluster.AudibleNotification.Default_Sound, self.alarm_status, ) - self._endpoint.device.hass.async_create_task(response) + self._endpoint.device.gateway.async_create_task(response) - def _send_panel_status_changed(self) -> None: + def _emit_panel_status_changed(self) -> None: """Handle the IAS ACE panel status changed command.""" response = self.panel_status_changed( self.armed_state, @@ -224,7 +238,11 @@ def _send_panel_status_changed(self) -> None: AceCluster.AudibleNotification.Default_Sound, self.alarm_status, ) - self._endpoint.device.hass.async_create_task(response) + self._endpoint.device.gateway.async_create_task(response) + self.emit( + CLUSTER_HANDLER_STATE_CHANGED, + ClusterHandlerStateChangedEvent(), + ) def _get_bypassed_zone_list(self): """Handle the IAS ACE bypassed zone list command.""" @@ -236,7 +254,7 @@ def _get_zone_status( @registries.CLUSTER_HANDLER_ONLY_CLUSTERS.register(IasWd.cluster_id) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasWd.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(IasWd.cluster_id) class IasWdClusterHandler(ClusterHandler): """IAS Warning Device cluster handler.""" @@ -255,9 +273,9 @@ def get_bit(value, bit): async def issue_squawk( self, - mode=WARNING_DEVICE_SQUAWK_MODE_ARMED, - strobe=WARNING_DEVICE_STROBE_YES, - squawk_level=WARNING_DEVICE_SOUND_HIGH, + mode=Squawk.SquawkMode.Armed, + strobe=Strobe.Strobe, + squawk_level=Squawk.SquawkLevel.High_level_sound, ): """Issue a squawk command. @@ -280,12 +298,12 @@ async def issue_squawk( async def issue_start_warning( self, - mode=WARNING_DEVICE_MODE_EMERGENCY, - strobe=WARNING_DEVICE_STROBE_YES, - siren_level=WARNING_DEVICE_SOUND_HIGH, + mode=WarningType.WarningMode.Emergency, + strobe=Strobe.Strobe, + siren_level=WarningType.SirenLevel.High_level_sound, warning_duration=5, # seconds strobe_duty_cycle=0x00, - strobe_intensity=WARNING_DEVICE_STROBE_HIGH, + strobe_intensity=StrobeLevel.High_level_strobe, ): """Issue a start warning command. @@ -318,17 +336,18 @@ async def issue_start_warning( ) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(IasZone.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(IasZone.cluster_id) class IASZoneClusterHandler(ClusterHandler): """Cluster handler for the IASZone Zigbee cluster.""" + _value_attribute: str = IasZone.AttributeDefs.zone_status.name + ZCL_INIT_ATTRS = { IasZone.AttributeDefs.zone_status.name: False, IasZone.AttributeDefs.zone_state.name: True, IasZone.AttributeDefs.zone_type.name: True, } - @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" if command_id == IasZone.ClientCommandDefs.status_change_notification.id: @@ -369,7 +388,7 @@ async def async_configure(self): str(ieee), self._cluster.ep_attribute, ) - except HomeAssistantError as ex: + except ZHAException as ex: self.debug( "Failed to write cie_addr: %s to '%s' cluster: %s", str(ieee), @@ -387,13 +406,22 @@ async def async_configure(self): self._status = ClusterHandlerStatus.CONFIGURED self.debug("finished IASZoneClusterHandler configuration") - @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: """Handle attribute updates on this cluster.""" if attrid == IasZone.AttributeDefs.zone_status.id: - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - attrid, - IasZone.AttributeDefs.zone_status.name, - value, + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=attrid, + attribute_name=IasZone.AttributeDefs.zone_status.name, + attribute_value=value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), ) + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self.get_attribute_value( + IasZone.AttributeDefs.zone_status.name, from_cache=False + ) diff --git a/zha/zigbee/cluster_handlers/smartenergy.py b/zha/zigbee/cluster_handlers/smartenergy.py index d167b8b1..e37e9660 100644 --- a/zha/zigbee/cluster_handlers/smartenergy.py +++ b/zha/zigbee/cluster_handlers/smartenergy.py @@ -22,60 +22,58 @@ Tunneling, ) -from .. import registries -from ..const import ( +from zha.zigbee.cluster_handlers import AttrReportConfig, ClusterHandler, registries +from zha.zigbee.cluster_handlers.const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP, - SIGNAL_ATTR_UPDATED, ) -from . import AttrReportConfig, ClusterHandler if TYPE_CHECKING: - from ..endpoint import Endpoint + from zha.zigbee.endpoint import Endpoint -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Calendar.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Calendar.cluster_id) class CalendarClusterHandler(ClusterHandler): """Calendar cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(DeviceManagement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(DeviceManagement.cluster_id) class DeviceManagementClusterHandler(ClusterHandler): """Device Management cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Drlc.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Drlc.cluster_id) class DrlcClusterHandler(ClusterHandler): """Demand Response and Load Control cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(EnergyManagement.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(EnergyManagement.cluster_id) class EnergyManagementClusterHandler(ClusterHandler): """Energy Management cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Events.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Events.cluster_id) class EventsClusterHandler(ClusterHandler): """Event cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(KeyEstablishment.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(KeyEstablishment.cluster_id) class KeyEstablishmentClusterHandler(ClusterHandler): """Key Establishment cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(MduPairing.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(MduPairing.cluster_id) class MduPairingClusterHandler(ClusterHandler): """Pairing cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Messaging.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Messaging.cluster_id) class MessagingClusterHandler(ClusterHandler): """Messaging cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Metering.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Metering.cluster_id) class MeteringClusterHandler(ClusterHandler): """Metering cluster handler.""" @@ -277,7 +275,7 @@ def multiplier(self) -> int: return self.cluster.get(Metering.AttributeDefs.multiplier.name) or 1 @property - def status(self) -> int | None: + def metering_status(self) -> int | None: """Return metering device status.""" if (status := self.cluster.get(Metering.AttributeDefs.status.name)) is None: return None @@ -300,7 +298,7 @@ def unit_of_measurement(self) -> int: """Return unit of measurement.""" return self.cluster.get(Metering.AttributeDefs.unit_of_measure.name) - async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: + async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: # pylint: disable=unused-argument """Fetch config from device and updates format specifier.""" fmting = self.cluster.get( @@ -325,8 +323,7 @@ async def async_update(self) -> None: result = await self.get_attributes(attrs, from_cache=False, only_cache=False) if result: for attr, value in result.items(): - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.attribute_updated( self.cluster.find_attribute(attr).id, attr, value, @@ -365,24 +362,24 @@ def _formatter_function( return round(value_watt) if selector == self.FormatSelector.SUMMATION: assert self._summa_format - return self._summa_format.format(value_float).lstrip() + return float(self._summa_format.format(value_float).lstrip()) assert self._format_spec - return self._format_spec.format(value_float).lstrip() + return float(self._format_spec.format(value_float).lstrip()) demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Prepayment.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Prepayment.cluster_id) class PrepaymentClusterHandler(ClusterHandler): """Prepayment cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Price.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Price.cluster_id) class PriceClusterHandler(ClusterHandler): """Price cluster handler.""" -@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Tunneling.cluster_id) +@registries.CLUSTER_HANDLER_REGISTRY.register(Tunneling.cluster_id) class TunnelingClusterHandler(ClusterHandler): """Tunneling cluster handler.""" diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index 89c56254..edeb7fd1 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -1,27 +1,18 @@ """Device for Zigbee Home Automation.""" +# pylint: disable=too-many-lines + from __future__ import annotations import asyncio -from collections.abc import Callable -from datetime import timedelta +from contextlib import suppress +from dataclasses import dataclass from enum import Enum +from functools import cached_property import logging -import random import time -from typing import TYPE_CHECKING, Any, Self - -from homeassistant.backports.functools import cached_property -from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.event import async_track_time_interval +from typing import TYPE_CHECKING, Any, Final, Self + from zigpy import types from zigpy.device import Device as ZigpyDevice import zigpy.exceptions @@ -34,15 +25,15 @@ from zigpy.zcl.foundation import Status as ZclStatus, ZCLCommandDef import zigpy.zdo.types as zdo_types -from . import const, discovery -from .cluster_handlers import ClusterHandler, ZDOClusterHandler -from .const import ( +from zha.application import discovery +from zha.application.const import ( ATTR_ACTIVE_COORDINATOR, ATTR_ARGS, ATTR_ATTRIBUTE, ATTR_AVAILABLE, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, + ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_DEVICE_TYPE, ATTR_ENDPOINT_ID, @@ -54,6 +45,7 @@ ATTR_MANUFACTURER, ATTR_MANUFACTURER_CODE, ATTR_MODEL, + ATTR_NAME, ATTR_NEIGHBORS, ATTR_NODE_DESCRIPTOR, ATTR_NWK, @@ -78,19 +70,25 @@ CONF_ENABLE_IDENTIFY_ON_JOIN, POWER_BATTERY_OR_UNKNOWN, POWER_MAINS_POWERED, - SIGNAL_AVAILABLE, - SIGNAL_UPDATE_DEVICE, UNKNOWN, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, + ZHA_CLUSTER_HANDLER_CFG_DONE, + ZHA_CLUSTER_HANDLER_MSG, + ZHA_EVENT, ZHA_OPTIONS, ) -from .endpoint import Endpoint -from .helpers import LogMixin, async_get_zha_config_value, convert_to_zcl_values +from zha.application.helpers import async_get_zha_config_value, convert_to_zcl_values +from zha.application.platforms import PlatformEntity +from zha.decorators import periodic +from zha.event import EventBase +from zha.exceptions import ZHAException +from zha.mixins import LogMixin +from zha.zigbee.cluster_handlers import ClusterHandler, ZDOClusterHandler +from zha.zigbee.endpoint import Endpoint if TYPE_CHECKING: - from ..websocket_api import ClusterBinding - from .gateway import ZHAGateway + from zha.application.gateway import Gateway _LOGGER = logging.getLogger(__name__) _UPDATE_ALIVE_INTERVAL = (60, 90) @@ -107,6 +105,16 @@ def get_device_automation_triggers( } +@dataclass(frozen=True, kw_only=True) +class ClusterBinding: + """Describes a cluster binding.""" + + name: str + type: str + id: int + endpoint_id: int + + class DeviceStatus(Enum): """Status of a device.""" @@ -114,24 +122,42 @@ class DeviceStatus(Enum): INITIALIZED = 2 -class ZHADevice(LogMixin): +@dataclass(kw_only=True, frozen=True) +class ZHAEvent: + """Event generated when a device wishes to send an arbitrary event.""" + + device_ieee: str + unique_id: str + data: dict[str, Any] + event_type: Final[str] = ZHA_EVENT + event: Final[str] = ZHA_EVENT + + +@dataclass(kw_only=True, frozen=True) +class ClusterHandlerConfigurationComplete: + """Event generated when all cluster handlers are configured.""" + + device_ieee: str + unique_id: str + event_type: Final[str] = ZHA_CLUSTER_HANDLER_MSG + event: Final[str] = ZHA_CLUSTER_HANDLER_CFG_DONE + + +class Device(LogMixin, EventBase): """ZHA Zigbee device object.""" + __polling_interval: int _ha_device_id: str def __init__( self, - hass: HomeAssistant, zigpy_device: zigpy.device.Device, - zha_gateway: ZHAGateway, + _gateway: Gateway, ) -> None: """Initialize the gateway.""" - self.hass: HomeAssistant = hass + super().__init__() + self._gateway: Gateway = _gateway self._zigpy_device: ZigpyDevice = zigpy_device - self._zha_gateway: ZHAGateway = zha_gateway - self._available_signal: str = f"{self.name}_{self.ieee}_{SIGNAL_AVAILABLE}" - self._checkins_missed_count: int = 0 - self.unsubs: list[Callable[[], None]] = [] self.quirk_applied: bool = isinstance( self._zigpy_device, zigpy.quirks.CustomDevice ) @@ -140,17 +166,21 @@ def __init__( f"{self._zigpy_device.__class__.__name__}" ) self.quirk_id: str | None = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) + self._power_config_ch: ClusterHandler | None = None + self._identify_ch: ClusterHandler | None = None + self._basic_ch: ClusterHandler | None = None + self._sw_build_id: int | None = None if self.is_mains_powered: self.consider_unavailable_time: int = async_get_zha_config_value( - self._zha_gateway.config_entry, + self._gateway.config, ZHA_OPTIONS, CONF_CONSIDER_UNAVAILABLE_MAINS, CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, ) else: self.consider_unavailable_time = async_get_zha_config_value( - self._zha_gateway.config_entry, + self._gateway.config, ZHA_OPTIONS, CONF_CONSIDER_UNAVAILABLE_BATTERY, CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, @@ -159,10 +189,12 @@ def __init__( self.last_seen is not None and time.time() - self.last_seen < self.consider_unavailable_time ) + self._checkins_missed_count: int = 0 + + self._platform_entities: dict[str, PlatformEntity] = {} + self.semaphore: asyncio.Semaphore = asyncio.Semaphore(3) + self._tracked_tasks: list[asyncio.Task] = [] self._zdo_handler: ZDOClusterHandler = ZDOClusterHandler(self) - self._power_config_ch: ClusterHandler | None = None - self._identify_ch: ClusterHandler | None = None - self._basic_ch: ClusterHandler | None = None self.status: DeviceStatus = DeviceStatus.CREATED self._endpoints: dict[int, Endpoint] = {} @@ -171,26 +203,18 @@ def __init__( self._endpoints[ep_id] = Endpoint.new(endpoint, self) if not self.is_coordinator: - keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) - self.debug( - "starting availability checks - interval: %s", keep_alive_interval - ) - self.unsubs.append( - async_track_time_interval( - self.hass, - self._check_available, - timedelta(seconds=keep_alive_interval), + self._tracked_tasks.append( + self.gateway.async_create_background_task( + self._check_available(), + name=f"device_check_alive_{self.ieee}", + eager_start=True, + untracked=True, ) ) - - @property - def device_id(self) -> str: - """Return the HA device registry device id.""" - return self._ha_device_id - - def set_device_id(self, device_id: str) -> None: - """Set the HA device registry device id.""" - self._ha_device_id = device_id + self.debug( + "starting availability checks - interval: %s", + getattr(self, "__polling_interval"), + ) @property def device(self) -> zigpy.device.Device: @@ -319,7 +343,7 @@ def skip_configuration(self) -> bool: @property def gateway(self): """Return the gateway for this device.""" - return self._zha_gateway + return self._gateway @cached_property def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: @@ -335,11 +359,6 @@ def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" return get_device_automation_triggers(self._zigpy_device) - @property - def available_signal(self) -> str: - """Signal to use to subscribe to device availability changes.""" - return self._available_signal - @property def available(self): """Return True if device is available.""" @@ -411,21 +430,30 @@ def zigbee_signature(self) -> dict[str, Any]: @property def sw_version(self) -> str | None: """Return the software version for this device.""" - device_registry = dr.async_get(self.hass) - reg_device: DeviceEntry | None = device_registry.async_get(self.device_id) - if reg_device is None: - return None - return reg_device.sw_version + return self._sw_build_id + + @property + def platform_entities(self) -> dict[str, PlatformEntity]: + """Return the platform entities for this device.""" + return self._platform_entities + + def get_platform_entity(self, unique_id: str) -> PlatformEntity: + """Get a platform entity by unique id.""" + entity = self._platform_entities.get(unique_id) + if entity is None: + raise ValueError(f"Entity {unique_id} not found") + return entity @classmethod def new( cls, - hass: HomeAssistant, zigpy_dev: zigpy.device.Device, - gateway: ZHAGateway, + gateway: Gateway, ) -> Self: """Create new device.""" - zha_dev = cls(hass, zigpy_dev, gateway) + zha_dev = cls(zigpy_dev, gateway) + # pylint: disable=pointless-string-statement + """TODO verify zha_dev.unsubs.append( async_dispatcher_connect( hass, @@ -433,20 +461,15 @@ def new( zha_dev.async_update_sw_build_id, ) ) - discovery.PROBE.discover_device_entities(zha_dev) + """ + discovery.DEVICE_PROBE.discover_device_entities(zha_dev) return zha_dev - @callback def async_update_sw_build_id(self, sw_version: int) -> None: """Update device sw version.""" - if self.device_id is None: - return - - device_registry = dr.async_get(self.hass) - device_registry.async_update_device( - self.device_id, sw_version=f"0x{sw_version:08x}" - ) + self._sw_build_id = sw_version + @periodic(_UPDATE_ALIVE_INTERVAL) async def _check_available(self, *_: Any) -> None: # don't flip the availability state of the coordinator if self.is_coordinator: @@ -465,7 +488,7 @@ async def _check_available(self, *_: Any) -> None: self._checkins_missed_count = 0 return - if self.hass.data[const.DATA_ZHA].allow_polling: + if self._gateway.config.allow_polling: if ( self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS or self.manufacturer == "LUMI" @@ -515,34 +538,34 @@ def update_available(self, available: bool) -> None: "Device availability changed and device became available," " reinitializing cluster handlers" ) - self.hass.async_create_task(self._async_became_available()) + self._gateway.track_task( + asyncio.create_task(self._async_became_available()) + ) return if availability_changed and not available: self.debug("Device availability changed and device became unavailable") - self.zha_send_event( + self.emit_zha_event( { "device_event_type": "device_offline", }, ) - async_dispatcher_send(self.hass, f"{self._available_signal}_entity") - @callback - def zha_send_event(self, event_data: dict[str, str | int]) -> None: + def emit_zha_event(self, event_data: dict[str, str | int]) -> None: # pylint: disable=unused-argument """Relay events to hass.""" - self.hass.bus.async_fire( - const.ZHA_EVENT, - { - const.ATTR_DEVICE_IEEE: str(self.ieee), - const.ATTR_UNIQUE_ID: str(self.ieee), - ATTR_DEVICE_ID: self.device_id, - **event_data, - }, + self.emit( + ZHA_EVENT, + ZHAEvent( + device_ieee=str(self.ieee), + unique_id=str(self.ieee), + data=event_data, + ), ) async def _async_became_available(self) -> None: """Update device availability and signal entities.""" await self.async_initialize(False) - async_dispatcher_send(self.hass, f"{self._available_signal}_entity") + for platform_entity in self._platform_entities.values(): + platform_entity.maybe_emit_state_changed_event() @property def device_info(self) -> dict[str, Any]: @@ -572,7 +595,7 @@ def device_info(self) -> dict[str, Any]: async def async_configure(self) -> None: """Configure the device.""" should_identify = async_get_zha_config_value( - self._zha_gateway.config_entry, + self._gateway.config, ZHA_OPTIONS, CONF_ENABLE_IDENTIFY_ON_JOIN, True, @@ -586,13 +609,15 @@ async def async_configure(self) -> None: if isinstance(self._zigpy_device, CustomDeviceV2): self.debug("applying quirks v2 custom device configuration") await self._zigpy_device.apply_custom_configuration() - async_dispatcher_send( - self.hass, - const.ZHA_CLUSTER_HANDLER_MSG, - { - const.ATTR_TYPE: const.ZHA_CLUSTER_HANDLER_CFG_DONE, - }, + + self.emit( + ZHA_CLUSTER_HANDLER_CFG_DONE, + ClusterHandlerConfigurationComplete( + device_ieee=str(self.ieee), + unique_id=str(self.ieee), + ), ) + self.debug("completed configuration") if ( @@ -625,11 +650,16 @@ async def async_initialize(self, from_cache: bool = False) -> None: self.status = DeviceStatus.INITIALIZED self.debug("completed initialization") - @callback - def async_cleanup_handles(self) -> None: - """Unsubscribe the dispatchers and timers.""" - for unsubscribe in self.unsubs: - unsubscribe() + async def on_remove(self) -> None: + """Cancel tasks this device owns.""" + tasks = [t for t in self._tracked_tasks if not (t.done() or t.cancelled())] + for task in tasks: + self.debug("Cancelling task: %s", task) + task.cancel() + with suppress(asyncio.CancelledError): + await asyncio.gather(*tasks, return_exceptions=True) + for platform_entity in self._platform_entities.values(): + await platform_entity.on_remove() @property def zha_device_info(self) -> dict[str, Any]: @@ -637,13 +667,10 @@ def zha_device_info(self) -> dict[str, Any]: device_info: dict[str, Any] = {} device_info.update(self.device_info) device_info[ATTR_ACTIVE_COORDINATOR] = self.is_active_coordinator - device_info["entities"] = [ - { - "entity_id": entity_ref.reference_id, - ATTR_NAME: entity_ref.device_info[ATTR_NAME], - } - for entity_ref in self.gateway.device_registry[self.ieee] - ] + device_info["entities"] = { + unique_id: platform_entity.to_json() + for unique_id, platform_entity in self.platform_entities.items() + } topology = self.gateway.application_controller.topology device_info[ATTR_NEIGHBORS] = [ @@ -690,16 +717,8 @@ def zha_device_info(self) -> dict[str, Any]: } ) device_info[ATTR_ENDPOINT_NAMES] = names - - device_registry = dr.async_get(self.hass) - reg_device = device_registry.async_get(self.device_id) - if reg_device is not None: - device_info["user_given_name"] = reg_device.name_by_user - device_info["device_reg_id"] = reg_device.id - device_info["area_id"] = reg_device.area_id return device_info - @callback def async_get_clusters(self) -> dict[int, dict[str, dict[int, Cluster]]]: """Get all clusters for this device.""" return { @@ -711,7 +730,6 @@ def async_get_clusters(self) -> dict[int, dict[str, dict[int, Cluster]]]: if ep_id != 0 } - @callback def async_get_groupable_endpoints(self): """Get device endpoints that have a group 'in' cluster.""" return [ @@ -720,7 +738,6 @@ def async_get_groupable_endpoints(self): if Groups.cluster_id in clusters[CLUSTER_TYPE_IN] ] - @callback def async_get_std_clusters(self): """Get ZHA and ZLL clusters for this device.""" @@ -733,7 +750,6 @@ def async_get_std_clusters(self): if ep_id != 0 and endpoint.profile_id in PROFILES } - @callback def async_get_cluster( self, endpoint_id: int, cluster_id: int, cluster_type: str = CLUSTER_TYPE_IN ) -> Cluster: @@ -741,7 +757,6 @@ def async_get_cluster( clusters: dict[int, dict[str, dict[int, Cluster]]] = self.async_get_clusters() return clusters[endpoint_id][cluster_type][cluster_id] - @callback def async_get_cluster_attributes( self, endpoint_id, cluster_id, cluster_type=CLUSTER_TYPE_IN ): @@ -751,7 +766,6 @@ def async_get_cluster_attributes( return None return cluster.attributes - @callback def async_get_cluster_commands( self, endpoint_id, cluster_id, cluster_type=CLUSTER_TYPE_IN ): @@ -798,7 +812,7 @@ async def write_zigbee_attribute( ) return response except zigpy.exceptions.ZigbeeException as exc: - raise HomeAssistantError( + raise ZHAException( f"Failed to set attribute: " f"{ATTR_VALUE}: {value} " f"{ATTR_ATTRIBUTE}: {attribute} " @@ -861,9 +875,9 @@ async def issue_cluster_command( if response is None: return # client commands don't return a response if isinstance(response, Exception): - raise HomeAssistantError("Failed to issue cluster command") from response + raise ZHAException("Failed to issue cluster command") from response if response[1] is not ZclStatus.SUCCESS: - raise HomeAssistantError( + raise ZHAException( f"Failed to issue cluster command with status: {response[1]}" ) diff --git a/zha/zigbee/endpoint.py b/zha/zigbee/endpoint.py index b0d617eb..2ffe4d07 100644 --- a/zha/zigbee/endpoint.py +++ b/zha/zigbee/endpoint.py @@ -8,20 +8,24 @@ import logging from typing import TYPE_CHECKING, Any, Final, TypeVar -from homeassistant.const import Platform -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util.async_ import gather_with_limited_concurrency - -from . import const, discovery, registries -from .cluster_handlers import ClusterHandler -from .helpers import get_zha_data +from zha.application import Platform, const, discovery +from zha.async_ import gather_with_limited_concurrency +from zha.zigbee.cluster_handlers import ClusterHandler +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_IDENTIFY, + CLUSTER_HANDLER_POWER_CONFIGURATION, +) +from zha.zigbee.cluster_handlers.registries import ( + CLIENT_CLUSTER_HANDLER_REGISTRY, + CLUSTER_HANDLER_REGISTRY, +) if TYPE_CHECKING: from zigpy import Endpoint as ZigpyEndpoint - from .cluster_handlers import ClientClusterHandler - from .device import ZHADevice + from zha.zigbee.cluster_handlers import ClientClusterHandler + from zha.zigbee.device import Device ATTR_DEVICE_TYPE: Final[str] = "device_type" ATTR_PROFILE_ID: Final[str] = "profile_id" @@ -35,19 +39,19 @@ class Endpoint: """Endpoint for a zha device.""" - def __init__(self, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> None: + def __init__(self, zigpy_endpoint: ZigpyEndpoint, device: Device) -> None: """Initialize instance.""" assert zigpy_endpoint is not None assert device is not None self._zigpy_endpoint: ZigpyEndpoint = zigpy_endpoint - self._device: ZHADevice = device + self._device: Device = device self._all_cluster_handlers: dict[str, ClusterHandler] = {} self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} self._client_cluster_handlers: dict[str, ClientClusterHandler] = {} self._unique_id: str = f"{str(device.ieee)}-{zigpy_endpoint.endpoint_id}" @property - def device(self) -> ZHADevice: + def device(self) -> Device: """Return the device this endpoint belongs to.""" return self._device @@ -105,19 +109,19 @@ def zigbee_signature(self) -> tuple[int, dict[str, Any]]: ) @classmethod - def new(cls, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> Endpoint: + def new(cls, zigpy_endpoint: ZigpyEndpoint, device: Device) -> Endpoint: """Create new endpoint and populate cluster handlers.""" endpoint = cls(zigpy_endpoint, device) endpoint.add_all_cluster_handlers() endpoint.add_client_cluster_handlers() if not device.is_coordinator: - discovery.PROBE.discover_entities(endpoint) + discovery.ENDPOINT_PROBE.discover_entities(endpoint) return endpoint def add_all_cluster_handlers(self) -> None: """Create and add cluster handlers for all input clusters.""" for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): - cluster_handler_classes = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_handler_classes = CLUSTER_HANDLER_REGISTRY.get( cluster_id, {None: ClusterHandler} ) quirk_id = ( @@ -151,11 +155,11 @@ def add_all_cluster_handlers(self) -> None: ) continue - if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION: + if cluster_handler.name == CLUSTER_HANDLER_POWER_CONFIGURATION: self._device.power_configuration_ch = cluster_handler - elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY: + elif cluster_handler.name == CLUSTER_HANDLER_IDENTIFY: self._device.identify_ch = cluster_handler - elif cluster_handler.name == const.CLUSTER_HANDLER_BASIC: + elif cluster_handler.name == CLUSTER_HANDLER_BASIC: self._device.basic_ch = cluster_handler self._all_cluster_handlers[cluster_handler.id] = cluster_handler @@ -164,7 +168,7 @@ def add_client_cluster_handlers(self) -> None: for ( cluster_id, cluster_handler_class, - ) in registries.CLIENT_CLUSTER_HANDLER_REGISTRY.items(): + ) in CLIENT_CLUSTER_HANDLER_REGISTRY.items(): cluster = self.zigpy_endpoint.out_clusters.get(cluster_id) if cluster is not None: cluster_handler = cluster_handler_class(cluster, self) @@ -215,28 +219,32 @@ def async_new_entity( **kwargs: Any, ) -> None: """Create a new entity.""" - from .device import DeviceStatus # pylint: disable=import-outside-toplevel + from zha.zigbee.device import ( # pylint: disable=import-outside-toplevel + DeviceStatus, + ) if self.device.status == DeviceStatus.INITIALIZED: return - zha_data = get_zha_data(self.device.hass) - zha_data.platforms[platform].append( - (entity_class, (unique_id, self.device, cluster_handlers), kwargs or {}) + self.device.gateway.config.platforms[platform].append( + ( + entity_class, + (unique_id, cluster_handlers, self, self.device), + kwargs or {}, + ) ) - @callback - def async_send_signal(self, signal: str, *args: Any) -> None: + def emit_propagated_event(self, signal: str, *args: Any) -> None: """Send a signal through hass dispatcher.""" - async_dispatcher_send(self.device.hass, signal, *args) + self.device.emit(signal, *args) - def send_event(self, signal: dict[str, Any]) -> None: + def emit_zha_event(self, event_data: dict[str, Any]) -> None: """Broadcast an event from this endpoint.""" - self.device.zha_send_event( + self.device.emit_zha_event( { const.ATTR_UNIQUE_ID: self.unique_id, const.ATTR_ENDPOINT_ID: self.id, - **signal, + **event_data, } ) diff --git a/zha/zigbee/group.py b/zha/zigbee/group.py index 4d26a7d9..2d764fa5 100644 --- a/zha/zigbee/group.py +++ b/zha/zigbee/group.py @@ -3,56 +3,58 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import async_entries_for_device -import zigpy.endpoint import zigpy.exceptions -import zigpy.group from zigpy.types.named import EUI64 -from .helpers import LogMixin +from zha.application.platforms import EntityStateChangedEvent, PlatformEntity +from zha.const import STATE_CHANGED +from zha.mixins import LogMixin if TYPE_CHECKING: - from .device import ZHADevice - from .gateway import ZHAGateway + from zigpy.group import Group as ZigpyGroup, GroupEndpoint + + from zha.application.gateway import Gateway + from zha.application.platforms import GroupEntity + from zha.zigbee.device import Device _LOGGER = logging.getLogger(__name__) -class GroupMember(NamedTuple): +@dataclass(frozen=True, kw_only=True) +class GroupMemberReference: """Describes a group member.""" ieee: EUI64 endpoint_id: int -class GroupEntityReference(NamedTuple): +@dataclass(frozen=True, kw_only=True) +class GroupEntityReference: """Reference to a group entity.""" - name: str | None - original_name: str | None entity_id: int + name: str | None = None + original_name: str | None = None -class ZHAGroupMember(LogMixin): +class GroupMember(LogMixin): """Composite object that represents a device endpoint in a Zigbee group.""" - def __init__( - self, zha_group: ZHAGroup, zha_device: ZHADevice, endpoint_id: int - ) -> None: + def __init__(self, zha_group: Group, device: Device, endpoint_id: int) -> None: """Initialize the group member.""" - self._zha_group = zha_group - self._zha_device = zha_device - self._endpoint_id = endpoint_id + self._group: Group = zha_group + self._device: Device = device + self._endpoint_id: int = endpoint_id @property - def group(self) -> ZHAGroup: + def group(self) -> Group: """Return the group this member belongs to.""" - return self._zha_group + return self._group @property def endpoint_id(self) -> int: @@ -60,14 +62,14 @@ def endpoint_id(self) -> int: return self._endpoint_id @property - def endpoint(self) -> zigpy.endpoint.Endpoint: + def endpoint(self) -> GroupEndpoint: """Return the endpoint for this group member.""" - return self._zha_device.device.endpoints.get(self.endpoint_id) + return self._device.device.endpoints.get(self.endpoint_id) @property - def device(self) -> ZHADevice: + def device(self) -> Device: """Return the ZHA device for this group member.""" - return self._zha_device + return self._device @property def member_info(self) -> dict[str, Any]: @@ -79,42 +81,20 @@ def member_info(self) -> dict[str, Any]: return member_info @property - def associated_entities(self) -> list[dict[str, Any]]: + def associated_entities(self) -> list[PlatformEntity]: """Return the list of entities that were derived from this endpoint.""" - entity_registry = er.async_get(self._zha_device.hass) - zha_device_registry = self.device.gateway.device_registry - - entity_info = [] - - for entity_ref in zha_device_registry.get(self.device.ieee): - # We have device entities now that don't leverage cluster handlers - if not entity_ref.cluster_handlers: - continue - entity = entity_registry.async_get(entity_ref.reference_id) - handler = list(entity_ref.cluster_handlers.values())[0] - - if ( - entity is None - or handler.cluster.endpoint.endpoint_id != self.endpoint_id - ): - continue - - entity_info.append( - GroupEntityReference( - name=entity.name, - original_name=entity.original_name, - entity_id=entity_ref.reference_id, - )._asdict() - ) - - return entity_info + return [ + platform_entity + for platform_entity in self._device.platform_entities.values() + if platform_entity.endpoint.id == self.endpoint_id + ] async def async_remove_from_group(self) -> None: """Remove the device endpoint from the provided zigbee group.""" try: - await self._zha_device.device.endpoints[ - self._endpoint_id - ].remove_from_group(self._zha_group.group_id) + await self._device.device.endpoints[self._endpoint_id].remove_from_group( + self._group.group_id + ) except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( ( @@ -122,31 +102,41 @@ async def async_remove_from_group(self) -> None: " ex: %s" ), self._endpoint_id, - self._zha_device.ieee, - self._zha_group.group_id, + self._device.ieee, + self._group.group_id, str(ex), ) + def to_json(self) -> dict[str, Any]: + """Get group info.""" + member_info: dict[str, Any] = {} + member_info["endpoint_id"] = self.endpoint_id + member_info["device"] = self.device.zha_device_info + member_info["entities"] = { + entity.unique_id: entity.to_json() for entity in self.associated_entities + } + return member_info + def log(self, level: int, msg: str, *args: Any, **kwargs) -> None: """Log a message.""" msg = f"[%s](%s): {msg}" - args = (f"0x{self._zha_group.group_id:04x}", self.endpoint_id) + args + args = (f"0x{self._group.group_id:04x}", self.endpoint_id) + args _LOGGER.log(level, msg, *args, **kwargs) -class ZHAGroup(LogMixin): +class Group(LogMixin): """ZHA Zigbee group object.""" def __init__( self, - hass: HomeAssistant, - zha_gateway: ZHAGateway, + gateway: Gateway, zigpy_group: zigpy.group.Group, ) -> None: """Initialize the group.""" - self.hass = hass - self._zha_gateway = zha_gateway + self._gateway = gateway self._zigpy_group = zigpy_group + self._group_entities: dict[str, GroupEntity] = {} + self._entity_unsubs: dict[str, Callable] = {} @property def name(self) -> str: @@ -164,83 +154,198 @@ def endpoint(self) -> zigpy.endpoint.Endpoint: return self._zigpy_group.endpoint @property - def members(self) -> list[ZHAGroupMember]: + def group_entities(self) -> dict[str, GroupEntity]: + """Return the platform entities of the group.""" + return self._group_entities + + @property + def zigpy_group(self) -> ZigpyGroup: + """Return the zigpy group.""" + return self._zigpy_group + + @property + def gateway(self) -> Gateway: + """Return the gateway for this group.""" + return self._gateway + + @property + def members(self) -> list[GroupMember]: """Return the ZHA devices that are members of this group.""" return [ - ZHAGroupMember(self, self._zha_gateway.devices[member_ieee], endpoint_id) + GroupMember(self, self._gateway.devices[member_ieee], endpoint_id) for (member_ieee, endpoint_id) in self._zigpy_group.members - if member_ieee in self._zha_gateway.devices + if member_ieee in self._gateway.devices ] - async def async_add_members(self, members: list[GroupMember]) -> None: - """Add members to this group.""" + # pylint: disable=pointless-string-statement + """ TODO verify + async def async_add_members(self, members: list[GroupMemberReference]) -> None: + #Add members to this group. if len(members) > 1: tasks = [] for member in members: tasks.append( - self._zha_gateway.devices[member.ieee].async_add_endpoint_to_group( + self._gateway.devices[member.ieee].async_add_endpoint_to_group( member.endpoint_id, self.group_id ) ) await asyncio.gather(*tasks) else: - await self._zha_gateway.devices[ - members[0].ieee - ].async_add_endpoint_to_group(members[0].endpoint_id, self.group_id) + await self._gateway.devices[members[0].ieee].async_add_endpoint_to_group( + members[0].endpoint_id, self.group_id + ) - async def async_remove_members(self, members: list[GroupMember]) -> None: - """Remove members from this group.""" + async def async_remove_members(self, members: list[GroupMemberReference]) -> None: + #Remove members from this group. if len(members) > 1: tasks = [] for member in members: tasks.append( - self._zha_gateway.devices[ - member.ieee - ].async_remove_endpoint_from_group( + self._gateway.devices[member.ieee].async_remove_endpoint_from_group( member.endpoint_id, self.group_id ) ) await asyncio.gather(*tasks) else: - await self._zha_gateway.devices[ + await self._gateway.devices[ members[0].ieee ].async_remove_endpoint_from_group(members[0].endpoint_id, self.group_id) + """ + + def register_group_entity(self, group_entity: GroupEntity) -> None: + """Register a group entity.""" + if group_entity.unique_id not in self._group_entities: + self._group_entities[group_entity.unique_id] = group_entity + self._entity_unsubs[group_entity.unique_id] = group_entity.on_event( + STATE_CHANGED, + self._maybe_update_group_members, + ) + self.update_entity_subscriptions() @property - def member_entity_ids(self) -> list[str]: - """Return the ZHA entity ids for all entities for the members of this group.""" - all_entity_ids: list[str] = [] - for member in self.members: - entity_references = member.associated_entities - for entity_reference in entity_references: - all_entity_ids.append(entity_reference["entity_id"]) - return all_entity_ids + def group_info(self) -> dict[str, Any]: + """Get ZHA group info.""" + group_info: dict[str, Any] = {} + group_info["group_id"] = self.group_id + group_info["name"] = self.name + group_info["members"] = [member.member_info for member in self.members] + return group_info - def get_domain_entity_ids(self, domain: str) -> list[str]: - """Return entity ids from the entity domain for this group.""" - entity_registry = er.async_get(self.hass) - domain_entity_ids: list[str] = [] + async def _maybe_update_group_members(self, event: EntityStateChangedEvent) -> None: + """Update the state of the entities that make up the group if they are marked as should poll.""" + tasks = [] + platform_entities = self.get_platform_entities(event.platform) + for platform_entity in platform_entities: + if platform_entity.should_poll: + tasks.append(platform_entity.async_update()) + if tasks: + await asyncio.gather(*tasks) - for member in self.members: - if member.device.is_coordinator: - continue - entities = async_entries_for_device( - entity_registry, - member.device.device_id, - include_disabled_entities=True, + def update_entity_subscriptions(self) -> None: + """Update the entity event subscriptions. + + Loop over all the entities in the group and update the event subscriptions. Get all of the unique ids + for both the group entities and the entities that they are compositions of. As we loop through the member + entities we establish subscriptions for their events if they do not exist. We also add the entity unique id + to a list for future processing. Once we have processed all group entities we combine the list of unique ids + for group entities and the platrom entities that we processed. Then we loop over all of the unsub ids and we + execute the unsubscribe method for each one that isn't in the combined list. + """ + group_entity_ids = list(self._group_entities.keys()) + processed_platform_entity_ids = [] + for group_entity in self._group_entities.values(): + for platform_entity in self.get_platform_entities(group_entity.PLATFORM): + processed_platform_entity_ids.append(platform_entity.unique_id) + if platform_entity.unique_id not in self._entity_unsubs: + self._entity_unsubs[platform_entity.unique_id] = ( + platform_entity.on_event( + STATE_CHANGED, + group_entity.update, + ) + ) + all_ids = group_entity_ids + processed_platform_entity_ids + existing_unsub_ids = self._entity_unsubs.keys() + processed_unsubs = [] + for unsub_id in existing_unsub_ids: + if unsub_id not in all_ids: + self._entity_unsubs[unsub_id]() + processed_unsubs.append(unsub_id) + + for unsub_id in processed_unsubs: + self._entity_unsubs.pop(unsub_id) + + async def async_add_members(self, members: list[GroupMemberReference]) -> None: + """Add members to this group.""" + devices: dict[EUI64, Device] = self._gateway.devices + if len(members) > 1: + tasks = [] + for member in members: + tasks.append( + devices[member.ieee].async_add_endpoint_to_group( + member.endpoint_id, self.group_id + ) + ) + await asyncio.gather(*tasks) + else: + member = members[0] + await devices[member.ieee].async_add_endpoint_to_group( + member.endpoint_id, self.group_id ) - domain_entity_ids.extend( - [entity.entity_id for entity in entities if entity.domain == domain] + self.update_entity_subscriptions() + + async def async_remove_members(self, members: list[GroupMemberReference]) -> None: + """Remove members from this group.""" + devices: dict[EUI64, Device] = self._gateway.devices + if len(members) > 1: + tasks = [] + for member in members: + tasks.append( + devices[member.ieee].async_remove_endpoint_from_group( + member.endpoint_id, self.group_id + ) + ) + await asyncio.gather(*tasks) + else: + member = members[0] + await devices[member.ieee].async_remove_endpoint_from_group( + member.endpoint_id, self.group_id ) - return domain_entity_ids + self.update_entity_subscriptions() @property - def group_info(self) -> dict[str, Any]: + def all_member_entity_unique_ids(self) -> list[str]: + """Return all platform entities unique ids for the members of this group.""" + all_entity_unique_ids: list[str] = [] + for member in self.members: + entities = member.associated_entities + for entity in entities: + all_entity_unique_ids.append(entity.unique_id) + return all_entity_unique_ids + + def get_platform_entities(self, platform: str) -> list[PlatformEntity]: + """Return entities belonging to the specified platform for this group.""" + platform_entities: list[PlatformEntity] = [] + for member in self.members: + if member.device.is_coordinator: + continue + for entity in member.associated_entities: + if platform == entity.PLATFORM: + platform_entities.append(entity) + + return platform_entities + + def to_json(self) -> dict[str, Any]: """Get ZHA group info.""" group_info: dict[str, Any] = {} - group_info["group_id"] = self.group_id + group_info["id"] = self.group_id group_info["name"] = self.name - group_info["members"] = [member.member_info for member in self.members] + group_info["members"] = { + str(member.device.ieee): member.to_json() for member in self.members + } + group_info["entities"] = { + unique_id: entity.to_json() + for unique_id, entity in self._group_entities.items() + } return group_info def log(self, level: int, msg: str, *args: Any, **kwargs) -> None: