diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cf036809 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: + - dev + - master + pull_request: ~ + +jobs: + shared-ci: + uses: zigpy/workflows/.github/workflows/ci.yml@dm/update-ci + with: + CODE_FOLDER: zha + CACHE_VERSION: 1 + PYTHON_VERSION_DEFAULT: 3.12 + PRE_COMMIT_CACHE_PATH: ~/.cache/pre-commit + MINIMUM_COVERAGE_PERCENTAGE: 100 + PYTHON_MATRIX: "3.12" diff --git a/.github/workflows/matchers/codespell.json b/.github/workflows/matchers/codespell.json new file mode 100644 index 00000000..cfa66d31 --- /dev/null +++ b/.github/workflows/matchers/codespell.json @@ -0,0 +1,16 @@ +{ + "problemMatcher": [ + { + "owner": "codespell", + "severity": "warning", + "pattern": [ + { + "regexp": "^(.+):(\\d+):\\s(.+)$", + "file": 1, + "line": 2, + "message": 3 + } + ] + } + ] +} diff --git a/.github/workflows/matchers/flake8.json b/.github/workflows/matchers/flake8.json new file mode 100644 index 00000000..e059a1cf --- /dev/null +++ b/.github/workflows/matchers/flake8.json @@ -0,0 +1,30 @@ +{ + "problemMatcher": [ + { + "owner": "flake8-error", + "severity": "error", + "pattern": [ + { + "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "flake8-warning", + "severity": "warning", + "pattern": [ + { + "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + } + ] +} diff --git a/.github/workflows/matchers/python.json b/.github/workflows/matchers/python.json new file mode 100644 index 00000000..3e5d8d5b --- /dev/null +++ b/.github/workflows/matchers/python.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "python", + "pattern": [ + { + "regexp": "^\\s*File\\s\\\"(.*)\\\",\\sline\\s(\\d+),\\sin\\s(.*)$", + "file": 1, + "line": 2 + }, + { + "regexp": "^\\s*raise\\s(.*)\\(\\'(.*)\\'\\)$", + "message": 2 + } + ] + } + ] +} diff --git a/.github/workflows/matchers/ruff.json b/.github/workflows/matchers/ruff.json new file mode 100644 index 00000000..4411fc5e --- /dev/null +++ b/.github/workflows/matchers/ruff.json @@ -0,0 +1,30 @@ +{ + "problemMatcher": [ + { + "owner": "ruff-error", + "severity": "error", + "pattern": [ + { + "regexp": "^(.*):(\\d+):(\\d+):\\s([EF]\\d{3}\\s.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + }, + { + "owner": "ruff-warning", + "severity": "warning", + "pattern": [ + { + "regexp": "^(.*):(\\d+):(\\d+):\\s([CDNW]\\d{3}\\s.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + } + ] + } + ] + } \ No newline at end of file diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml new file mode 100644 index 00000000..3d931cf8 --- /dev/null +++ b/.github/workflows/publish-to-pypi.yml @@ -0,0 +1,12 @@ +name: Publish distributions to PyPI + +on: + release: + types: + - published + +jobs: + shared-build-and-publish: + uses: zigpy/workflows/.github/workflows/publish-to-pypi.yml@main + secrets: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.gitignore b/.gitignore index 68bc17f9..e05e2e7d 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,4 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..ce2b716e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + additional_dependencies: [tomli] + args: ["--toml", "pyproject.toml"] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 + hooks: + - id: mypy + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.3.2 + hooks: + - id: ruff + args: ["--fix", "--exit-non-zero-on-fix", "--config", "pyproject.toml"] diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..8a5d7d48 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "esbenp.prettier-vscode", + "ms-python.python" + ] +} diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json new file mode 100644 index 00000000..8cf4fe92 --- /dev/null +++ b/.vscode/settings.default.json @@ -0,0 +1,4 @@ +{ + "python.testing.pytestArgs": ["--no-cov"], + "python.testing.pytestEnabled": false +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e0924234 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.pytestEnabled": true, + "editor.formatOnSave": true, + "python.testing.unittestEnabled": false +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..bcef8755 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,469 @@ +[build-system] +requires = ["setuptools>=61.0.0", "wheel", "setuptools-git-versioning<2"] +build-backend = "setuptools.build_meta" + +[project] +name = "zha" +dynamic = ["version"] +description = "Library implementing ZHA for Home Assistant" +urls = {repository = "https://github.com/zigpy/zha"} +authors = [ + {name = "David F. Mulcahey", email = "david.mulcahey@icloud.com"} +] +readme = "README.md" +license = {text = "GPL-3.0"} +requires-python = ">=3.12" +dependencies = [ + "zigpy>=0.63.5", +] + +[tool.setuptools.packages.find] +exclude = ["tests", "tests.*"] + +[project.optional-dependencies] +testing = [ + "pytest", +] + +[tool.setuptools-git-versioning] +enabled = true + +[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.EXCEPTIONS] +overgeneral-exceptions = [ + "builtins.BaseException", + "builtins.Exception", +] + +[tool.pylint.TYPING] +runtime-typing = false + +[tool.pylint.CODE_STYLE] +max-line-length-suggestions = 72 + +[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" + +[tool.ruff.lint] +select = [ + "B002", # Python does not support the unary prefix increment + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B904", # Use raise from to specify exception cause + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "G", # flake8-logging-format + "I", # isort + "ICN001", # import concentions; {name} should be imported as {asname} + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF101", # Do not cast an iterable to list before iterating over it + "PERF102", # When using only the {subset} of a dict use the {subset}() method + "PERF203", # try-except within a loop incurs performance overhead + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "PLC", # pylint + "PLE", # pylint + "PLR", # pylint + "PLW", # pylint + "Q000", # Double quotes found but single quotes preferred + "RUF006", # Store a reference to the return value of asyncio.create_task + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S320", # suspicious-xmle-tree-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM114", # Combine if branches using logical or operator + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM208", # Use {expr} instead of not (not {expr}) + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T100", # Trace found: {name} used + "T20", # flake8-print + "TID251", # Banned imports + "TRY004", # Prefer TypeError exception for invalid type + "TRY302", # Remove exception handler; error is immediately re-raised + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + "E731", # do not assign a lambda expression, use a def + + # Ignore ignored, as the rule is now back in preview/nursery, which cannot + # be ignored anymore without warnings. + # https://github.com/astral-sh/ruff/issues/7491 + # "PLC1901", # Lots of false positives + + # False positives https://github.com/astral-sh/ruff/issues/5386 + "PLC0208", # Use a sequence type instead of a `set` when iterating over values + "PLR0911", # Too many return statements ({returns} > {max_returns}) + "PLR0912", # Too many branches ({branches} > {max_branches}) + "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) + "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "UP006", # keep type annotation style as is + "UP007", # keep type annotation style as is + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", +] + +[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.isort] +force-sort-within-sections = true +known-first-party = [ + "zha", + "tests", +] +combine-as-imports = true +split-on-trailing-comma = false + +[tool.ruff.lint.per-file-ignores] + +# Allow for main entry & scripts to write to stdout +"script/*" = ["T20"] + +[tool.ruff.lint.mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 00000000..8b3e562f --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,12 @@ +codecov +colorlog +codespell +mypy +pre-commit +pytest-cov +pytest-sugar +pytest-timeout +pytest-asyncio +pytest +zigpy>=0.63.5 +ruff diff --git a/script/setup b/script/setup new file mode 100755 index 00000000..7dead73e --- /dev/null +++ b/script/setup @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Setup the repository. + +# Stop on errors +set -e + +cd "$(dirname "$0")/.." + +# Add default vscode settings if not existing +SETTINGS_FILE=./.vscode/settings.json +SETTINGS_TEMPLATE_FILE=./.vscode/settings.default.json +if [ ! -f "$SETTINGS_FILE" ]; then + echo "Copy $SETTINGS_TEMPLATE_FILE to $SETTINGS_FILE." + cp "$SETTINGS_TEMPLATE_FILE" "$SETTINGS_FILE" +fi + +curl -LsSf https://astral.sh/uv/install.sh | sh +uv venv venv +. venv/bin/activate +uv pip install -U pip setuptools pre-commit +uv pip install -r requirements_test.txt +uv pip install -e . + +pre-commit install diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..059ed2d4 --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +"""Setup file for zha.""" + +import setuptools + +if __name__ == "__main__": + setuptools.setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..adfa6fae --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests modules.""" diff --git a/zha/__init__.py b/zha/__init__.py new file mode 100644 index 00000000..280b81d0 --- /dev/null +++ b/zha/__init__.py @@ -0,0 +1 @@ +"""zigbee home automation."""