diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100755 index 0000000..8329376 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,51 @@ +{ + "name": "Gree HA Climate (dev)", + + // mcr.microsoft.com/devcontainers/python already bundles zsh, Oh My Zsh, + // a non-root "vscode" user with sudo, git, and pipx - no need to layer + // the python/common-utils features on top of a bare base image. + // + // Match the Python version to whatever python_requires the *current* + // home-assistant/core pyproject.toml declares. Check before first build: + // https://github.com/home-assistant/core/blob/dev/pyproject.toml + // Available tags: https://mcr.microsoft.com/en-us/artifact/mar/devcontainers/python/tags + "image": "mcr.microsoft.com/devcontainers/python:3.14-bookworm", + "runArgs": ["--name", "ha-gree-dev"], + + "postCreateCommand": "bash .devcontainer/postCreate.sh", + + "forwardPorts": [8123], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + + "customizations": { + "vscode": { + "extensions": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.pylint", + "ms-python.mypy-type-checker", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "lokalise.i18n-ally" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "python.analysis.enableEditableInstalls": true, + + "pylint.path": ["${workspaceFolder}/.venv/bin/pylint"], + "mypy-type-checker.path": ["${workspaceFolder}/.venv/bin/mypy"] + } + } + }, + + "remoteUser": "vscode" +} diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100755 index 0000000..62b140c --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Runs once when the devcontainer is created (or on "Rebuild Container"). +set -euo pipefail + +cd "$(dirname "$0")/.." # repo root + +# Requirements mirrored from official HA Devcontainer +echo "==> Installing system dependencies" +sudo apt-get update +sudo apt-get install -y --no-install-recommends bluez ffmpeg libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev libpcap-dev libturbojpeg0 libyaml-dev libxml2 git cmake autoconf +sudo apt-get clean +sudo rm -rf /var/lib/apt/lists/* + +echo "==> Installing go2rtc binary (needed by default_config:, not available via apt)" +GO2RTC_ARCH="$(dpkg --print-architecture)" # amd64 | arm64 +case "$GO2RTC_ARCH" in + amd64) GO2RTC_ASSET="go2rtc_linux_amd64" ;; + arm64) GO2RTC_ASSET="go2rtc_linux_arm64" ;; + *) echo " (unrecognized arch '$GO2RTC_ARCH', skipping go2rtc install)"; GO2RTC_ASSET="" ;; +esac +if [ -n "$GO2RTC_ASSET" ]; then + sudo curl -fL "https://github.com/AlexxIT/go2rtc/releases/latest/download/${GO2RTC_ASSET}" \ + -o /usr/local/bin/go2rtc + sudo chmod +x /usr/local/bin/go2rtc +fi + +echo "==> Ensuring Python virtual environment exists" + +if [ -d ".venv" ]; then + echo "==> Reusing existing virtual environment" +else + echo "==> Creating new virtual environment" + python3 -m venv .venv +fi + +echo "==> Activate VENV" +source .venv/bin/activate + +echo "==> Upgrading pip/setuptools/wheel" +python -m pip install --upgrade pip setuptools wheel + + +HA_SRC_DIR=".ha-core" + +echo "==> Ensuring Home Assistant core is available" +./.devcontainer/setup-ha-repo.sh + +echo "==> Installing Home Assistant" +./.devcontainer/setup-ha-core.sh + +echo "==> Compiling Home Assistant translations" +( + cd $HA_SRC_DIR || exit 1 + python -m script.translations develop --all +) + +echo "==> Installing dev/lint tooling" +if [ -f requirements_dev.txt ]; then + python -m pip install --upgrade -r requirements_dev.txt +fi + +echo "==> Wiring up config/custom_components -> ../custom_components" +mkdir -p config +if [ ! -e config/custom_components ]; then + ln -s ../custom_components config/custom_components +fi + +echo "==> Ensuring config/configuration.yaml exists" +if [ ! -f config/configuration.yaml ]; then + # Let Home Assistant itself generate the default config directory/files - + # this is what "hass --script ensure_config" is for, and it's what + # integration_blueprint's own setup script does too. Beats hand-writing + # a configuration.yaml that can drift from what core actually defaults to. + python -m homeassistant --script ensure_config --config config + + # Add debug logging for custom_components on top of the generated default. + cat >> config/configuration.yaml <<'YAML' + +# Added by postCreate.sh +logger: + default: info + logs: + custom_components: debug +YAML +fi + +echo "==> Done. Run the 'Run Home Assistant' task, or press F5, to start." diff --git a/.devcontainer/setup-ha-core.sh b/.devcontainer/setup-ha-core.sh new file mode 100755 index 0000000..45875cb --- /dev/null +++ b/.devcontainer/setup-ha-core.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +export UV_CACHE_DIR="$PWD/.uv-cache" +HA_SRC_DIR="${HA_SRC_DIR:-.ha-core}" +VENV_DIR="${VENV_DIR:-.venv}" + +PYTHON="$VENV_DIR/bin/python" + +if [ ! -x "$PYTHON" ]; then + echo "ERROR: Python virtual environment not found at $VENV_DIR" + exit 1 +fi + +if [ ! -d "$HA_SRC_DIR" ]; then + echo "ERROR: Home Assistant core directory '$HA_SRC_DIR' does not exist." + exit 1 +fi + +echo "==> Installing Home Assistant core requirements" + +"$PYTHON" -m pip install --no-cache-dir \ + "uv==$(awk -F'==' '/^uv==/{print $2}' "$HA_SRC_DIR/requirements.txt")" + +"$PYTHON" -m uv pip install --upgrade colorlog + +"$PYTHON" -m uv pip install -r "$HA_SRC_DIR/requirements.txt" +"$PYTHON" -m uv pip install -r "$HA_SRC_DIR/requirements_test.txt" +"$PYTHON" -m uv pip install -r "$HA_SRC_DIR/requirements_all.txt" + +echo "==> Installing Home Assistant core" +"$PYTHON" -m uv pip install --upgrade --no-cache-dir --editable "$HA_SRC_DIR" --config-settings editable_mode=compat \ No newline at end of file diff --git a/.devcontainer/setup-ha-repo.sh b/.devcontainer/setup-ha-repo.sh new file mode 100755 index 0000000..697deed --- /dev/null +++ b/.devcontainer/setup-ha-repo.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +HA_SRC_DIR="${HA_SRC_DIR:-.ha-core}" + +if [ -d "$HA_SRC_DIR/.git" ]; then + echo "==> Home Assistant core already cloned, updating" + git -C "$HA_SRC_DIR" fetch --depth 1 origin dev + git -C "$HA_SRC_DIR" checkout dev + git -C "$HA_SRC_DIR" reset --hard origin/dev +else + echo "==> Cloning Home Assistant core from the 'dev' branch" + git clone --progress --depth 1 --branch dev \ + https://github.com/home-assistant/core.git "$HA_SRC_DIR" +fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2483976..e2030dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,8 @@ .idea/ __pycache__/ +.venv/ +.mypy_cache/ +.ruff_cache/ +config/ +.ha-core/ +.uv-cache/ \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100755 index 0000000..5bbeeef --- /dev/null +++ b/.pylintrc @@ -0,0 +1,313 @@ +# Keep aligned with HA core pyproject.toml + +[MAIN] +py-version=3.14 +jobs=2 +persistent=no +fail-on=I + +init-hook=import sys; from pathlib import Path; sys.path.insert(0, str(Path(".ha-core/pylint/plugins").resolve())) + +load-plugins= + pylint.extensions.code_style, + pylint.extensions.typing, + pylint_home_assistant, + +extension-pkg-allow-list= + av.audio.stream, + av.logging, + av.stream, + ciso8601, + orjson, + cv2 + +[BASIC] +class-const-naming-style=any + +[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 +# possibly-used-before-assignment - too many errors / not necessarily issues +# --- +# 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, + too-many-positional-arguments, + wrong-import-order, + consider-using-namedtuple-or-dataclass, + consider-using-assignment-expr, + possibly-used-before-assignment, + + # Disabled while existing violations are being cleaned up + home-assistant-unused-test-fixture-argument, + + # Handled by ruff + await-outside-async, + bad-str-strip-call, + bad-string-format-type, + bidirectional-unicode, + continue-in-finally, + duplicate-bases, + misplaced-bare-raise, + format-needs-mapping, + function-redefined, + invalid-all-object, + invalid-character-backspace, + invalid-character-esc, + invalid-character-nul, + invalid-character-sub, + invalid-character-zero-width-space, + logging-too-few-args, + logging-too-many-args, + missing-format-string-key, + mixed-format-string, + no-method-argument, + no-self-argument, + nonexistent-operator, + nonlocal-without-binding, + not-in-loop, + notimplemented-raised, + return-in-init, + return-outside-function, + syntax-error, + too-few-format-args, + too-many-format-args, + too-many-star-expressions, + truncated-format-string, + undefined-all-variable, + undefined-variable, + used-prior-global-declaration, + yield-inside-async-function, + yield-outside-function, + anomalous-backslash-in-string, + assert-on-string-literal, + assert-on-tuple, + bad-format-string, + bad-format-string-key, + bare-except, + binary-op-exception, + cell-var-from-loop, + duplicate-except, + duplicate-key, + duplicate-string-formatting-argument, + duplicate-value, + eval-used, + exec-used, + expression-not-assigned, + f-string-without-interpolation, + forgotten-debug-statement, + format-string-without-interpolation, + global-variable-not-assigned, + implicit-str-concat, + import-outside-toplevel, + import-self, + inconsistent-quotes, + invalid-envvar-default, + keyword-arg-before-vararg, + logging-format-interpolation, + logging-fstring-interpolation, + logging-not-lazy, + misplaced-future, + named-expr-without-context, + nested-min-max, + pointless-statement, + raise-missing-from, + redefined-builtin, + try-except-raise, + unused-argument, + unused-format-string-argument, + unused-format-string-key, + unused-import, + unused-variable, + useless-else-on-loop, + wildcard-import, + bad-classmethod-argument, + consider-iterating-dictionary, + empty-docstring, + invalid-name, + line-too-long, + missing-class-docstring, + missing-final-newline, + missing-function-docstring, + missing-module-docstring, + multiple-imports, + singleton-comparison, + subprocess-run-check, + superfluous-parens, + ungrouped-imports, + unidiomatic-typecheck, + unnecessary-direct-lambda-call, + unnecessary-lambda-assignment, + unnecessary-pass, + unneeded-not, + useless-import-alias, + wrong-import-order, + wrong-import-position, + comparison-of-constants, + comparison-with-itself, + consider-alternative-union-syntax, + consider-merging-isinstance, + consider-using-alias, + consider-using-dict-comprehension, + consider-using-generator, + consider-using-get, + consider-using-set-comprehension, + consider-using-sys-exit, + consider-using-ternary, + literal-comparison, + property-with-parameters, + super-with-arguments, + too-many-branches, + too-many-return-statements, + too-many-statements, + trailing-comma-tuple, + unnecessary-comprehension, + use-a-generator, + use-dict-literal, + use-list-literal, + useless-object-inheritance, + useless-return, + no-else-break, + no-else-continue, + no-else-raise, + no-else-return, + broad-except, + protected-access, + broad-exception-raised, + consider-using-f-string, + + # Handled by mypy + 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= + use-symbolic-message-instead + +[REPORTS] +score=no + +[TYPECHECK] +ignored-classes=_CountingAttr +mixin-class-rgx=.*[Mm]ix[Ii]n + +[FORMAT] +expected-line-ending-format=LF + +[EXCEPTIONS] +overgeneral-exceptions= + builtins.BaseException, + builtins.Exception + +[TYPING] +runtime-typing=no + +[CODE_STYLE] +max-line-length-suggestions=72 + +[DESIGN] +max-args=10 diff --git a/.ruff.toml b/.ruff.toml new file mode 100755 index 0000000..80f1750 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,155 @@ +target-version = "py314" + +[lint] +select = [ + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC", # flake8-async + "B", # flake8-bugbear + "BLE", + "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) + "DTZ011", # Use datetime.now(tz=).date() instead of date.today() + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N806", # Variable {name} in function should be snake_case + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise + "RUF", # Ruff-specific rules (see `ignore` for exclusions) + "S107", # Possible hardcoded password assigned to function default + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S301", # suspicious-pickle-usage + "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 + "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 + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TC", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle +] + +ignore = [ + "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead + "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop + "ASYNC240", # Use an async function for entering the file system + "B008", # Do not perform function call in argument defaults; commonly used in Home Assistant (e.g. cv.* validators) + "B019", # Use of functools.lru_cache or functools.cache on methods can lead to memory leaks + "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 + "D417", # Missing argument descriptions in docstring - to allow documenting only non-obvious parameters + "E501", # line too long + + "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives + "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}) + "PLR0917", # Too many positional arguments defined for a function ({p_args} > {max_args}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW0108", # Unnecessary lambda wrapping a function call; can often be replaced by the function itself + "PLW1641", # __eq__ without __hash__ + "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target + "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception + "PT018", # Assertion should be broken down into multiple parts + "RUF001", # String contains ambiguous unicode character. + "RUF012", # Mutable class attributes should be annotated with typing.ClassVar + "RUF015", # Prefer next(...) over single element slice + "RUF043", # Pattern passed to match= contains metacharacters but is neither escaped nor raw + "SIM102", # Use a single if statement instead of nested if statements + "SIM103", # Return the condition {condition} directly + "SIM108", # Use ternary operator {contents} instead of if-else-block + "SIM115", # Use context handler for opening files + + # Moving imports into type-checking blocks can mess with pytest.patch() + "TC001", # Move application import {} into a type-checking block + "TC002", # Move third-party import {} into a type-checking block + "TC003", # Move standard library import {} into a type-checking block + # Quotes for typing.cast generally not necessary, only for performance critical paths + "TC006", # Add quotes to type expression in typing.cast() + + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + + "UP047", # Non PEP 696 generic function + "UP049", # Avoid private type parameter names + + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605", + + "FURB116", + + # Disabled to implement in follow up PRs after ruff 0.16 bump + "ISC004", + "LOG004", +] + +[lint.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" +"pytz".msg = "use zoneinfo instead" +"tests".msg = "You should not import tests" +"__future__.annotations".msg = "It should not be needed because Home Assistant requires Python 3.14+" + +[lint.isort] +force-sort-within-sections = true +known-first-party = ["homeassistant"] +combine-as-imports = true +split-on-trailing-comma = false + +[format] +quote-style = "double" diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100755 index 0000000..409e4a9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Home Assistant (debug my integration)", + "type": "debugpy", + "request": "launch", + "module": "homeassistant", + "cwd": "${workspaceFolder}", + "args": ["--config", "${workspaceFolder}/config", "--debug"], + // HA core is pip-installed into site-packages; custom_components lives + // in the workspace (via the symlink). debugpy's default justMyCode + // treats anything under site-packages/stdlib as "not mine", so this + // alone scopes stepping/uncaught-exception breaks to your integration + // - no extra config needed. Explicit breakpoints in HA core still work + // regardless of this setting; it only affects automatic stepping. + "justMyCode": true + }, + { + "name": "Home Assistant (no debug filtering, full core)", + "type": "debugpy", + "request": "launch", + "module": "homeassistant", + "cwd": "${workspaceFolder}", + "args": ["--config", "${workspaceFolder}/config", "--debug"], + "justMyCode": false + }, + { + "name": "Attach to running Home Assistant (debugpy)", + "type": "debugpy", + "request": "attach", + "connect": { "host": "localhost", "port": 5678 }, + "justMyCode": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..160f55e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,39 @@ +{ + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + + "editor.tabSize": 4, + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": false, + + "python.analysis.typeCheckingMode": "basic", + "python.analysis.autoImportCompletions": true, + + "pylint.args": ["--rcfile=${workspaceFolder}/.pylintrc"], + "mypy-type-checker.args": ["--config-file=${workspaceFolder}/mypy.ini"], + + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + + "i18n-ally.localesPaths": ["custom_components/gree_custom/translations"], + "i18n-ally.editor.preferEditor": true, + "i18n-ally.enabledFrameworks": ["general"], + "i18n-ally.enabledParsers": ["json"], + "i18n-ally.keystyle": "nested", + "i18n-ally.indent": 4, + "i18n-ally.sourceLanguage": "en", + + "yaml.customTags": [ + "!include scalar", + "!include_dir_list scalar", + "!include_dir_named scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar", + "!secret scalar" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100755 index 0000000..64ad7c5 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,113 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant", + "type": "shell", + "dependsOn": "HA: Compile translations", + "command": "${command:python.interpreterPath}", + "args": [ + "-m", + "homeassistant", + "--config", + "${workspaceFolder}/config" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": { "kind": "build", "isDefault": true }, + "presentation": { "reveal": "always", "panel": "dedicated" } + }, + { + "label": "HA: Compile translations", + "type": "process", + "command": "${command:python.interpreterPath}", + "args": ["-m", "script.translations", "develop", "--all"], + "options": { + "cwd": "${workspaceFolder}/.ha-core" + }, + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": false + }, + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + } + }, + { + "label": "HA: Check config", + "type": "shell", + "command": "${command:python.interpreterPath} -m homeassistant --config ${workspaceFolder}/config --script check_config", + "problemMatcher": [] + }, + { + "label": "Fetch Home Assistant core", + "type": "shell", + "command": "${workspaceFolder}/.devcontainer/setup-ha-repo.sh", + "problemMatcher": [], + "detail": "Fetches the latest Home Assistant core dev branch into the persistent .ha-core checkout." + }, + { + "label": "Update Home Assistant to latest dev branch", + "type": "shell", + "dependsOn": "Fetch Home Assistant core", + "command": "${workspaceFolder}/.devcontainer/setup-ha-core.sh", + "problemMatcher": [], + "detail": "Installs the fetched Home Assistant core checkout and its dependencies as an editable package." + }, + { + "label": "Ruff: check", + "type": "shell", + "command": "ruff check custom_components", + "options": { + "env": { + "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}" + } + }, + "problemMatcher": [] + }, + { + "label": "Ruff: format", + "type": "shell", + "command": "ruff format custom_components", + "options": { + "env": { + "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}" + } + }, + "problemMatcher": [] + }, + { + "label": "Pylint", + "type": "shell", + "command": "pylint custom_components", + "options": { + "env": { + "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}" + } + }, + "problemMatcher": [] + }, + { + "label": "Mypy", + "type": "shell", + "command": "mypy custom_components", + "options": { + "env": { + "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}" + } + }, + "problemMatcher": [] + }, + { + "label": "Reinstall dev tooling", + "type": "shell", + "command": "${command:python.interpreterPath} -m pip install --upgrade -r requirements_dev.txt", + "problemMatcher": [] + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e27e6ad..673dad1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,33 +4,24 @@ This integration follows the development guidelines for Home Assistant integrati ## Development Environment -Home Assistant provides [several guidelines](https://developers.home-assistant.io/docs/development_environment) regarding the setup of the development environment. Because we are not contributing to the official integrations, there is no need to fork the official [Home Assistant Core](https://github.com/home-assistant/core) repository. However, it is useful to use it as it provides a preconfigured VSCode development environment with the necessary tools. - -Here's a general guide to get it working with this integration repository: - -1. Create a folder for the development (for example `development/`) -2. Clone [home-assistant/core ](https://github.com/home-assistant/core) inside of it (`development/core`) -3. Follow the [guidelines](https://developers.home-assistant.io/docs/development_environment) on getting the devcontainer working -4. Fork [this](https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent) repository and clone your fork inside of the same folder (`development/YourForkName`) -5. Create a branch for your changes in the cloned repo `git checkout -b my-branch-name` -6. Create a mount point for this integration in the devcontainer - 1. Open `development/core/devcontainer/devcontainer.json` - 2. Add the mounting: - ```json - "mounts": [ - "source=${localWorkspaceFolder}/../YourForkName/custom_components/gree_custom,target=/workspaces/core/config/custom_components/gree_custom,type=bind" - ], - ``` -7. Open `development/core` with VSCode -8. Use the command **"Dev Containers: Reopen in Container"** -9. Once inside the container make sure the folder `config/custom_components/gree_custom` exists -10. You should be now be able to edit the integration files from inside the devcontainer -11. Make your changes -12. Push to your fork, rebase with the latest upstream version and submit a pull request +Home Assistant provides [several guidelines](https://developers.home-assistant.io/docs/development_environment) regarding the setup of the development environment. Because we are not contributing to the official integrations, there is no need to fork the official [Home Assistant Core](https://github.com/home-assistant/core) repository. + +This repository includes a DevContainer to improve the development experience. To use it ensure your are using a machine that support DevContainers + + +1. Fork [this](https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent) repository and clone your fork (for example at `gree-ha-climate`) +2. Create a branch for your changes in the cloned repo `git checkout -b my-branch-name` +3. Open `gree-ha-climate` with VSCode, locally or with the Remote SSH extension +4. Use the command **"Dev Containers: Reopen in Container"** + * It takes a few minutes to create the container for the first time +5. Once inside the container you should be able to see the repo files and have a working development environment +6. Make your changes + * Use the provided Tasks to Run HomeAssistant and debug the integration as required +7. Push to your fork, rebase with the latest upstream version and submit a pull request ## Testing -Use the **Run Home Assistant Core** Task to start Home Assistant. +Use the **Run Home Assistant** Task to start Home Assistant. You should also be able to set and hit breakpoints in your code. diff --git a/README.md b/README.md index 3668c86..220e350 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,13 @@ Depending on the device configuration, specific Gree AC model, and firmware vers ## Credits +Portions of the code development, debugging, and documentation were performed by Large Language Models (LLMs) + This project is based on the work of several contributors and projects: +- [greeclimate-js](https://github.com/davo22/greeclimate-js) - TypeScript library for controlling Gree-based mini-split air conditioning systems +- [greeclimate](https://github.com/davo22/greeclimate) - A fully async Python3 based package for controlling Gree based ACs and heat pumps - [gree-remote](https://github.com/tomikaa87/gree-remote) - Gree air conditioner remote control protocol - [greeclimate](https://github.com/cmroche/greeclimate) - Python package for controlling Gree based minisplit systems -- [Home Assistant Developer Documentation](https://developers.home-assistant.io/) - Official development guidelines and best practices +- [gree-api-client](https://github.com/luc10/gree-api-client) - Python client for the Gree API +- [Home Assistant Developer Documentation](https://developers.home-assistant.io) - Official development guidelines and best practices diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index 11365b5..41118eb 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -1,47 +1,71 @@ """Gree climate integration init.""" # Standard library imports +import json import logging +from typing import Any + +from aiomqtt.exceptions import MqttConnectError from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_EMAIL, CONF_HOST, - CONF_MAC, + CONF_NAME, CONF_PORT, + CONF_REGION, CONF_SCAN_INTERVAL, CONF_TIMEOUT, + CONF_TOKEN, Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import Any, ConfigType +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers.typing import ConfigType +from .aiogree.cipher import EncryptionVersion +from .aiogree.cloud_api import GreeRegion from .aiogree.device import GreeDevice -from .aiogree.errors import GreeBindingError, GreeConnectionError +from .aiogree.errors import GreeConnectionError +from .aiogree.transport_mqtt import GreeMqttTransport +from .aiogree.transport_udp import GreeUdpTransport # Local imports from .const import ( - CONF_ADVANCED, - CONF_DEV_NAME, + CONF_CLOUD, + CONF_DEVICE_CONNECTION, + CONF_DEVICE_CONNECTION_CLOUD, + CONF_DEVICE_CONNECTION_LOCAL, + CONF_DEVICE_OPTIONS, CONF_DEVICES, + CONF_DISABLE_AVAILABLE_CHECK, CONF_ENCRYPTION_KEY, CONF_ENCRYPTION_VERSION, + CONF_MAC_CONTROLLER_CLOUD, + CONF_MAC_CONTROLLER_LOCAL, CONF_MAX_ONLINE_ATTEMPTS, + CONF_PREFER_CLOUD, + CONF_RESTORE_STATES, CONF_UID, + CONFENTRY_ID_LOCAL_ONLY, DEFAULT_CONNECTION_MAX_ATTEMPTS, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, + DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_ENCRYPTION_VERSION, + DEFAULT_PREFER_CLOUD, + DEFAULT_RESTORE_STATES, DEFAULT_SCAN_INTERVAL, DOMAIN, + ENCRYPTION_VERSION_AUTO, ) from .coordinator import GreeConfigEntry, GreeCoordinator from .helpers import try_find_new_ip from .services import async_setup_services +ISSUE_DEVICE_CONNECTION_FAILED = "device_connection_failed" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -72,91 +96,155 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: # noqa: C901 """Set up Gree from a config entry.""" _LOGGER.info( - "Setup entry '%s': %s at %s", + "Setup entry '%s': %s", entry.entry_id, - entry.data[CONF_MAC], - entry.data[CONF_HOST], + entry.title, ) _LOGGER.debug( - "Setup entry '%s': %s\ndata=%s", + "Setup entry '%s': data=%s", entry.entry_id, - entry, - async_redact_data(entry.data, ["encryption_key"]), + json.dumps(async_redact_data(entry.data, ["encryption_key"])), ) conf = entry.data - if ( - conf is None - or conf[CONF_MAC] is None - or conf[CONF_HOST] is None - or conf[CONF_ADVANCED] is None - ): - _LOGGER.error("Bad config entry, this should not happen") - return False + if conf is None or not conf[CONF_DEVICES]: + _LOGGER.info("No devices configured in entry %s", entry.unique_id) + return True + cloud_conf = conf.get(CONF_CLOUD) or {} + device_configs: dict[str, Any] = conf[CONF_DEVICES] coordinators: dict[str, GreeCoordinator] = {} - for d in conf.get(CONF_DEVICES, []): - mac = str(d.get(CONF_MAC, "")) + "@" + conf.get(CONF_MAC) + mqtt_transport: GreeMqttTransport | None = None + local_transports: dict[str, GreeUdpTransport] = {} + + cleanup_device_connection_issues(hass, entry.entry_id, set(device_configs.keys())) + + for mac, dev_config in device_configs.items(): + connection = dev_config.get(CONF_DEVICE_CONNECTION) + options = dev_config.get(CONF_DEVICE_OPTIONS) + if not connection or not options: + _LOGGER.error("Bad data for device %s", mac) + continue + + name = options.get(CONF_NAME) + _LOGGER.debug("Creating device %s: %s", mac, name) + + connection_local = connection.get(CONF_DEVICE_CONNECTION_LOCAL, {}) + connection_cloud = connection.get(CONF_DEVICE_CONNECTION_CLOUD, {}) + + mac_controller_local = connection_local.get(CONF_MAC_CONTROLLER_LOCAL) + mac_controller_cloud = connection_cloud.get(CONF_MAC_CONTROLLER_CLOUD) + + if not mac_controller_local and not mac_controller_cloud: + _LOGGER.error("Bad data for device %s. No controller MAC", mac) + continue + + host = connection_local.get(CONF_HOST) + port = connection_local.get(CONF_PORT, DEFAULT_DEVICE_PORT) + uid = connection.get(CONF_UID, cloud_conf.get(CONF_UID, DEFAULT_DEVICE_UID)) + disable_available_check = connection.get( + CONF_DISABLE_AVAILABLE_CHECK, DEFAULT_DISABLE_AVAILABLE_CHECK + ) + max_online_attempts = connection_local.get( + CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS + ) + timeout = connection_local.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT) + scan_interval = connection.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + prefer_cloud = connection_cloud.get(CONF_PREFER_CLOUD, DEFAULT_PREFER_CLOUD) + preferred_encryption_key = connection.get(CONF_ENCRYPTION_KEY) + encryption_version_value = connection_local.get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION + ) + preferred_local_version = ( + None + if encryption_version_value == ENCRYPTION_VERSION_AUTO + else EncryptionVersion(int(encryption_version_value)) + ) + if host and port and mac_controller_local: + if mac_controller_local not in local_transports: + local_transports[mac_controller_local] = GreeUdpTransport( + ip_addr=host, + port=port, + max_retries=max_online_attempts, + timeout=timeout, + ) + + if mac_controller_cloud and cloud_conf and not mqtt_transport: + _LOGGER.debug("Creating MQTT transport for %s", cloud_conf[CONF_EMAIL]) + + userid: int = cloud_conf.get(CONF_UID, 0) + token: str = cloud_conf.get(CONF_TOKEN, "") + region: str = cloud_conf.get(CONF_REGION, "") + + if not userid or not token or not region: + raise ConfigEntryAuthFailed("no_account_info") + + mqtt_transport = GreeMqttTransport( + user_id=str(userid), token=token, region=GreeRegion(region) + ) + device = GreeDevice( - name=d.get(CONF_DEV_NAME, "Gree HVAC"), - ip_addr=conf.get(CONF_HOST), + name=name, mac_addr=mac, - port=conf[CONF_ADVANCED].get(CONF_PORT, DEFAULT_DEVICE_PORT), - encryption_key=conf[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), - encryption_version=conf[CONF_ADVANCED].get( - CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION - ), - uid=conf[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), - max_connection_attempts=conf[CONF_ADVANCED].get( - CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS - ), - timeout=conf[CONF_ADVANCED].get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), + preferred_encryption_key=preferred_encryption_key, + user_id=uid, ) + try: - _LOGGER.debug( - "Setup entry '%s': Configuring Gree Device (%s, %s)", - entry.entry_id, - mac, - conf.get(CONF_HOST), + await device.bind_with_transport( + preferred_local_version=preferred_local_version, + local_controller_mac=mac_controller_local, + local_transport=None + if prefer_cloud + else local_transports.get(mac_controller_local), + mqtt_controller_mac=mac_controller_cloud, + mqtt_transport=mqtt_transport, ) - - try: - await device.bind_device() - except GreeConnectionError as err_inner: - if not await try_find_new_ip(hass, device, entry): - raise ConfigEntryNotReady from err_inner - await device.bind_device() - - coordinators[device.mac_address] = GreeCoordinator( - hass, entry, device, d.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + except MqttConnectError as err: + raise ConfigEntryAuthFailed("bad_credentials") from err + except GreeConnectionError: + if not await try_find_new_ip(hass, device, entry): + _LOGGER.exception("Error setting up device %s", mac) + create_device_connection_issue(hass, entry.entry_id, mac, name) + except Exception as err: + _LOGGER.exception("Error setting up device %s", mac) + create_device_connection_issue(hass, entry.entry_id, mac, name) + raise ConfigEntryNotReady from err + else: + delete_device_connection_issue(hass, entry.entry_id, mac) + + coordinators[mac] = GreeCoordinator( + hass=hass, + config_entry=entry, + scan_interval=scan_interval, + check_availability=not disable_available_check, + restore_states=options.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), + device_config=dev_config, + device=device, ) await coordinators[device.mac_address].async_config_entry_first_refresh() _LOGGER.debug("Setup entry '%s': Bound to device %s", entry.entry_id, mac) - except TimeoutError as err: - _LOGGER.exception( - "Setup entry '%s': Connection to %s timed out", entry.entry_id, mac - ) - raise ConfigEntryNotReady from err - - except GreeBindingError as err: - _LOGGER.exception( - "Setup entry '%s': Failed to bind to device %s", entry.entry_id, mac - ) - raise ConfigEntryNotReady from err + # Clear MQTT Transport if not used + if mqtt_transport and not any( + (c.device.is_bound and isinstance(c.device.transport, GreeMqttTransport)) + for c in coordinators.values() + ): + await mqtt_transport.disconnect() + entry.runtime_data = {} entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -187,8 +275,8 @@ async def async_remove_config_entry_device( await runtime_data.async_shutdown() data: dict[str, Any] = dict(config_entry.data) - device_configs: list[dict] = data.get(CONF_DEVICES, []) - new_device_configs = [d for d in device_configs if d.get(CONF_MAC) != mac] + device_configs: dict[str, Any] = data.get(CONF_DEVICES, {}) + new_device_configs = {k: v for k, v in device_configs.items() if k != mac} if len(new_device_configs) == len(device_configs): # Nothing to remove @@ -201,9 +289,66 @@ async def async_remove_config_entry_device( if new_device_configs: # There are still other devices, update the entry - await hass.config_entries.async_update_entry(config_entry, data=data) - else: - # No other devices, remove the entry + return hass.config_entries.async_update_entry(config_entry, data=data) + + # No other devices, remove the entry if local + if config_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: await hass.config_entries.async_remove(config_entry.entry_id) return True + + +def _device_issue_id(config_entry_id: str, device_id: str) -> str: + return f"{ISSUE_DEVICE_CONNECTION_FAILED}_{config_entry_id}_{device_id}" + + +def create_device_connection_issue( + hass: HomeAssistant, + config_entry_id: str, + device_id: str, + device_name: str, +) -> None: + """Create device connection issue.""" + ir.async_create_issue( + hass, + DOMAIN, + _device_issue_id(config_entry_id, device_id), + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_DEVICE_CONNECTION_FAILED, + translation_placeholders={ + "device": device_name, + }, + ) + + +def delete_device_connection_issue( + hass: HomeAssistant, + config_entry_id: str, + device_id: str, +) -> None: + """Delete device connection issue.""" + ir.async_delete_issue( + hass, + DOMAIN, + _device_issue_id(config_entry_id, device_id), + ) + + +def cleanup_device_connection_issues( + hass: HomeAssistant, + config_entry_id: str, + configured_device_ids: set[str], +) -> None: + """Cleanup all device connection issues for a given ConfigEntry which are not in its configuration.""" + registry = ir.async_get(hass) + prefix = f"{ISSUE_DEVICE_CONNECTION_FAILED}_{config_entry_id}_" + + for domain, issue_id in registry.issues: + if domain != DOMAIN or not issue_id.startswith(prefix): + continue + + device_id = issue_id.removeprefix(prefix) + + if device_id not in configured_device_ids: + ir.async_delete_issue(hass, DOMAIN, issue_id) diff --git a/custom_components/gree_custom/aiogree/__init__.py b/custom_components/gree_custom/aiogree/__init__.py old mode 100644 new mode 100755 diff --git a/custom_components/gree_custom/aiogree/api.py b/custom_components/gree_custom/aiogree/api.py old mode 100644 new mode 100755 index 6ba9891..9844c62 --- a/custom_components/gree_custom/aiogree/api.py +++ b/custom_components/gree_custom/aiogree/api.py @@ -1,16 +1,22 @@ """Contains the API to interface with the Gree device.""" -from dataclasses import dataclass +from collections.abc import Mapping +from dataclasses import dataclass, fields, replace from enum import IntEnum, StrEnum, unique import json import logging import re -from typing import Any +from typing import Any, NamedTuple + +from pydantic import BaseModel, ConfigDict from .cipher import CipherBase, EncryptionVersion, get_cipher -from .const import DEFAULT_DEVICE_PORT +from .cloud_api import GreeCloudApi +from .const import DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_USERID, MAX_PACK_SIZE from .errors import GreeBindingError, GreeConnectionError, GreeError, GreeProtocolError -from .transport import GreeTransport, async_udp_broadcast_request +from .helpers import gree_extract_macs, redact_str +from .transport import GreeBaseTransport +from .transport_udp import GreeUdpTransport, async_udp_broadcast_request _LOGGER = logging.getLogger(__name__) @@ -69,18 +75,19 @@ class GreeProp(StrEnum): FEATURE_HUMIDITY_TARGET = "Dwet" # SENSORS - # indoor temperature sensor, used to read the current room temperature, if available - SENSOR_TEMPERATURE = "TemSen" - # outside temperature sensor, used to read the current outdooors temperature, if available - SENSOR_OUTSIDE_TEMPERATURE = "OutEnvTem" - # indoor humidity sensor, used to read the current room humidity, if available - SENSOR_HUMIDITY = "DwatSen" + # indoor temperature sensors, used to read the current room temperature, if available, ordered by preference + SENSOR_INDOOR_TEMPERATURE_1 = "EnvTem" + SENSOR_INDOOR_TEMPERATURE_2 = "InEvaTem" + SENSOR_INDOOR_TEMPERATURE_3 = "TemSen" # value heavily varies with operation mode + # outside temperature sensors, used to read the current outdoors temperature, if available, ordered by preference + SENSOR_OUTSIDE_TEMPERATURE_1 = "OutEnvTem" + SENSOR_OUTSIDE_TEMPERATURE_2 = "TemsSenOut" + # indoor humidity sensor, used to read the current room humidity, if available, ordered by preference + SENSOR_HUMIDITY_1 = "DwatSen" + SENSOR_HUMIDITY_2 = "HumSen" # error display. 0 if no error, otherwise error SENSOR_FAULT = "FaultDisplay" - # OTHER - _UNKNOWN_HEAT_COOL_TYPE = "HeatCoolType" - # If set to 0 the unit will beep on every command BEEPER = "Buzzer_ON_OFF" # If set to 1 the unit will beep on every command (available on newer firmwares) @@ -90,247 +97,290 @@ class GreeProp(StrEnum): PROP_KEY_TO_ENUM = {prop.value: prop for prop in GreeProp} +class InfoProp(StrEnum): + """Enumeration of props that return device information.""" + + DEVICE_MAC = "mac" + DEVICE_NAME = "name" + BC = "bc" + MODEL_TYPE = "ModelType" + MODEL_NEW = "ModelNew" + MID = "mid" + MID_TYPE = "MidType" + HID = "hid" + SERVER = "host" + VENDER = "vender" + PROTOCOL_VERSION = "ver" + WIFI_STATUS = "wifiStatus" + WIFI_RESET = "wifiReset" + BUS = "busVol" + + +INFOPROP_KEY_TO_ENUM = {prop.value: prop for prop in InfoProp} + + class OtherProps(StrEnum): """Enumeration of additional device properties.""" - _UNKN_MODEL = "ModelType" - _UNKN_ACStupPos = "ACStupPos" - _UNKN_ActiveTime = "ActiveTime" - _UNKN_Add0_1 = "Add0.1" - _UNKN_Add0_5 = "Add0.5" - _UNKN_AirQ = "AirQ" - _UNKN_AllErr = "AllErr" - _UNKN_Antifreeze = "Antifreeze" - _UNKN_AssHt = "AssHt" - _UNKN_AutoClean = "AutoClean" - _UNKN_AutoComnCloud = "AutoComnCloud" - _UNKN_AutoUpdate = "AutoUpdate" - _UNKN_BlkTemCom = "BlkTemCom" - _UNKN_ChildLock = "ChildLock" - _UNKN_CO2 = "CO2" - _UNKN_CO2Level = "CO2Level" - _UNKN_CommErr = "CommErr" - _UNKN_CompressorFqy = "CompressorFqy" - _UNKN_CompressorTem = "CompressorTem" - _UNKN_Coolmod = "Coolmod" - _UNKN_CoolNoise = "CoolNoise" - _UNKN_CoolSvStTemMin = "CoolSvStTemMin" - _UNKN_CpsTem = "CpsTem" - _UNKN_CurTmHor = "CurTmHor" - _UNKN_CurTmMin = "CurTmMin" - _UNKN_Dazzling = "Dazzling" - _UNKN_Defrost = "Defrost" - _UNKN_Dfltr = "Dfltr" - _UNKN_DFPoint = "DFPoint" - _UNKN_DIYGra1PoiAmo = "DIYGra1PoiAmo" - _UNKN_DnPLLRSwing = "DnPLLRSwing" - _UNKN_DnPRLRSwing = "DnPRLRSwing" - _UNKN_DnPUDSwing = "DnPUDSwing" - _UNKN_Dpump = "Dpump" - _UNKN_DsplySt = "DsplySt" - _UNKN_DwatFul = "DwatFul" - _UNKN_Elc1Kwh = "Elc1Kwh" - _UNKN_ElcAllKwhClr = "ElcAllKwhClr" - _UNKN_ElcAllKwhH = "ElcAllKwhH" - _UNKN_ElcAllKwhL = "ElcAllKwhL" - _UNKN_ElcDatDte = "ElcDatDte" - _UNKN_ElcDatHor = "ElcDatHor" - _UNKN_ElcDatMth = "ElcDatMth" - _UNKN_ElcErg = "ElcErg" - _UNKN_ElcGear = "ElcGear" - _UNKN_ElcOnKwh = "ElcOnKwh" - _UNKN_ElcP = "ElcP" - _UNKN_Emod = "Emod" - _UNKN_EnergyFlow = "EnergyFlow" - _UNKN_EnvArea1St = "EnvArea1St" - _UNKN_EnvArea2St = "EnvArea2St" - _UNKN_EnvArea3St = "EnvArea3St" - _UNKN_EnvArea4St = "EnvArea4St" - _UNKN_EnvArea5St = "EnvArea5St" - _UNKN_EnvArea6St = "EnvArea6St" - _UNKN_EnvArea7St = "EnvArea7St" - _UNKN_EnvArea8St = "EnvArea8St" - _UNKN_EnvArea9St = "EnvArea9St" - _UNKN_EnvFun = "EnvFun" - _UNKN_EvapClr = "EvapClr" - _UNKN_FanMod = "FanMod" - _UNKN_FavorMode = "FavorMode" - _UNKN_FbidBloPer = "FbidBloPer" - _UNKN_GasAvail = "GasAvail" - _UNKN_GasLED = "GasLED" - _UNKN_GasMas = "GasMas" - _UNKN_GasMod = "GasMod" - _UNKN_GasN = "GasN" - _UNKN_GetEr = "GetEr" - _UNKN_HabitLearn = "HabitLearn" - _UNKN_HandCtl = "HandCtl" - _UNKN_HasTmr = "HasTmr" - _UNKN_HeatCool = "HeatCool" - _UNKN_HeatNoise = "HeatNoise" - _UNKN_HeatSvStTemMax = "HeatSvStTemMax" - _UNKN_HumiSvStTemMin = "HumiSvStTemMin" - _UNKN_HumSen = "HumSen" - _UNKN_HumSor = "HumSor" - _UNKN_IDUAirQu = "IDUAirQu" - _UNKN_ImageRecovery = "ImageRecovery" - _UNKN_ImgUpdateCol = "ImgUpdateCol" - _UNKN_ImgUpdateFail = "ImgUpdateFail" - _UNKN_ImgUpdateSta = "ImgUpdateSta" - _UNKN_ImgUpdateSucs = "ImgUpdateSucs" - _UNKN_ImgVerSta = "ImgVerSta" - _UNKN_InEvaTem = "InEvaTem" - _UNKN_InHid = "InHid" - _UNKN_InHidDownPer = "InHidDownPer" - _UNKN_InHidSvrVer = "InHidSvrVer" - _UNKN_JFErrorCode = "JFErrorCode" - _UNKN_LedLig = "LedLig" - _UNKN_LTemDry = "LTemDry" - _UNKN_MaeS = "MaeS" - _UNKN_MakeWat = "MakeWat" - _UNKN_MasIDUMod = "MasIDUMod" - _UNKN_MasSub = "MasSub" - _UNKN_MicroSen = "MicroSen" - _UNKN_MidType = "MidType" - _UNKN_ModS = "ModS" - _UNKN_NewTimer = "NewTimer" - _UNKN_NewTimerSet = "NewTimerSet" - _UNKN_NobodySave = "NobodySave" - _UNKN_NoD = "NoD" - _UNKN_NoiseSet = "NoiseSet" - _UNKN_ODUViti = "ODUViti" - _UNKN_OEEPHid = "OEEPHid" - _UNKN_OEEPHidDownPer = "OEEPHidDownPer" - _UNKN_OEEPHidSvrVer = "OEEPHidSvrVer" - _UNKN_PctCle = "PctCle" - _UNKN_PctCleOnTm = "PctCleOnTm" - _UNKN_PctCleSetTm = "PctCleSetTm" - _UNKN_PctRe = "PctRe" - _UNKN_PM2P5 = "PM2P5" - _UNKN_PM2P5Sta = "PM2P5Sta" - _UNKN_PM2P5V = "PM2P5V" - _UNKN_PMVComfort = "PMVComfort" - _UNKN_Purify = "Purify" - _UNKN_RemWarnLig = "RemWarnLig" - _UNKN_ReplaceHEPA = "ReplaceHEPA" - _UNKN_ReportCtrl = "ReportCtrl" - _UNKN_ReportFreq = "ReportFreq" - _UNKN_ReportInterval = "ReportInterval" - _UNKN_RoomHigh = "RoomHigh" - _UNKN_RoomLen = "RoomLen" - _UNKN_RoomWid = "RoomWid" - _UNKN_SaveGuid = "SaveGuid" - _UNKN_Security = "Security" - _UNKN_SecurityMode = "SecurityMode" - _UNKN_Sfog = "Sfog" - _UNKN_Slp1H1 = "Slp1H1" - _UNKN_Slp1H2 = "Slp1H2" - _UNKN_Slp1H3 = "Slp1H3" - _UNKN_Slp1H4 = "Slp1H4" - _UNKN_Slp1H5 = "Slp1H5" - _UNKN_Slp1H6 = "Slp1H6" - _UNKN_Slp1H7 = "Slp1H7" - _UNKN_Slp1H8 = "Slp1H8" - _UNKN_Slp1L1 = "Slp1L1" - _UNKN_Slp1L2 = "Slp1L2" - _UNKN_Slp1L3 = "Slp1L3" - _UNKN_Slp1L4 = "Slp1L4" - _UNKN_Slp1L5 = "Slp1L5" - _UNKN_Slp1L6 = "Slp1L6" - _UNKN_Slp1L7 = "Slp1L7" - _UNKN_Slp1L8 = "Slp1L8" - _UNKN_SmartMod = "SmartMod" - _UNKN_SmartSlpMod = "SmartSlpMod" - _UNKN_SmartSlpModEx = "SmartSlpModEx" - _UNKN_SmartWind = "SmartWind" - _UNKN_Smod = "Smod" - _UNKN_SorErr = "SorErr" - _UNKN_Srst = "Srst" - _UNKN_SrstAF = "SrstAF" - _UNKN_SrstCF = "SrstCF" - _UNKN_SrstPF = "SrstPF" - _UNKN_SrstPP = "SrstPP" - _UNKN_SrstRF = "SrstRF" - _UNKN_StSlp1C = "StSlp1C" - _UNKN_StSlp1CInc = "StSlp1CInc" - _UNKN_StSlp1CSp = "StSlp1CSp" - _UNKN_StSlp1H = "StSlp1H" - _UNKN_StSlp1HInc = "StSlp1HInc" - _UNKN_StSlp1HSp = "StSlp1HSp" - _UNKN_StSlp2C = "StSlp2C" - _UNKN_StSlp2CInc = "StSlp2CInc" - _UNKN_StSlp2CSp = "StSlp2CSp" - _UNKN_StSlp2H = "StSlp2H" - _UNKN_StSlp2HInc = "StSlp2HInc" - _UNKN_StSlp2HSp = "StSlp2HSp" - _UNKN_StSlp3C = "StSlp3C" - _UNKN_StSlp3CInc = "StSlp3CInc" - _UNKN_StSlp3CSp = "StSlp3CSp" - _UNKN_StSlp3H = "StSlp3H" - _UNKN_StSlp3HInc = "StSlp3HInc" - _UNKN_StSlp3HSp = "StSlp3HSp" - _UNKN_StSlp4C = "StSlp4C" - _UNKN_StSlp4CInc = "StSlp4CInc" - _UNKN_StSlp4CSp = "StSlp4CSp" - _UNKN_StSlp4H = "StSlp4H" - _UNKN_StSlp4HInc = "StSlp4HInc" - _UNKN_StSlp4HSp = "StSlp4HSp" - _UNKN_StTmr = "StTmr" - _UNKN_Swash = "Swash" - _UNKN_Swat = "Swat" - _UNKN_SwhDIYGra1 = "SwhDIYGra1" - _UNKN_SwhFreAir = "SwhFreAir" - _UNKN_SwhSw = "SwhSw" - _UNKN_SwhWifi = "SwhWifi" - _UNKN_SwhWifiCo = "SwhWifiCo" - _UNKN_SwhWifiRe = "SwhWifiRe" - _UNKN_TemSor = "TemSor" - _UNKN_TemsSenOut = "TemsSenOut" - _UNKN_TmrLpTms = "TmrLpTms" - _UNKN_TmrOff = "TmrOff" - _UNKN_TmrOffHorLf = "TmrOffHorLf" - _UNKN_TmrOffMinLf = "TmrOffMinLf" - _UNKN_TmrOn = "TmrOn" - _UNKN_TmrOnHorLf = "TmrOnHorLf" - _UNKN_TmrOnMinLf = "TmrOnMinLf" - _UNKN_UDFanPort = "UDFanPort" - _UNKN_UnmanedOffTime = "UnmanedOffTime" - _UNKN_UnmanedShutDown = "UnmanedShutDown" - _UNKN_UvcControl = "UvcControl" - _UNKN_Video = "Video" - _UNKN_VitiGr = "VitiGr" - _UNKN_VOC = "VOC" - _UNKN_VocCtl = "VocCtl" - _UNKN_VocIdiom = "VocIdiom" - _UNKN_VocRole = "VocRole" - _UNKN_VocUpdateCol = "VocUpdateCol" - _UNKN_VocUpdateRes = "VocUpdateRes" - _UNKN_VocUpdateSta = "VocUpdateSta" - _UNKN_VocVerSta = "VocVerSta" - _UNKN_WatErr = "WatErr" - _UNKN_WatTmp = "WatTmp" - _UNKN_Werr = "Werr" - _UNKN_Wet = "Wet" - _UNKN_Wmod = "Wmod" - _UNKN_WschOff = "WschOff" - _UNKN_WschOffMin = "WschOffMin" - _UNKN_WschOn = "WschOn" - _UNKN_WschOnMin = "WschOnMin" - _UNKN_WsenNub = "WsenNub" - _UNKN_WsenTmpH = "WsenTmpH" - _UNKN_WsenTmpL = "WsenTmpL" - _UNKN_WsenTmpM = "WsenTmpM" - _UNKN_WsetTmp = "WsetTmp" - _UNKN_WstpH = "WstpH" - _UNKN_WstpSv = "WstpSv" - _UNKN_Wtmr1 = "Wtmr1" - _UNKN_Wtmr1Min = "Wtmr1Min" - _UNKN_Wtmr2 = "Wtmr2" - _UNKN_Wtmr2Min = "Wtmr2Min" - _UNKN_Wtmr3 = "Wtmr3" - _UNKN_Wtmr3Min = "Wtmr3Min" + UNKN_ACStupPos = "ACStupPos" + UNKN_ActiveTime = "ActiveTime" + UNKN_Add0_1 = "Add0.1" + UNKN_Add0_5 = "Add0.5" + UNKN_AirQ = "AirQ" + UNKN_AllErr = "AllErr" + UNKN_Antifreeze = "Antifreeze" + UNKN_AppTimer = "AppTimer" + UNKN_AssHt = "AssHt" + UNKN_AutoClean = "AutoClean" + UNKN_AutoCleanSta = "AutoCleanSta" + UNKN_AutoCleanStaEx = "AutoCleanStaEx" + UNKN_AutoComnCloud = "AutoComnCloud" + UNKN_AutoPowReduce = "AutoPowReduce" + UNKN_AutoUpdate = "AutoUpdate" + UNKN_BlkTemCom = "BlkTemCom" + UNKN_ChildLock = "ChildLock" + UNKN_CO2 = "CO2" + UNKN_CO2Level = "CO2Level" + UNKN_CommErr = "CommErr" + UNKN_CompressorFqy = "CompressorFqy" + UNKN_CompressorTem = "CompressorTem" + UNKN_CoolFeel = "CoolFeel" + UNKN_Coolmod = "Coolmod" + UNKN_CoolNoise = "CoolNoise" + UNKN_CoolSvStTemMin = "CoolSvStTemMin" + UNKN_CpsTem = "CpsTem" + UNKN_CurTmHor = "CurTmHor" + UNKN_CurTmMin = "CurTmMin" + UNKN_Dazzling = "Dazzling" + UNKN_Defrost = "Defrost" + UNKN_Dfltr = "Dfltr" + UNKN_DFPoint = "DFPoint" + UNKN_DIYGra1PoiAmo = "DIYGra1PoiAmo" + UNKN_DnPLLRSwing = "DnPLLRSwing" + UNKN_DnPRLRSwing = "DnPRLRSwing" + UNKN_DnPUDSwing = "DnPUDSwing" + UNKN_Dpump = "Dpump" + UNKN_DsplySt = "DsplySt" + UNKN_DwatFul = "DwatFul" + UNKN_Elc1Kwh = "Elc1Kwh" + UNKN_ElcAllKwhClr = "ElcAllKwhClr" + UNKN_ElcAllKwhH = "ElcAllKwhH" + UNKN_ElcAllKwhL = "ElcAllKwhL" + UNKN_ElcDatDte = "ElcDatDte" + UNKN_ElcDatHor = "ElcDatHor" + UNKN_ElcDatMth = "ElcDatMth" + UNKN_ElcEn = "ElcEn" + UNKN_ElcErg = "ElcErg" + UNKN_ElcGear = "ElcGear" + UNKN_ElcOnKwh = "ElcOnKwh" + UNKN_ElcP = "ElcP" + UNKN_Emod = "Emod" + UNKN_EnergyFlow = "EnergyFlow" + UNKN_EnvArea1St = "EnvArea1St" + UNKN_EnvArea2St = "EnvArea2St" + UNKN_EnvArea3St = "EnvArea3St" + UNKN_EnvArea4St = "EnvArea4St" + UNKN_EnvArea5St = "EnvArea5St" + UNKN_EnvArea6St = "EnvArea6St" + UNKN_EnvArea7St = "EnvArea7St" + UNKN_EnvArea8St = "EnvArea8St" + UNKN_EnvArea9St = "EnvArea9St" + UNKN_EnvFun = "EnvFun" + UNKN_EnvTem = "EnvTem" + UNKN_estateInsta21 = "estateInsta21" + UNKN_estateInsta22 = "estateInsta22" + UNKN_estateInsta23 = "estateInsta23" + UNKN_estateInsta24 = "estateInsta24" + UNKN_EvapClr = "EvapClr" + UNKN_FanMod = "FanMod" + UNKN_FavorMode = "FavorMode" + UNKN_FbidBloPer = "FbidBloPer" + UNKN_GasAvail = "GasAvail" + UNKN_GasLED = "GasLED" + UNKN_GasMas = "GasMas" + UNKN_GasMod = "GasMod" + UNKN_GasN = "GasN" + UNKN_GetEr = "GetEr" + UNKN_HabitLearn = "HabitLearn" + UNKN_HandCtl = "HandCtl" + UNKN_HasTmr = "HasTmr" + UNKN_HeatCool = "HeatCool" + UNKN_HeatCoolType = "HeatCoolType" + UNKN_HeatNoise = "HeatNoise" + UNKN_HeatSvStTemMax = "HeatSvStTemMax" + UNKN_HumiSvStTemMin = "HumiSvStTemMin" + UNKN_HumSor = "HumSor" + UNKN_IDUAirQu = "IDUAirQu" + UNKN_ImageRecovery = "ImageRecovery" + UNKN_ImgUpdateCol = "ImgUpdateCol" + UNKN_ImgUpdateFail = "ImgUpdateFail" + UNKN_ImgUpdateSta = "ImgUpdateSta" + UNKN_ImgUpdateSucs = "ImgUpdateSucs" + UNKN_ImgVerSta = "ImgVerSta" + UNKN_InEvaTem = "InEvaTem" + UNKN_InHid = "InHid" + UNKN_InHidDownPer = "InHidDownPer" + UNKN_InHidSvrVer = "InHidSvrVer" + UNKN_JFErrorCode = "JFErrorCode" + UNKN_LedLig = "LedLig" + UNKN_LedLight = "LedLight" + UNKN_LTemDry = "LTemDry" + UNKN_MaeS = "MaeS" + UNKN_MakeWat = "MakeWat" + UNKN_MasIDUMod = "MasIDUMod" + UNKN_MasSub = "MasSub" + UNKN_MicroSen = "MicroSen" + UNKN_MMWPosRpt = "MMWPosRpt" + UNKN_ModS = "ModS" + UNKN_NewTimer = "NewTimer" + UNKN_NewTimerSet = "NewTimerSet" + UNKN_NightLig = "NightLig" + UNKN_NobodySave = "NobodySave" + UNKN_NoD = "NoD" + UNKN_NoiseSet = "NoiseSet" + UNKN_ODUViti = "ODUViti" + UNKN_OEEPHid = "OEEPHid" + UNKN_OEEPHidDownPer = "OEEPHidDownPer" + UNKN_OEEPHidSvrVer = "OEEPHidSvrVer" + UNKN_OxygenDisplay = "OxygenDisplay" + UNKN_OxygenSwitch = "OxygenSwitch" + UNKN_PctCle = "PctCle" + UNKN_PctCleOnTm = "PctCleOnTm" + UNKN_PctCleSetTm = "PctCleSetTm" + UNKN_PctRe = "PctRe" + UNKN_PM2P5 = "PM2P5" + UNKN_PM2P5Sta = "PM2P5Sta" + UNKN_PM2P5V = "PM2P5V" + UNKN_PMVComfort = "PMVComfort" + UNKN_PowReduceType = "PowReduceType" + UNKN_Purify = "Purify" + UNKN_RemWarnLig = "RemWarnLig" + UNKN_ReplaceHEPA = "ReplaceHEPA" + UNKN_ReportCtrl = "ReportCtrl" + UNKN_ReportFreq = "ReportFreq" + UNKN_ReportInterval = "ReportInterval" + UNKN_RoomHigh = "RoomHigh" + UNKN_RoomLen = "RoomLen" + UNKN_RoomWid = "RoomWid" + UNKN_SaveGuid = "SaveGuid" + UNKN_Security = "Security" + UNKN_SecurityMode = "SecurityMode" + UNKN_Sfog = "Sfog" + UNKN_ShutdownFault = "ShutdownFault" + UNKN_Slp1H1 = "Slp1H1" + UNKN_Slp1H2 = "Slp1H2" + UNKN_Slp1H3 = "Slp1H3" + UNKN_Slp1H4 = "Slp1H4" + UNKN_Slp1H5 = "Slp1H5" + UNKN_Slp1H6 = "Slp1H6" + UNKN_Slp1H7 = "Slp1H7" + UNKN_Slp1H8 = "Slp1H8" + UNKN_Slp1L1 = "Slp1L1" + UNKN_Slp1L2 = "Slp1L2" + UNKN_Slp1L3 = "Slp1L3" + UNKN_Slp1L4 = "Slp1L4" + UNKN_Slp1L5 = "Slp1L5" + UNKN_Slp1L6 = "Slp1L6" + UNKN_Slp1L7 = "Slp1L7" + UNKN_Slp1L8 = "Slp1L8" + UNKN_SmartMod = "SmartMod" + UNKN_SmartSlpMod = "SmartSlpMod" + UNKN_SmartSlpModEx = "SmartSlpModEx" + UNKN_SmartWind = "SmartWind" + UNKN_Smod = "Smod" + UNKN_SorErr = "SorErr" + UNKN_Srst = "Srst" + UNKN_SrstAF = "SrstAF" + UNKN_SrstCF = "SrstCF" + UNKN_SrstPF = "SrstPF" + UNKN_SrstPP = "SrstPP" + UNKN_SrstRF = "SrstRF" + UNKN_StSlp1C = "StSlp1C" + UNKN_StSlp1CInc = "StSlp1CInc" + UNKN_StSlp1CSp = "StSlp1CSp" + UNKN_StSlp1H = "StSlp1H" + UNKN_StSlp1HInc = "StSlp1HInc" + UNKN_StSlp1HSp = "StSlp1HSp" + UNKN_StSlp2C = "StSlp2C" + UNKN_StSlp2CInc = "StSlp2CInc" + UNKN_StSlp2CSp = "StSlp2CSp" + UNKN_StSlp2H = "StSlp2H" + UNKN_StSlp2HInc = "StSlp2HInc" + UNKN_StSlp2HSp = "StSlp2HSp" + UNKN_StSlp3C = "StSlp3C" + UNKN_StSlp3CInc = "StSlp3CInc" + UNKN_StSlp3CSp = "StSlp3CSp" + UNKN_StSlp3H = "StSlp3H" + UNKN_StSlp3HInc = "StSlp3HInc" + UNKN_StSlp3HSp = "StSlp3HSp" + UNKN_StSlp4C = "StSlp4C" + UNKN_StSlp4CInc = "StSlp4CInc" + UNKN_StSlp4CSp = "StSlp4CSp" + UNKN_StSlp4H = "StSlp4H" + UNKN_StSlp4HInc = "StSlp4HInc" + UNKN_StSlp4HSp = "StSlp4HSp" + UNKN_StTmr = "StTmr" + UNKN_SubhealthFault = "SubhealthFault" + UNKN_Swash = "Swash" + UNKN_Swat = "Swat" + UNKN_SwhDIYGra1 = "SwhDIYGra1" + UNKN_SwhFreAir = "SwhFreAir" + UNKN_SwhSw = "SwhSw" + UNKN_SwhWifi = "SwhWifi" + UNKN_SwhWifiCo = "SwhWifiCo" + UNKN_SwhWifiRe = "SwhWifiRe" + UNKN_TemSor = "TemSor" + UNKN_TemsSenOut = "TemsSenOut" + UNKN_TmrLpTms = "TmrLpTms" + UNKN_TmrOff = "TmrOff" + UNKN_TmrOffHorLf = "TmrOffHorLf" + UNKN_TmrOffMinLf = "TmrOffMinLf" + UNKN_TmrOn = "TmrOn" + UNKN_TmrOnHorLf = "TmrOnHorLf" + UNKN_TmrOnMinLf = "TmrOnMinLf" + UNKN_UDFanPort = "UDFanPort" + UNKN_UniqueCode = "UniqueCode" + UNKN_UnmanedOffTime = "UnmanedOffTime" + UNKN_UnmanedSetting = "UnmanedSetting" + UNKN_UnmanedShutDown = "UnmanedShutDown" + UNKN_UvcControl = "UvcControl" + UNKN_Video = "Video" + UNKN_VitiGr = "VitiGr" + UNKN_VOC = "VOC" + UNKN_VocCtl = "VocCtl" + UNKN_VocIdiom = "VocIdiom" + UNKN_VocRole = "VocRole" + UNKN_VocUpdateCol = "VocUpdateCol" + UNKN_VocUpdateRes = "VocUpdateRes" + UNKN_VocUpdateSta = "VocUpdateSta" + UNKN_VocVerSta = "VocVerSta" + UNKN_WatErr = "WatErr" + UNKN_WatTmp = "WatTmp" + UNKN_Werr = "Werr" + UNKN_Wet = "Wet" + UNKN_Widn = "Wind" + UNKN_WisdomRisk = "WisdomRisk" + UNKN_Wmod = "Wmod" + UNKN_WschOff = "WschOff" + UNKN_WschOffMin = "WschOffMin" + UNKN_WschOn = "WschOn" + UNKN_WschOnMin = "WschOnMin" + UNKN_WsenNub = "WsenNub" + UNKN_WsenTmpH = "WsenTmpH" + UNKN_WsenTmpL = "WsenTmpL" + UNKN_WsenTmpM = "WsenTmpM" + UNKN_WsetTmp = "WsetTmp" + UNKN_WstpH = "WstpH" + UNKN_WstpSv = "WstpSv" + UNKN_Wtmr1 = "Wtmr1" + UNKN_Wtmr1Min = "Wtmr1Min" + UNKN_Wtmr2 = "Wtmr2" + UNKN_Wtmr2Min = "Wtmr2Min" + UNKN_Wtmr3 = "Wtmr3" + UNKN_Wtmr3Min = "Wtmr3Min" # # INVALID - # _INV_MafIdf = "MafIdf" - # _INV_DevId = "DevID" + # INV_MafIdf = "MafIdf" + # INV_DevId = "DevID" @unique @@ -415,35 +465,111 @@ class HumidityControlMode(IntEnum): smart_dry = 2 # This is only available in cool operation mode -class GreeCommand(IntEnum): +class GreeCommand(StrEnum): """Enumeration of Gree commands.""" - STATUS = 0 - BIND = 1 + GET_STATE = "status" + BIND = "bind" + SET_STATE = "cmd" + SCAN = "scan" + + +class DeviceScanInfoResponse(BaseModel): + """Response data for a Gree device returned in a UDP scan.""" + + # Scan Responses format: + # {"t":"dev","bc":"","catalog":"gree","series":"gree","model":"gree","lock":0,"vender":"1","mid":"60","name":"GR-Gcloud_60_0a_5ba3_EC","ver":"V3.2.M","mac":"9424b8fd5ba3","subCnt":6} + # {'t': 'dev', 'cid': 'c03937b12280', 'bc': '00000000000000000000000000000000', 'brand': 'gree', 'catalog': 'gree', 'mac': 'c03937b12280', 'mid': '10001', 'model': 'gree', 'name': '', 'lock': 0, 'series': 'gree', 'vender': '1', 'ver': 'V3.4.M', 'ModelType': '32776', 'hid': '362001065279+U-WB05RT13V1.45.bin'} + model_config = ConfigDict(extra="ignore") + + t: str + cid: str + mac: str + bc: str | None = None + brand: str | None = None + catalog: str | None = None + mid: str | None = None + model: str | None = None + name: str | None = None + lock: bool | None = None + series: str | None = None + vender: str | None = None + ver: str | None = None + ModelType: str | None = None + hid: str | None = None + subCnt: int | None = None # noqa: N815 @dataclass class GreeDiscoveredDevice: - """Information about a discovered Gree device.""" + """Representation of a discovered Gree device.""" - name: str - host: str + # Device Id mac: str - port: int - brand: str - model: str - uid: int - subdevices: int + mac_controller_local: str = "" + mac_controller_mqtt: str = "" + user_id: int = DEFAULT_DEVICE_USERID + key: str | None = None + # Local + host: str | None = None + port: int | None = None + # Cloud + username: str | None = None + # Properties + name: str = "" + catalog: str = "" + brand: str = "gree" + model: str = "gree" + model_type: str = "" + vender: str = "" + # Firmware + mid: str = "" + hid: str = "" + ver: str = "" + + @property + def friendly_name(self) -> str: + """Friendly representation of the discovered device.""" + connection = "Local" if self.host else "Cloud" + name = self.name + ", " if self.name else "" + return f"{name}{self.mac} ({connection})" + + +class StatusResult(NamedTuple): + """The result of a status request.""" + + prop_values: dict[str, str] + missing_props: list[str] + + +class BindingInfo(NamedTuple): + """Combination of key and encryption version from a binding procedure.""" + + encryption_key: str + encryption_version: EncryptionVersion + + +async def gree_get_response( + mac_controller: str, + json_data: dict, + cipher: CipherBase, + transport: GreeBaseTransport, +) -> dict: + """Send a request to the device and return the decoded response. + Args: + mac_controller: MAC of the controller device + json_data: JSON payload to send + cipher: Device cipher to encrypt and decrypt the JSON pack, if present + transport: Transport to send the emssage throuhg -async def get_result_pack( - json_data: dict, cipher: CipherBase, transport: GreeTransport -) -> dict: - """Send a request to the device and return the decoded response pack.""" + Returns: + Decrypted JSON response + + """ try: - recv_json = await transport.request_json(json_data) - data = get_gree_response_data(recv_json, cipher) + data = await transport.request_json(mac_controller, json_data, cipher) except GreeConnectionError: raise except json.JSONDecodeError as err: @@ -451,149 +577,255 @@ async def get_result_pack( except Exception as err: raise GreeProtocolError("Error in device response") from err - pack = data.get("pack", None) + return data - if pack is None: - raise GreeProtocolError("Device response missing 'pack' field") - # Do not modify the original data - redacted = data.copy() - if "key" in redacted["pack"] and redacted["pack"]["key"]: - redacted["pack"] = redacted["pack"].copy() - redacted["pack"]["key"] = str(redacted["pack"]["key"])[:5] + "[redacted]" - - _LOGGER.debug("Got data from %s: %s", transport.ip_addr, redacted) +async def gree_get_response_pack( + mac_controller: str, + json_data: dict, + cipher: CipherBase, + transport: GreeBaseTransport, +) -> dict: + """Send a request to the device and return the decoded response pack. - return pack + Args: + mac_controller: MAC of the controller device + json_data: JSON payload to send + cipher: Device cipher to encrypt and decrypt the JSON pack, if present + transport: Transport to send the emssage throuhg + Returns: + Decrypted JSON pack response -def get_gree_response_data( - recv_json: dict, - cipher: CipherBase, -) -> dict: - """Decode and decrypt a response from a Gree device.""" + """ - encoded_pack = recv_json.get("pack") - tag = recv_json.get("tag") + data = await gree_get_response(mac_controller, json_data, cipher, transport) - if encoded_pack: - decrypted_pack = cipher.decrypt(encoded_pack, tag) - # Replace encrypted pack with decrypted data - recv_json["pack"] = json.loads(decrypted_pack) + pack: dict | None = data.get("pack", None) - return recv_json + if pack is None: + raise GreeProtocolError("Device response missing 'pack' field") + # Do not modify the original data + redacted = data.copy() + if "key" in redacted["pack"] and redacted["pack"]["key"]: + redacted["pack"] = redacted["pack"].copy() + redacted["pack"]["key"] = redact_str(str(redacted["pack"]["key"])) -def gree_encrypt_pack( - pack: dict, - cipher: CipherBase, -) -> tuple[str, str | None]: - """Encrypt a protocol pack for transmission to the device.""" + _LOGGER.debug("[%s] Got data: %s", transport, redacted) - if cipher is None: - raise GreeError("Cipher must not be None") + return pack - encrypted_data, tag = cipher.encrypt(json.dumps(pack)) - # WARNING: My device does not respond if the encrypted_pack is more that 1024 bytes - if len(encrypted_data.encode("utf-8")) > 1024: - _LOGGER.warning("Pack length is over 1024 bytes") +def _create_bind_pack(mac_addr_controller: str, uid: int, cipher: CipherBase) -> dict: + """Create a bind request pack. - return (encrypted_data, tag) + Args: + mac_addr: The MAC address of the device to bind with + uid: User ID for the device + cipher: Device cipher to encrypt and decrypt the JSON pack, if present + Returns: + The created Bind pack -def gree_create_bind_pack(mac_addr: str, uid: int, cipher: CipherBase) -> dict: - """Create a bind request pack.""" + """ pack: dict = {} if cipher.version == EncryptionVersion.V1: - pack = {"mac": mac_addr, "t": "bind", "uid": uid} + pack = {"t": GreeCommand.BIND.value, "uid": uid, "mac": mac_addr_controller} elif cipher.version == EncryptionVersion.V2: - pack = {"cid": mac_addr, "mac": mac_addr, "t": "bind", "uid": uid} + pack = { + "t": GreeCommand.BIND.value, + "uid": uid, + "mac": mac_addr_controller, + "cid": mac_addr_controller, + } _LOGGER.debug("Bind Pack: %s", pack) return pack -def gree_create_sub_bind_pack(mac_addr: str) -> dict: - """Create a sub-device bind request pack.""" +def _create_get_subdevices_pack(mac_addr_controller: str) -> dict: + """Create a sub-device list request pack. - pack: dict = {"mac": mac_addr, "i": 1} + Args: + mac_addr_controller: The MAC address of the device that controls the sub devices + + Returns: + The created get sub-devices pack + + """ + + pack: dict = {"mac": mac_addr_controller, "i": 1} _LOGGER.debug("Sub Bind Pack: %s", pack) return pack -def gree_create_status_pack(mac_addr: str, props: list[str]) -> dict: - """Create a status request pack.""" +def _create_get_status_pack(mac_addr: str, props: list[str]) -> dict: + """Create a status request pack. + + Args: + mac_addr: MAC address of the device to get the status of + props: List of property names to query - pack: dict = {"cols": props, "mac": mac_addr, "t": "status"} + Returns: + The created get status pack + + """ + + pack: dict = {"t": GreeCommand.GET_STATE.value, "mac": mac_addr, "cols": props} _LOGGER.debug("Status Pack: %s", pack) return pack -def gree_create_set_pack(mac_addr: str, props: dict[GreeProp, int]) -> dict: - """Create a command pack to update device properties.""" +def _create_set_status_pack(mac_addr: str, props: Mapping[str, int]) -> dict: + """Create a command pack to update device properties. + + Args: + mac_addr: MAC address of the device to set the status of + props: Dictionary of property names and values to set + + Returns: + The created set status pack + + """ + + props_ordered = _order_set_props(dict(props)) pack: dict = { - "opt": [prop.value for prop in props], - "p": list(props.values()), - "t": "cmd", + "t": GreeCommand.SET_STATE.value, "sub": mac_addr, + "opt": list(props_ordered.keys()), + "p": list(props_ordered.values()), } - _LOGGER.debug("Status Pack: %s", pack) + _LOGGER.debug("Set Pack: %s", pack) return pack -def gree_create_payload( - pack: str, +def _order_set_props(props: dict[str, int]) -> dict[str, int]: + """Reorder the props to match device requirements. + + The order of the props is important and required for devices, especially if the transport does not support batching. + + Args: + props: Dictionary of property names and values to order + + Returns: + Ordered dictionary of property names and values + + """ + + # CRITICAL: Send Mode FIRST, then Temperature, then others, then Power LAST + # This order is required for commercial/parent-child devices + + remaining = props.copy() + ordered: dict[str, int] = {} + + # if GreeProp.BEEPER.value in remaining: + # ordered[GreeProp.BEEPER.value] = remaining.pop(GreeProp.BEEPER.value) + # if GreeProp.BEEPER_NEW.value in remaining: + # ordered[GreeProp.BEEPER_NEW.value] = remaining.pop(GreeProp.BEEPER_NEW.value) + + # Mode first + if GreeProp.OP_MODE.value in remaining: + ordered[GreeProp.OP_MODE.value] = remaining.pop(GreeProp.OP_MODE.value) + + # Temperature-related properties + for prop in ( + GreeProp.TARGET_TEMPERATURE_UNIT.value, + GreeProp.TARGET_TEMPERATURE_BIT.value, + GreeProp.TARGET_TEMPERATURE.value, + ): + if prop in remaining: + ordered[prop] = remaining.pop(prop) + + # Power goes last + power = remaining.pop(GreeProp.POWER.value, None) + b1 = remaining.pop(GreeProp.BEEPER.value, None) + b2 = remaining.pop(GreeProp.BEEPER_NEW.value, None) + + # Everything else + ordered.update(remaining) + + if power is not None: + ordered[GreeProp.POWER.value] = power + if b1 is not None: + ordered[GreeProp.BEEPER.value] = b1 + if b2 is not None: + ordered[GreeProp.BEEPER_NEW.value] = b2 + + return ordered + + +def _create_payload( + pack: dict, payload_type: str, - i_command: GreeCommand, - mac_addr: str, + i: int, + mac_addr_controller: str, uid: int, - tag: str | None, ) -> dict: - """Create a protocol payload containing an encrypted pack.""" + """Create a protocol payload containing a pack. + + Args: + pack: The Pack of the payload + payload_type: Type of pack payload + i: sequential increment number + mac_addr_controller: The MAC address of the device that controls the sub devices + uid: User ID for the device + + Returns: + The created full payload + + """ payload: dict[str, Any] = { "cid": "app", - "i": i_command.value, - "pack": pack, + "i": i, "t": payload_type, - "tcid": mac_addr, + "pack": pack, + "tcid": mac_addr_controller, "uid": uid, } - if tag is not None: - payload["tag"] = tag - _LOGGER.debug("Payload: %s", payload) return payload async def gree_try_bind( - mac_addr: str, + mac_addr_controller: str, uid: int, version: EncryptionVersion | None, key: str | None, - transport: GreeTransport, -) -> tuple[str, EncryptionVersion]: - """Bind to the device and determine the correct encryption settings. + transport: GreeBaseTransport, +) -> BindingInfo: + """Bind to a controller device and determine the correct encryption settings. Attempts binding using the provided encryption version and/or key when available. If binding fails, falls back to the default encryption - versions and returns the encryption key and version accepted by the - device. + versions. + + Args: + mac_addr_controller: The MAC address of the device that controls the sub devices + uid: User ID for the device + version: Encryption version for the given transport (Optional) + key: Encryption key for the device (Optional) + transport: Transport used to communicate with the device + + Returns: + The encryption key and version accepted by the device. + """ ret_key: str = "" - error: Exception | None = Exception("Binding failed") + error: GreeError | None = GreeBindingError("Binding failed") has_version = version is not None has_key = key is not None and bool(key.strip()) + redacted_key = redact_str(key) ciphers: list[CipherBase] = [] @@ -601,23 +833,27 @@ async def gree_try_bind( ciphers.append(get_cipher(version)) if has_key: _LOGGER.info( - "Trying to perform binding. Prefer provided version (%s) and key (%s)", + "[%s] Trying to perform binding. Prefer provided version (%s) and key (%s)", + transport, version, - key[:5] + "[redacted]", + redacted_key, ) else: _LOGGER.info( - "Trying to perform binding. Prefer provided version (%s) and generic key ", + "[%s] Trying to perform binding. Prefer provided version (%s) and generic key ", + transport, version, ) elif has_key: _LOGGER.info( - "Trying to perform binding. Prefering provided key (%s)", - key[:5] + "[redacted]", + "[%s] Trying to perform binding. Prefering provided key (%s)", + transport, + redacted_key, ) else: _LOGGER.info( - "Trying to perform binding. Testing both versions with generic keys" + "[%s] Trying to perform binding. Testing both versions with generic keys", + transport, ) # Fallback to both default ciphers @@ -626,21 +862,24 @@ async def gree_try_bind( for cipher in ciphers: _LOGGER.debug( - "Requesting bind to device with encryption key v%d", cipher.version + "[%s] Requesting bind to device with encryption key v%d", + transport, + cipher.version, ) - pack = gree_create_bind_pack(mac_addr, uid, cipher) - encrypted_pack, tag = gree_encrypt_pack(pack, cipher) - json_payload = gree_create_payload( - encrypted_pack, "pack", GreeCommand.BIND, mac_addr, uid, tag - ) + pack = _create_bind_pack(mac_addr_controller, uid, cipher) + # encrypted_pack, tag = gree_encrypt_pack(pack, cipher) + json_payload = _create_payload(pack, "pack", 1, mac_addr_controller, uid) try: - result = await get_result_pack(json_payload, cipher, transport) + result = await gree_get_response_pack( + mac_addr_controller, json_payload, cipher, transport + ) - except Exception as err: + except GreeError as err: _LOGGER.exception( - "Error in bind request using encryption key with version %d", + "[%s] Error in bind request using encryption key with version %d", + transport, cipher.version, ) @@ -651,7 +890,7 @@ async def gree_try_bind( else: ret_key = result.get("key", "") - + ret_key_redacted = redact_str(ret_key) if ret_key.strip() == "": raise GreeBindingError( "Binding failed: Received empty encryption key from device" @@ -659,77 +898,153 @@ async def gree_try_bind( if has_key and ret_key != key: _LOGGER.warning( - "Binding successful with different key. Using retrieved key. Expected '%s', got '%s'", - key[:5] + "[redacted]", - ret_key[:5] + "[redacted]", + "[%s] Binding successful with different key. Using retrieved key. Expected '%s', got '%s'", + transport, + redacted_key, + ret_key_redacted, ) if has_version and cipher.version != version: _LOGGER.warning( - "Binding successful with different version. Using retrieved version. Expected '%s', got '%s'", + "[%s] Binding successful with different version. Using retrieved version. Expected '%s', got '%s'", + transport, version, cipher.version, ) - _LOGGER.info("Bind request with version %d was successful", cipher.version) - - _LOGGER.debug("Fetched encryption key: %s[redacted]", ret_key[:5]) + _LOGGER.info( + "[%s] Fetched encryption key %s with version %d", + transport, + ret_key_redacted, + cipher.version, + ) - return ret_key, cipher.version + return BindingInfo( + encryption_key=ret_key, encryption_version=cipher.version + ) raise GreeBindingError( - f"Binding failed: Unable to obtain valid encryption version and key pair for {mac_addr} at {transport.ip_addr}" + f"Binding failed: Unable to obtain valid encryption version and key pair for {mac_addr_controller} at {transport}" ) from error +EMPTY_PACK_OVERHEAD = len( + json.dumps(_create_get_status_pack("XXXXXXXXXXXX", [""])).encode() +) + + async def gree_get_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: list[str], + prop_names: list[str], cipher: CipherBase, - transport: GreeTransport, -) -> tuple[dict[str, str], list[str]]: + transport: GreeBaseTransport, +) -> StatusResult: """Retrieve the current values of the requested device properties. - Returns a mapping of property names to values, along with a list of - properties that were not returned by the device. - The Gree protocol provides best-effort responses, meaning requested properties may be omitted or returned without corresponding values. This makes it impossible to know in a batch where they are. Callers should therefore only batch properties known to be supported. + + Args: + mac_addr_controller: The MAC address of the device that controls the connection + mac_addr: MAC address of the device to get the status of. + uid: User ID for the device + prop_names: List of property names to query + cipher: Device cipher to encrypt and decrypt the JSON pack, if present + transport: Transport used to communicate with the device + + Returns: + Mapping of property names to values, along with a list of + properties that were not returned by the device. + """ - _LOGGER.debug("Getting status for device '%s'", mac_addr) + _LOGGER.debug("[%s] Getting status for device '%s'", transport, mac_addr) # Filter empty, none and white spaces - props = [p for p in props if p is not None and p.strip()] + prop_names = [p for p in prop_names if p is not None and p.strip()] + + # Use a MAX_PACK_SIZE pack as a limit for the full request + # UDP seems to break at a 1024 bytes encrypted pack (~760 unencrypted prop list) + # MQTT seems to break at a 1000 bytes encrypted pack (~670 unencrypted prop list) + # Use a lesser value as a safe option (512) + # Since the device only responds to requests under 1024 bytes + # here we divide the props in batches so that the request does not pass the limit + batches: list[list[str]] = [] + current: list[str] = [] + current_size = EMPTY_PACK_OVERHEAD + + for prop in prop_names: + prop_size = len(json.dumps([prop]).encode()) + + if current_size + prop_size < MAX_PACK_SIZE: + current.append(prop) + current_size += prop_size + else: + if current: + batches.append(current) - pack = gree_create_status_pack(mac_addr, props) - encrypted_pack, tag = gree_encrypt_pack(pack, cipher) + current = [prop] + current_size = EMPTY_PACK_OVERHEAD + prop_size - json_payload = gree_create_payload( - encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag - ) + if current: + batches.append(current) + + if len(batches) > 1: + _LOGGER.debug( + "[%s] The requested props are more that what is allowed in one request. Divided into %d requests", + transport, + len(batches), + ) + + status: dict[str, str] = {} + missing: list[str] = [] try: - result = await get_result_pack(json_payload, cipher, transport) + for batched_props in batches: + pack = _create_get_status_pack(mac_addr, batched_props) + json_payload = _create_payload(pack, "pack", 0, mac_addr_controller, uid) + result = await gree_get_response_pack( + mac_addr_controller, json_payload, cipher, transport + ) + res = gree_process_status_pack(result, batched_props) + status.update(res.prop_values) + missing.extend(res.missing_props) except GreeConnectionError, GreeProtocolError: raise except Exception as err: - raise GreeProtocolError("Error getting device status") from err + raise GreeProtocolError( + f"Error getting status of device '{mac_addr}' via {transport}" + ) from err + + return StatusResult(prop_values=status, missing_props=missing) + + +def gree_process_status_pack(pack: dict, props: list[str] | None) -> StatusResult: + """Process a status pack. + + Args: + pack: The raw unencrypted pack from a status request + props: List of property names that should be in the status response (Optional) + Returns: + Mapping of property names to values, along with a list of + properties that were not returned by the device. + + """ # Gree protocol provides best-effort responses # Meaning: # cols = what the device claims it is returning # dat = best-effort values, possibly incomplete # alignment between them is not guaranteed globally - cols = result.get("cols") - dat = result.get("dat") + cols = pack.get("cols") + dat = pack.get("dat") if cols is None or dat is None: raise GreeProtocolError("No data received while getting device status") @@ -737,10 +1052,8 @@ async def gree_get_status( if len(cols) != len(dat): if len(cols) == 1: # if there is a single prop without value, add to missing - _LOGGER.error( - "Device '%s' was queried for invalid prop: %s", mac_addr, cols - ) - return {}, [cols] + _LOGGER.warning("Device queried for invalid prop: %s", cols) + return StatusResult(prop_values={}, missing_props=cols) raise GreeProtocolError(f"Malformed response: cols={len(cols)} dat={len(dat)}") @@ -751,39 +1064,48 @@ async def gree_get_status( returned_props.add(prop) status_values[prop] = value - invalid_props = [p for p in props if p not in returned_props] + invalid_props = [] + if props: + invalid_props = [p for p in props if p not in returned_props] + if len(invalid_props) > 0: + _LOGGER.info("Device queried for invalid props: %s", invalid_props) - _LOGGER.debug("Got status for device '%s': %s", mac_addr, status_values) - - if len(invalid_props) > 0: - _LOGGER.error( - "Device '%s' was queried for invalid props: %s", mac_addr, invalid_props - ) - return status_values, invalid_props + _LOGGER.debug("Got status for device: %s", status_values) + return StatusResult(prop_values=status_values, missing_props=invalid_props) async def gree_set_status( mac_addr_controller: str, mac_addr: str, uid: int, - props: dict[GreeProp, int], + prop_values: Mapping[str, int], cipher: CipherBase, - transport: GreeTransport, -) -> dict[GreeProp, int]: + transport: GreeBaseTransport, +) -> Mapping[str, int]: """Update one or more device properties. - Returns the property values acknowledged by the device. + Args: + mac_addr_controller: The MAC address of the device that controls the connection + mac_addr: MAC address of the device to set the status of. + uid: User ID for the device + prop_values: Dictionary of property names and values to set + cipher: Device cipher to encrypt and decrypt the JSON pack, if present + transport: Transport used to communicate with the device + + Returns: + The property values acknowledged by the device, with no guarantee of them being the changed ones. + Sometimes the return have them, sometimes don't or they actually miss props that were set successfully. + """ - _LOGGER.debug("Trying to set device status") + _LOGGER.debug("[%s] Trying to set device status", transport) - pack = gree_create_set_pack(mac_addr, props) - encrypted_pack, tag = gree_encrypt_pack(pack, cipher) - json_payload = gree_create_payload( - encrypted_pack, "pack", GreeCommand.STATUS, mac_addr_controller, uid, tag - ) + pack = _create_set_status_pack(mac_addr, prop_values) + json_payload = _create_payload(pack, "pack", 0, mac_addr_controller, uid) try: - result = await get_result_pack(json_payload, cipher, transport) + result = await gree_get_response_pack( + mac_addr_controller, json_payload, cipher, transport + ) except GreeConnectionError, GreeProtocolError: raise @@ -791,186 +1113,403 @@ async def gree_set_status( except Exception as err: raise GreeProtocolError("Error getting device status") from err - if result["r"] is None or result["r"] != 200: + if (result_code := result.get("r")) != 200: raise GreeProtocolError( - f"Error setting device status, response code: {result['r']}" + f"Error setting device status, response code: {result_code}" ) - options_set = [PROP_KEY_TO_ENUM[c] for c in result["opt"] if c in PROP_KEY_TO_ENUM] - if options_set is None or len(options_set) == 0: - raise GreeProtocolError("No options were set, something went wrong") - - values_set_1 = result.get("p", None) - values_set_2 = result.get("val", None) # this one is optional - - if values_set_1 is None: - raise GreeProtocolError("No values were set, something went wrong") - values_set_1 = list(map(int, values_set_1)) + # Gree protocol doesn't guarantee a return of {[opt]:[p]} + # Sometimes the response have them, sometimes don't or they actually miss set props + # As such, don't error/raise if that is the case + # the fields bellow are optional + options_set: list[str] = result.get("opt", []) + values_set_1 = list(map(int, result.get("p", []))) + values_set_2 = list(map(int, result.get("val", []))) # If present, must match [p] - if values_set_2 is not None: - values_set_2 = list(map(int, values_set_2)) - if len(values_set_1) != len(values_set_2): - raise GreeProtocolError( - f"Wrong option values received: {values_set_1} {values_set_2}" - ) + # In case the response has nothing, but didn't had a error code + # Assume all was set and return all given props + if len(options_set) == 0: + return prop_values - if len(values_set_1) != len(options_set): + if len(values_set_2) > 0 and len(values_set_1) != len(values_set_2): raise GreeProtocolError( f"Options and values set mismatch {options_set} {values_set_1}" ) updated_props = dict(zip(options_set, values_set_1, strict=True)) - if updated_props != props: - _LOGGER.warning("Expected updated props %s but got %s", props, updated_props) + if updated_props != prop_values: + _LOGGER.debug( + "[%s] Expected updated props %s but got %s", + transport, + prop_values, + updated_props, + ) return updated_props -async def gree_get_device_info( - transport: GreeTransport, cipher: CipherBase | None = None +async def _gree_get_scan( + mac: str, transport: GreeBaseTransport, cipher: CipherBase | None = None ) -> dict[str, str | dict | None]: """Retrieve device information from a scan response.""" - data: dict = await get_result_pack( - {"t": "scan"}, + pack: dict = await gree_get_response_pack( + mac, + {"t": GreeCommand.SCAN.value}, cipher or get_cipher(EncryptionVersion.V1), transport, ) - _LOGGER.debug("Got device info: %s", data) + _LOGGER.debug("Got device info: %s", pack) - info: dict[str, str | dict | None] = {} - info["raw"] = data - info["firmware_version"], info["firmware_code"] = _extract_fw_version(data) - info["mac"] = data.get("mac", "") - info["subdevices_count"] = data.get("subCnt", 0) - return info + return pack + + +def extract_fw_version(hid: str) -> tuple[str | None, str | None]: + """Extract the firmware version and code from device information. + + Args: + hid: Hid field from a device status + Returns: + str: Firmware version + str: Firmware code -def _extract_fw_version(info: dict) -> tuple[str | None, str | None]: - """Extract the firmware version and device identifier from device information.""" - hid = info.get("hid", "") + """ ver_match = re.search(r"V([\d.]+)\.bin", hid) - if ver_match: - ver = ver_match.group(1) # version from hid - else: - ver = info.get("ver") - ver = ver.lstrip("V") if ver else None # clean ver or None + fw_version = str(ver_match.group(1)) if ver_match else None id_match = re.match(r"(\d+)", hid) # leading digits - device_id = id_match.group(1) if id_match else None - return ver, device_id + fw_code = str(id_match.group(1)) if id_match else None + + return fw_version, fw_code -async def discover_gree_devices( - broadcast_addresses: list[str], timeout: int +async def _get_sub_devices_list( + mac_addr_controller: str, + uid: int, + cipher: CipherBase, + transport: GreeUdpTransport, + parent_device: GreeDiscoveredDevice | None = None, + expected: int | None = None, ) -> list[GreeDiscoveredDevice]: - """Discover Gree devices on the specified broadcast networks.""" + """Retrieve the list of sub-devices exposed by a main controller device. + + Args: + mac_addr_controller: The MAC address of the device that controls the connection + uid: User ID for the device + cipher: Device cipher to encrypt and decrypt the JSON pack, if present + transport: Transport used to communicate with the device + + Returns: + List of sub-devices directly from the controller response + + """ + + _LOGGER.debug( + "Retrieving subdevices for '%s' using '%s'", mac_addr_controller, transport + ) + + discovered_subdevices: list[GreeDiscoveredDevice] = [] + try: + pack = _create_get_subdevices_pack(mac_addr_controller) + + json_payload = _create_payload( + pack, + "subList", + 1, + mac_addr_controller, + uid, + ) + + response = await gree_get_response_pack( + mac_addr_controller, json_payload, cipher, transport + ) + + except Exception as err: + raise GreeProtocolError( + f"Error fetching sub-device list for '{mac_addr_controller}'" + ) from err + + else: + # Response in format: + # {"t":"subList","i":0,"c":6,"r":200,"list":[{"mac":"09c4a41d000000","mid":"6049"},...]} + sub_devs = response.get("list", []) + if expected and (response.get("c") != expected or len(sub_devs) != expected): + _LOGGER.warning( + "[%s] Expected %d sub-devices and found %d", + mac_addr_controller, + expected, + response.get("c"), + ) + + for sub_dev in sub_devs: + new_dev: GreeDiscoveredDevice + if parent_device: + new_dev = replace( + parent_device, + mac=sub_dev.get("mac"), + mid=sub_dev.get("mid"), + ) + else: + new_dev = GreeDiscoveredDevice( + mac=sub_dev.get("mac"), + mac_controller_local=mac_addr_controller, + host=transport.ip_addr, + port=transport.port, + mid=sub_dev.get("mid"), + ) + discovered_subdevices.append(new_dev) + + return discovered_subdevices + +async def _process_local_scan_response( + ip_address: str, pack: dict, user_id: int +) -> list[GreeDiscoveredDevice]: discovered_devices: list[GreeDiscoveredDevice] = [] - responses = await async_udp_broadcast_request( - broadcast_addresses, DEFAULT_DEVICE_PORT, json.dumps({"t": "scan"}), timeout + device = DeviceScanInfoResponse.model_validate(pack) + + if not device.mac: + _LOGGER.debug("No MAC address in response from %s", ip_address) + return discovered_devices + + mac, mac_controller = gree_extract_macs(device.mac) + + discovered_device = GreeDiscoveredDevice( + mac=mac, + mac_controller_local=mac_controller, + host=ip_address, + port=DEFAULT_DEVICE_PORT, + name=device.name or f"Gree {mac[-5:]}", + catalog=device.catalog or "", + brand=device.brand or "gree", + model=device.model or "gree", + model_type=device.ModelType or "", + vender=device.vender or "", + mid=device.mid or "", + hid=device.hid or "", + ver=device.ver or "", + user_id=DEFAULT_DEVICE_USERID, ) + discovered_devices.append(discovered_device) + + if device.subCnt and device.subCnt > 0: + # TODO: Ingest subdevices, need debugging. + # TODO: Is the device above also added, or only sub_devices? + transport = GreeUdpTransport(ip_address, DEFAULT_DEVICE_PORT) + sub_devices = await _get_sub_devices_list( + mac_controller, + user_id, + get_cipher(EncryptionVersion.V1), + transport, + discovered_device, + device.subCnt, + ) + discovered_devices.extend(sub_devices) + return discovered_devices - for address, response in responses.items(): - data = get_gree_response_data( - response, + +async def gree_discover_device_local( + ip_address: str, timeout: int, user_id: int +) -> list[GreeDiscoveredDevice]: + """Target scan to a single Gree device. + + Args: + ip_address: IP address of the target device + timeout: Timeout (s) to wait for device responses + user_id: User ID for the request + + Returns: + List of the discovered devices under the same target device + + """ + discovered_devices: list[GreeDiscoveredDevice] = [] + + try: + transport: GreeUdpTransport = GreeUdpTransport( + ip_addr=ip_address, timeout=timeout + ) + pack: dict = await gree_get_response_pack( + "", + {"t": GreeCommand.SCAN.value}, get_cipher(EncryptionVersion.V1), + transport, ) - if data is not None: - pack = data.get("pack") - if pack is not None: - if pack.get("t") == "dev": - mac_addr = pack.get("mac", "") - if not mac_addr: - _LOGGER.debug("No MAC address in response from %s", address) - continue - - # Just collect basic device info for now - encryption detection happens later - discovered_device = GreeDiscoveredDevice( - name=pack.get("name", "") or f"Gree {mac_addr[-4:]}", - host=address, - mac=mac_addr, - port=DEFAULT_DEVICE_PORT, - brand=pack.get("brand", "gree"), - model=pack.get("brand", "gree"), - uid=data.get("uid", 0), - subdevices=pack.get("subCnt", 0), - ) - - discovered_devices.append(discovered_device) - _LOGGER.debug("Discovered device: %s", discovered_device) - - # # If VRF, the mac is of the main device and we have to query it for the sub devices - # # Sub-devices will be created with a mac of sub@main - # # check if the device has sub-devices - # sub_count = pack.get("subCnt", 0) - - # if sub_count > 0: - # # Is VRF with multiple sub devices - # _LOGGER.debug( - # "Trying to fetching sub-devices for '%s' (subCount=%d)", - # mac_addr, - # sub_count, - # ) - # try: - # discovered_sub_devices = await get_sub_devices_list( - # discovered_device.mac, - # discovered_device.host, - # discovered_device.uid, - # max_connection_attempts=2, - # timeout=timeout, - # ) - - # for sub_device in discovered_sub_devices: - # sub_mac = sub_device.get("mac", "") - # if sub_mac: - # discovered_sub_device = GreeDiscoveredDevice( - # name=f"{discovered_device.name or f'Gree {mac_addr[-4:]}'}@{sub_mac[:4]}", - # host=discovered_device.host, - # mac=f"{sub_mac}@{discovered_device.mac}", - # port=discovered_device.port, - # brand=discovered_device.brand, - # model=sub_device.get("mid", discovered_device), - # uid=discovered_device.uid, - # ) - # discovered_devices.append(discovered_sub_device) - # _LOGGER.debug( - # "Discovered sub-device: %s", - # discovered_sub_device, - # ) - # except Exception: - # _LOGGER.exception("Failed to fetch sub-devices") + except Exception: + _LOGGER.exception("Fail in targeted scan") + return discovered_devices + + _LOGGER.debug("Got device info: %s", pack) + return await _process_local_scan_response(ip_address, pack, user_id) + + +async def gree_discover_devices_local( + broadcast_addresses: list[str], timeout: int, user_id: int +) -> list[GreeDiscoveredDevice]: + """Discover Gree devices on the network. + + Args: + broadcast_addresses: List of broadcast addresses to search + timeout: Timeout (s) to wait for device responses + user_id: User ID for the request + + Returns: + List of the discovered devices + + """ + + discovered_devices: list[GreeDiscoveredDevice] = [] + + responses = await async_udp_broadcast_request( + broadcast_addresses, + DEFAULT_DEVICE_PORT, + {"t": "scan"}, + timeout, + get_cipher(EncryptionVersion.V1), + ) + for address, response in responses.items(): + if response is not None: + pack = response.get("pack") + if pack is not None and pack.get("t") == "dev": + discovered_devices.extend( + await _process_local_scan_response(address, pack, user_id) + ) + + _LOGGER.info("Found total of %d local devices", len(discovered_devices)) return discovered_devices -async def gree_get_sub_devices_list( - mac_addr: str, uid: int, cipher: CipherBase, transport: GreeTransport -) -> list: - """Retrieve the list of sub-devices exposed by a main controller device.""" - try: - pack = gree_create_sub_bind_pack(mac_addr) - encrypted_pack, tag = gree_encrypt_pack( - pack, - cipher, +async def gree_discover_devices_cloud( + cloud_api: GreeCloudApi, +) -> list[GreeDiscoveredDevice]: + """Discover Gree devices on the Gree API. + + Args: + cloud_api: The cloud API endpoint to get the devices from + + Returns: + List of the discovered devices + + """ + discovered_devices: list[GreeDiscoveredDevice] = [] + + responses = await cloud_api.get_all_devices() + + for dev in responses: + mac, mac_controller = gree_extract_macs(dev.mac) + discovered_devices.append( + GreeDiscoveredDevice( + mac=mac, + mac_controller_mqtt=mac_controller, + user_id=cloud_api.user_id or DEFAULT_DEVICE_USERID, + key=dev.key, + username=cloud_api.username, + name=dev.name, + catalog=dev.catalog, + brand=dev.brand, + model=dev.prodModel, + model_type=dev.subdivCode, + vender=dev.vender, + mid=dev.mid, + hid=dev.hid, + ver=dev.ver, + ) ) + return discovered_devices - json_payload = gree_create_payload( - encrypted_pack, - "subList", - GreeCommand.BIND, - mac_addr, - uid, - tag, + +async def gree_discover_devices( + cloud_api: GreeCloudApi | None, + broadcast_addresses: list[str] | None, + timeout: int = 3, +) -> list[GreeDiscoveredDevice]: + """Discover Gree Devices. + + Args: + cloud_api: The cloud API endpoint to get the devices from (Optional) + broadcast_addresses: List of broadcast addresses to search (Optional) + timeout: Timeout (s) to wait for device responses + + Returns: + De-duplicated list of discovered devices. + + """ + cloud_devices: list[GreeDiscoveredDevice] = [] + local_devices: list[GreeDiscoveredDevice] = [] + + if cloud_api: + cloud_devices = await gree_discover_devices_cloud(cloud_api) + for dev in cloud_devices: + _LOGGER.debug(repr(dev)) + + if broadcast_addresses: + local_devices = await gree_discover_devices_local( + broadcast_addresses, + timeout, + cloud_api.user_id if cloud_api and cloud_api.user_id else 0, ) - result = await get_result_pack(json_payload, cipher, transport) + for dev in local_devices: + _LOGGER.debug(repr(dev)) - return result.get("list", []) + if len(cloud_devices) > 0 or len(local_devices) > 0: + return gree_merge_discovered_devices(local_devices, cloud_devices) - except Exception as err: - raise GreeProtocolError( - f"Error fetching sub-device list for '{mac_addr}'" - ) from err + return [] + + +def gree_merge_discovered_devices( + local_devices: list[GreeDiscoveredDevice], + cloud_devices: list[GreeDiscoveredDevice], +) -> list[GreeDiscoveredDevice]: + """Merge local and cloud discovered devices in a single concise list.""" + local_map = {d.mac: d for d in local_devices} + cloud_map = {d.mac: d for d in cloud_devices} + + merged = [] + + for mac in sorted(local_map.keys() | cloud_map.keys()): + local = local_map.get(mac) + cloud = cloud_map.get(mac) + + # Exists only locally + if cloud is None and local: + merged.append(local) + continue + + # Exists only in cloud + if local is None and cloud: + merged.append(cloud) + continue + + if local is None or cloud is None: + continue + + # Start with cloud values + merged_device = GreeDiscoveredDevice(**vars(cloud)) + + # Override with non-empty local values + # So that local info overrides cloud, especially for MAC + for field in fields(GreeDiscoveredDevice): + value = getattr(local, field.name) + + if value is None: + continue + + if isinstance(value, str) and value == "": + continue + + setattr(merged_device, field.name, value) + + # Prefer cloud name and uid + merged_device.name = cloud.name + merged_device.user_id = cloud.user_id + + merged.append(merged_device) + + _LOGGER.debug("Merged devices: %d", len(merged)) + + return merged diff --git a/custom_components/gree_custom/aiogree/cipher.py b/custom_components/gree_custom/aiogree/cipher.py old mode 100644 new mode 100755 index c547d4b..09bccca --- a/custom_components/gree_custom/aiogree/cipher.py +++ b/custom_components/gree_custom/aiogree/cipher.py @@ -4,11 +4,11 @@ import base64 from enum import IntEnum, unique import logging +from typing import override -from Crypto.Cipher import AES -from Crypto.Cipher._mode_ecb import EcbMode -from Crypto.Cipher._mode_gcm import GcmMode -from Crypto.Util.Padding import pad, unpad +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from .errors import GreeError @@ -21,6 +21,7 @@ GREE_GENERIC_DEVICE_KEY_GCM = "{yxAHAY_Lm6pbC/<" AES_BLOCK_SIZE = 16 +AES_BLOCK_SIZE_BITS = AES_BLOCK_SIZE * 8 # Cryptography uses bits for block size @unique @@ -68,40 +69,51 @@ def __init__(self, key: str | None) -> None: """Initialize V1 Encryption.""" super().__init__(key or GREE_GENERIC_DEVICE_KEY_ECB) - def _create_cipher(self) -> EcbMode: - return AES.new(self._key, AES.MODE_ECB) + def _create_cipher(self) -> Cipher[modes.ECB]: + return Cipher(algorithms.AES(self._key), modes.ECB(), backend=default_backend()) @property + @override def version(self) -> EncryptionVersion: """The encryption version of this cypher.""" return EncryptionVersion.V1 + @override def encrypt(self, data: str) -> tuple[str, str | None]: """Encrypt data with V1.""" _LOGGER.debug("Encrypting data (V1): %s", data) + # padding + padder = padding.PKCS7(AES_BLOCK_SIZE_BITS).padder() + padded_data = padder.update(data.encode()) + padder.finalize() + + # cipher cipher = self._create_cipher() - padded = pad(data.encode("utf-8"), AES_BLOCK_SIZE) + encryptor = cipher.encryptor() + encrypted = encryptor.update(padded_data) + encryptor.finalize() - encrypted = cipher.encrypt(padded) - encoded = base64.b64encode(encrypted).decode("utf-8") + encoded = base64.b64encode(encrypted).decode() _LOGGER.debug("Encrypted data (V1): %s", encoded) return encoded, None + @override def decrypt(self, data: str, tag: str | None = None) -> str: """Decrypt data with V1.""" _LOGGER.debug("Decrypting data (V1): %s", data) cipher = self._create_cipher() + decryptor = cipher.decryptor() decoded = base64.b64decode(data) - decrypted = cipher.decrypt(decoded) + decrypted = decryptor.update(decoded) + decryptor.finalize() try: - plaintext = unpad(decrypted, AES_BLOCK_SIZE).decode() - except ValueError: + unpadder = padding.PKCS7(AES_BLOCK_SIZE_BITS).unpadder() + plaintext_bytes = unpadder.update(decrypted) + unpadder.finalize() + plaintext = plaintext_bytes.decode() + except ValueError, Exception: # noqa: BLE001 # GREE PROTOCOL: Fallback for some devices sending malformed padding plaintext = decrypted.decode(errors="ignore") @@ -117,30 +129,39 @@ def __init__(self, key: str | None) -> None: """Initialize V2 Encryption.""" super().__init__(key or GREE_GENERIC_DEVICE_KEY_GCM) - def _create_cipher(self) -> GcmMode: - cipher = AES.new(self._key, AES.MODE_GCM, nonce=GCM_IV) - cipher.update(GCM_ADD) - return cipher + def _create_cipher(self, tag: bytes | None = None) -> Cipher[modes.GCM]: + return Cipher( + algorithms.AES(self._key), + modes.GCM(GCM_IV, tag), + backend=default_backend(), + ) @property + @override def version(self) -> EncryptionVersion: """The encryption version of this cypher.""" return EncryptionVersion.V2 + @override def encrypt(self, data: str) -> tuple[str, str]: """Encrypt data with V2 and return the data with a tag.""" _LOGGER.debug("Encrypting data (V2): %s", data) cipher = self._create_cipher() + encryptor = cipher.encryptor() + encryptor.authenticate_additional_data(GCM_ADD) - encrypted, tag = cipher.encrypt_and_digest(data.encode("utf-8")) + encrypted = encryptor.update(data.encode()) + encryptor.finalize() + tag = encryptor.tag + # encrypted, tag = cipher.encrypt_and_digest(data.encode("utf-8")) - encoded = base64.b64encode(encrypted).decode("utf-8") - tag_encoded = base64.b64encode(tag).decode("utf-8") + encoded = base64.b64encode(encrypted).decode() + tag_encoded = base64.b64encode(tag).decode() _LOGGER.debug("Encrypted data (V2): %s, tag='%s'", encoded, tag_encoded) return encoded, tag_encoded + @override def decrypt(self, data: str, tag: str | None) -> str: """Decrypt data with V2 and verify the data with the tag.""" _LOGGER.debug("Decrypting data (V2): %s, tag=%s", data, tag) @@ -148,13 +169,19 @@ def decrypt(self, data: str, tag: str | None) -> str: if not tag: raise GreeError("Decrypting data (V2) failed: tag is needed") - cipher = self._create_cipher() - - decoded = base64.b64decode(data) + decoded_data = base64.b64decode(data) decoded_tag = base64.b64decode(tag) - decrypted = cipher.decrypt_and_verify(decoded, decoded_tag) - plaintext = decrypted.decode("utf-8") + cipher = self._create_cipher(tag=decoded_tag) + decryptor = cipher.decryptor() + decryptor.authenticate_additional_data(GCM_ADD) + + # decrypted = cipher.decrypt_and_verify(decoded_data, decoded_tag) + try: + decrypted = decryptor.update(decoded_data) + decryptor.finalize() + plaintext = decrypted.decode() + except Exception as err: + raise GreeError(f"GCM Decryption failed: {err}") from err _LOGGER.debug("Decrypted data successfully (V2)") return _trim_json_payload(plaintext) @@ -179,7 +206,7 @@ def _trim_json_payload(data: str) -> str: def get_cipher( - encryption_version: EncryptionVersion, key: str | None = None + encryption_version: EncryptionVersion | None, key: str | None = None ) -> CipherBase: """Get AES cipher object based on encryption version using default keys.""" diff --git a/custom_components/gree_custom/aiogree/cloud_api.py b/custom_components/gree_custom/aiogree/cloud_api.py new file mode 100755 index 0000000..f5e5470 --- /dev/null +++ b/custom_components/gree_custom/aiogree/cloud_api.py @@ -0,0 +1,555 @@ +"""Gree Cloud API Client. + +Based on: https://github.com/luc10/gree-api-client + +Allows authentication with Gree Cloud and retrieval of device information +including encryption keys required for MQTT communication. +""" + +import base64 +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import StrEnum +import hashlib +import json +import logging +from types import TracebackType +from typing import Self + +import aiohttp +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from pydantic import BaseModel, ConfigDict, Field + +from .errors import GreeCloudError, GreeCloudLoginError + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class CloudHome: + """Information about a Gree Cloud home.""" + + id: int + name: str + + +@dataclass +class CloudCredentials: + """Gree Cloud authentication credentials.""" + + user_id: int + token: str + + +class CloudDeviceInfoResponse(BaseModel): + """Response format for a cloud device discovered using the Gree Cloud.""" + + model_config = ConfigDict(extra="ignore") + + mac: str + pmac: str + name: str + catalog: str + brand: str + mid: str + subdivCode: str # noqa: N815 + vender: str + key: str + barCode: str # noqa: N815 + longitude: str + latitude: str + altitude: str + city: str + bindTime: str # noqa: N815 + selfLearning: int # noqa: N815 + ssid: str + autoRepair: int # noqa: N815 + authorize: str + thirdpartyId: str # noqa: N815 + ver: str + prodModel: str # noqa: N815 + hid: str + institutionCodeSN: str # noqa: N815 + devNoteName: str # noqa: N815 + isHidden: int # noqa: N815 + devExt: str # noqa: N815 + regionalControl: str # noqa: N815 + homeSort: int # noqa: N815 + + +class FirmwareInfoResponse(BaseModel): + """Response format for a firmware information query.""" + + model_config = ConfigDict(populate_by_name=True) + + create_date: str | None = Field(default=None, alias="CreateDate") + comm_protocol_version: str | None = Field(default=None, alias="commProtVer") + description: str | None = Field(default=None, alias="desc") + forced_upgrade: bool | None = Field(default=None, alias="forcedUpgrade") + forced_upgrade_type: int | None = Field(default=None, alias="frcUpgdType") + result: int | None = Field(default=None, alias="r") + url: str | None = None + version: str | None = Field(default=None, alias="ver") + + +class GreeRegion(StrEnum): + """List of supported Gree regions.""" + + AU = "Australia" + CN = "China Mainland" + AS = "East South Asia" + EU = "Europe" + IN = "India" + LA = "Latin American" + ME = "Middle East" + US = "North American" + RU = "Russia" + SA = "South American" + + +CLOUD_SERVERS = { + GreeRegion.AU: "https://augrih.gree.com", + GreeRegion.CN: "https://grih.gree.com", + GreeRegion.AS: "https://hkgrih.gree.com", + GreeRegion.EU: "https://eugrih.gree.com", + GreeRegion.IN: "https://ingrih.gree.com", + GreeRegion.LA: "https://lagrih.gree.com", + GreeRegion.ME: "https://megrih.gree.com", + GreeRegion.US: "https://nagrih.gree.com", + GreeRegion.RU: "https://rugrih.gree.com", + GreeRegion.SA: "https://sagrih.gree.com", +} + + +class GreeCloudApi: + """Gree Cloud API Client. + + Provides authentication and device discovery for Gree Cloud services. + """ + + # App constants from reverse engineering + APP_ID = "4920681951525131286" + APP_HASH = "0fa513124aa97781d1f3f40d61ca1a89" + AES_KEY = b"#G$&^jgfujy6ujxt" + + def __init__(self, region: GreeRegion, username: str, password: str) -> None: + """Initialize the Gree Cloud API client. + + Args: + base_url: The regional Gree Cloud server URL + username: User email/username + password: User password (will be hashed internally) + + """ + self.region = region + self.base_url = CLOUD_SERVERS[region] + self.username = username + self.password = password + self.user_id: int | None = None + self.token: str | None = None + + # Create session with timeout + timeout = aiohttp.ClientTimeout(total=30, connect=10) + self._session: aiohttp.ClientSession | None = aiohttp.ClientSession( + timeout=timeout + ) + + @classmethod + def for_server( + cls, region: GreeRegion, username: str, password: str + ) -> GreeCloudApi: + """Create API client for a specific server region. + + Args: + region: Gree region (e.g., 'Europe', 'North American') + username: User email/username + password: User password + + Returns: + GreeCloudApi instance configured for the specified region + + """ + + return cls(region, username, password) + + def _md5(self, input_str: str) -> str: + """Calculate MD5 hash.""" + return hashlib.md5(input_str.encode("utf-8")).hexdigest() + + def _prepare_body( + self, payload: dict, date: datetime, hash_props: list[str] + ) -> dict: + """Prepare request body with authentication. + + Args: + payload: Request payload data + date: Current datetime (should be UTC) + hash_props: List of property names to include in hash calculation + + Returns: + Complete request body with API authentication + + """ + # Use UTC time for consistency with server + t = date.strftime("%Y-%m-%d %H:%M:%S") + r = int(date.timestamp()) + + # Generate verification code + vc = self._md5(f"{self.APP_ID}_{self.APP_HASH}_{t}_{r}") + + # Generate data verification code + props = [str(payload[p]) for p in hash_props] + dat_vc = self._md5(f"{self.APP_HASH}_{'_'.join(props)}") + + return { + "api": { + "appId": self.APP_ID, + "r": r, + "t": t, + "vc": vc, + }, + "datVc": dat_vc, + **payload, + } + + def _encrypt(self, data: str) -> bytes: + """Encrypt data with AES-128-ECB.""" + + # Setup padding (PKCS7) + padder = padding.PKCS7(128).padder() + padded_data = padder.update(data.encode()) + padder.finalize() + + # Setup Cipher + cipher = Cipher( + algorithms.AES(self.AES_KEY), modes.ECB(), backend=default_backend() + ) + encryptor = cipher.encryptor() + return encryptor.update(padded_data) + encryptor.finalize() + + def _decrypt(self, data: bytes) -> str: + """Decrypt data with AES-128-ECB.""" + # Setup Cipher + cipher = Cipher( + algorithms.AES(self.AES_KEY), modes.ECB(), backend=default_backend() + ) + decryptor = cipher.decryptor() + + # Decrypt + decrypted_padded = decryptor.update(data) + decryptor.finalize() + + # Remove padding (PKCS7) + unpadder = padding.PKCS7(128).unpadder() + + try: + unpadded_data = unpadder.update(decrypted_padded) + unpadder.finalize() + return unpadded_data.decode() + except Exception: # noqa: BLE001 + # Fallback for malformed padding if necessary + return decrypted_padded.decode(errors="ignore") + + async def _send_request(self, endpoint: str, data: str) -> str: + """Send POST request to API. + + Args: + endpoint: API endpoint path + data: JSON data to send + + Returns: + Encrypted response string + + """ + + url = f"{self.base_url}{endpoint}" + + encrypted_body = self._encrypt(data) + base64_body = base64.b64encode(encrypted_body).decode("utf-8") + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Gaen1": "5ac2bdf935bcca70", + "Charset": "utf-8", + } + + _LOGGER.debug("Sending request to %s", url) + + if self._session is None: + raise GreeCloudError("No HTTP session") + + # Use persistent session with timeout + async with self._session.post( + url, data=base64_body, headers=headers + ) as response: + if response.status != 200: + raise GreeCloudError(f"HTTP {response.status}: {response.reason}") + + json_data = await response.json() + return str(json_data["enRes"]) + + async def login(self) -> CloudCredentials: + """Login to Gree Cloud. + + Returns: + CloudCredentials with user_id and token + + """ + + # IMPORTANT: Use UTC time to match server time + date = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow + t = date.strftime("%Y-%m-%d %H:%M:%S") + + # Hash password using Gree's algorithm + h = self._md5(self._md5(self.password) + self.password) + psw = self._md5(h + t) + + body = json.dumps( + self._prepare_body( + { + "psw": psw, + "t": t, + "user": self.username, + }, + date, + ["user", "psw", "t"], + ) + ) + + encrypted_response = await self._send_request("/App/UserLoginV2", body) + decrypted = self._decrypt(base64.b64decode(encrypted_response)) + + _LOGGER.debug("Login response (decrypted): %s", decrypted) + + data = json.loads(decrypted) + _LOGGER.debug("Login response (parsed): %s", data) + + # Check for error response + if "r" in data and data["r"] != 200: + raise GreeCloudLoginError( + f"Login failed: {data.get('msg', 'Unknown error')}" + ) + + # Handle different response formats + if "uid" in data: + user_id = data["uid"] + token = data["token"] + elif "data" in data and isinstance(data["data"], dict): + user_id = data["data"].get("uid") + token = data["data"].get("token") + else: + raise GreeCloudError(f"Unexpected login response format: {data}") + + if not user_id or not token: + raise GreeCloudLoginError(f"Missing uid or token in response: {data}") + + self.user_id = user_id + self.token = token + + _LOGGER.info("Successfully logged in as user %s", self.user_id) + + return CloudCredentials(user_id=user_id, token=token) + + async def get_homes(self) -> list[CloudHome]: + """Get list of homes. + + Returns: + List of CloudHome objects + + """ + + if not self.user_id or not self.token: + raise GreeCloudError("Not logged in. Call login() first.") + + date = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow + + body = json.dumps( + self._prepare_body( + { + "token": self.token, + "uid": self.user_id, + }, + date, + ["token", "uid"], + ) + ) + + encrypted_response = await self._send_request("/App/GetHomes", body) + decrypted = self._decrypt(base64.b64decode(encrypted_response)) + data = json.loads(decrypted) + _LOGGER.debug(data) + + homes = [CloudHome(id=h["id"], name=h["name"].strip()) for h in data["home"]] + + _LOGGER.info("Found %d homes", len(homes)) + return homes + + async def get_devices(self, home_id: int) -> list[CloudDeviceInfoResponse]: + """Get list of devices in a home. + + Args: + home_id: ID of the home + + Returns: + List of CloudDeviceInfo objects + + """ + + if not self.user_id or not self.token: + raise GreeCloudError("Not logged in. Call login() first.") + + date = datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow + + body = json.dumps( + self._prepare_body( + { + "token": self.token, + "homeId": home_id, + "uid": self.user_id, + }, + date, + ["token", "uid", "homeId"], + ) + ) + + encrypted_response = await self._send_request( + "/App/GetDevsInRoomsOfHomeV2", body + ) + decrypted = self._decrypt(base64.b64decode(encrypted_response)) + data = json.loads(decrypted) + _LOGGER.debug(data) + + devices = [] + for room in data["rooms"]: + for dev in room["devs"]: + device = CloudDeviceInfoResponse.model_validate(dev) + devices.append(device) + + _LOGGER.info("Found %d devices in home %d", len(devices), home_id) + return devices + + async def get_all_devices(self) -> list[CloudDeviceInfoResponse]: + """Get all devices from all homes. + + Filters out duplicate devices with same key. When duplicates exist, + keeps the one with MAC ending in '00' (responsive) and hides the one without. + + Returns: + List of all CloudDeviceInfo objects across all homes (deduplicated) + + """ + homes = await self.get_homes() + all_devices: list[CloudDeviceInfoResponse] = [] + + for home in homes: + devices = await self.get_devices(home.id) + all_devices.extend(devices) + + # Filter duplicates: when same key exists with MACs where one ends with '00' + filtered_devices = self._filter_duplicate_devices_complete(all_devices) + + if len(filtered_devices) < len(all_devices): + _LOGGER.info( + "Filtered out %d duplicate device(s)", + len(all_devices) - len(filtered_devices), + ) + + _LOGGER.info( + "Found total of %d devices across all homes", len(filtered_devices) + ) + return filtered_devices + + async def close(self) -> None: + """Close the HTTP session.""" + if self._session and not self._session.closed: + await self._session.close() + _LOGGER.debug("HTTP session closed") + + async def __aenter__(self) -> Self: + """Async context manager enter.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + """Async context manager exit.""" + await self.close() + return False + + def _filter_duplicate_devices_complete( + self, devices: list[CloudDeviceInfoResponse] + ) -> list[CloudDeviceInfoResponse]: + """Filter out duplicate devices with same key. + + When devices share the same encryption key but have different MACs + (one normal, one ending with '00'), keep only the one with '00'. + The device without '00' suffix doesn't respond to commands. + + Args: + devices: List of devices to filter + + Returns: + Filtered list without duplicates + + """ + # Group devices by encryption key + key_groups: dict[str, list[CloudDeviceInfoResponse]] = {} + for device in devices: + if device.key not in key_groups: + key_groups[device.key] = [] + key_groups[device.key].append(device) + + filtered = [] + for group in key_groups.values(): + if len(group) == 1: + # No duplicates, keep as is + filtered.append(group[0]) + else: + # Multiple devices with same key - filter by MAC + # Prefer device >12 chars and ending in "00" + devices_over_12 = [ + d for d in group if len(d.mac) > 12 and d.mac.endswith("00") + ] + devices_under_12 = [ + d for d in group if not (len(d.mac) > 12 and d.mac.endswith("00")) + ] + + if devices_over_12: + # Keep device(s) with '00' suffix + filtered.extend(devices_over_12) + if devices_under_12: + _LOGGER.debug( + "Filtering out non-responsive device(s) without '00': %s", + [d.mac for d in devices_under_12], + ) + else: + # No device with '00' found, keep all (shouldn't happen but be safe) + filtered.extend(group) + + return filtered + + +async def gree_get_latest_firmware_info( + region: GreeRegion, + firmware_code: str, + timeout: float = 10.0, +) -> FirmwareInfoResponse | None: + """Fetch firmware information from a Gree firmware server.""" + endpoint = f"{CLOUD_SERVERS[region]}/wifiModule/Lastversion" + + async with ( + aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session, + session.get( + endpoint, + params={"firmwareCode": firmware_code}, + ) as response, + ): + response.raise_for_status() + data = await response.json() + + if data.get("r") != 200: + return None + + return FirmwareInfoResponse.model_validate(data) diff --git a/custom_components/gree_custom/aiogree/const.py b/custom_components/gree_custom/aiogree/const.py old mode 100644 new mode 100755 index ec7208e..37a4905 --- a/custom_components/gree_custom/aiogree/const.py +++ b/custom_components/gree_custom/aiogree/const.py @@ -1,5 +1,7 @@ """Constants for the aiogree.""" +MAX_PACK_SIZE = 512 + MIN_TEMP_C = 16 MAX_TEMP_C = 30 @@ -12,5 +14,5 @@ MIN_HUM_DRY_P = 30 MAX_HUM_DRY_P = 70 -DEFAULT_DEVICE_UID = 0 +DEFAULT_DEVICE_USERID = 0 DEFAULT_DEVICE_PORT = 7000 diff --git a/custom_components/gree_custom/aiogree/device.py b/custom_components/gree_custom/aiogree/device.py index 9a8d47d..95e4765 100755 --- a/custom_components/gree_custom/aiogree/device.py +++ b/custom_components/gree_custom/aiogree/device.py @@ -1,36 +1,35 @@ """Contains the API to interface with the Gree device.""" -from itertools import islice +from collections.abc import Mapping import logging from typing import Any +from aiomqtt import MqttError +from packaging.version import Version + from .api import ( - PROP_KEY_TO_ENUM, EncryptionVersion, FanSpeed, - GreeDiscoveredDevice, GreeProp, HorizontalSwingMode, HumidityControlMode, + InfoProp, OperationMode, - OtherProps, SleepMode, TemperatureUnits, VerticalSwingMode, - gree_get_device_info, - gree_get_status, - gree_get_sub_devices_list, - gree_set_status, - gree_try_bind, + extract_fw_version, ) -from .cipher import CipherBase, get_cipher +from .cloud_api import FirmwareInfoResponse, GreeRegion, gree_get_latest_firmware_info from .const import ( - DEFAULT_DEVICE_UID, + DEFAULT_DEVICE_USERID, MAX_HUM_COOL_P, MAX_HUM_DRY_P, MIN_HUM_COOL_P, MIN_HUM_DRY_P, ) +from .device_api_client import DeviceApiClient +from .device_state import DeviceState from .errors import ( GreeBindingError, GreeConnectionError, @@ -40,11 +39,9 @@ GreeHumidityControlTargetUnavailable, GreeHumidityControlUnavailable, GreeProtocolError, - GreeQuietIgnored, GreeSleepUnavailable, GreeSmartDryUnavailable, GreeSmartHeatUnavailable, - GreeTurboIgnored, GreeTurboUnavailable, ) from .helpers import ( @@ -55,438 +52,339 @@ gree_get_target_temp_props_from_f, gree_get_target_temperature_c, gree_get_target_temperature_f, + redact_str, ) -from .transport import GreeTransport +from .transport import GreeBaseTransport +from .transport_mqtt import GreeMqttTransport +from .transport_udp import GreeUdpTransport _LOGGER = logging.getLogger(__name__) -def chunked(iterable, size): - """Creates chunks of data.""" - it = iter(iterable) - while chunk := list(islice(it, size)): - yield chunk - - class GreeDevice: """Representation of a Gree device.""" def __init__( self, name: str, - ip_addr: str, mac_addr: str, - port: int, - encryption_key: str, - encryption_version: EncryptionVersion | None = None, - uid: int = DEFAULT_DEVICE_UID, - max_connection_attempts: int = 5, - timeout: int = 10, + preferred_encryption_key: str | None = None, + user_id: int = DEFAULT_DEVICE_USERID, capabilities: list[GreeProp] | None = None, ) -> None: """Initialize the Gree device.""" _LOGGER.info( - "Initialize the GREE Device API for: %s (%s:%d)", + "[%s] Initializing the GREE Device API", mac_addr, - ip_addr, - port, - ) - _LOGGER.debug( - "Version: %s, Key: %s[redacted]", encryption_version, encryption_key[:5] ) self._name: str = name - self._ip_addr: str = ip_addr - self._port: int = port - self._max_connection_attempts: int = max_connection_attempts - self._timeout: int = timeout - - # For VRF units, the mac will be in the sub_device@main_device format - # where the sub_device is the device we are controling and - # main_device is the controller for that sub_device - mac_addr = mac_addr.replace(":", "").replace("-", "").lower() - - if "@" in mac_addr: - self._mac_addr, self._mac_addr_controller = mac_addr.split("@", 1) - else: - self._mac_addr = self._mac_addr_controller = mac_addr - - self._transport = GreeTransport(ip_addr, port, max_connection_attempts, timeout) - self._uniqueid: str = self._mac_addr + self._mac_addr = mac_addr - self._encryption_version: EncryptionVersion | None = encryption_version - self._encryption_key: str = encryption_key - self._cipher: CipherBase | None = None - self._uid: int = uid + self._preferred_encryption_key: str | None = preferred_encryption_key - self._raw_state: dict[GreeProp, int] = {} - self._new_raw_state: dict[GreeProp, int] = {} - - if capabilities is None: - self._capabilities: list[GreeProp] = list(GreeProp) - else: - self._capabilities: list[GreeProp] = capabilities - - self._is_bound: bool = False - self._is_available: bool = False - - self._props_to_update: list[GreeProp] = list(GreeProp) - # Don't poll the beeper state - self._props_to_update.remove(GreeProp.BEEPER) - self._props_to_update.remove(GreeProp.BEEPER_NEW) + self._capabilities: list[GreeProp] = capabilities or list(GreeProp) self._temp_processor_indoors: TempOffsetResolver | None = None self._temp_processor_outdoors: TempOffsetResolver | None = None self._beeper = False - self._raw_info: dict[str, Any] = {} self._firmware_version: str | None = None self._firmware_code: str | None = None - self._subdevicesCount: int = 0 - - async def bind_device(self) -> bool: - """Setup the device (async).""" + self._firmware_protocol_version: str = "" - if self._is_bound: - return True + self._state = DeviceState( + device_id=self.unique_id, capabilities=self._capabilities + ) - # Use fetch_device_info (targeted scan) to the device - # since binding only succeeds after a scan - try: - await self.fetch_device_info() + self._client = DeviceApiClient( + mac=self._mac_addr, + userid=user_id, + ) - except GreeConnectionError: - raise + async def bind_with_transport( + self, + preferred_local_version: EncryptionVersion | None = None, + local_controller_mac: str | None = None, + local_transport: GreeUdpTransport | None = None, + mqtt_controller_mac: str | None = None, + mqtt_transport: GreeMqttTransport | None = None, + ) -> None: + """Bind the device to a new transport. It will try local transport first and then MQTT.""" + await self._client.unbind() - except Exception as err: + if not local_transport and not mqtt_transport: raise GreeBindingError( - "Could not fetch device info before binding" - ) from err - - try: - key, version = await gree_try_bind( - self._mac_addr_controller, - self._uid, - self._encryption_version, - self._encryption_key, - self._transport, - ) - - except GreeBindingError: - raise - except Exception as e: - raise GreeBindingError(f"Failed binding to device {self._ip_addr}") from e - - else: - self._encryption_key = key - self._encryption_version = version - _LOGGER.info( - "Device is bound with version %s and key %s", - version, - key[:5] + "[redacted]", + f"No transport provided for {self._mac_addr} to bind with" ) - self._cipher = get_cipher(version, key) - self._is_available = True - self._is_bound = True + attempts: list[tuple[GreeBaseTransport, str, EncryptionVersion | None]] = [] - return True + if local_transport: + if not local_controller_mac: + _LOGGER.error("No controller MAC provided for local transport") + else: + attempts.append( + (local_transport, local_controller_mac, preferred_local_version) + ) - async def fetch_device_info(self, cipher: CipherBase | None = None): - """Updates the device info fields.""" - try: - self._raw_info = await gree_get_device_info( - self._transport, cipher or self._cipher - ) - except GreeConnectionError: - raise + if mqtt_transport: + if not mqtt_controller_mac: + _LOGGER.error("No controller MAC provided for MQTT transport") + else: + attempts.append( + (mqtt_transport, mqtt_controller_mac, EncryptionVersion.V1) + ) - except Exception as e: - raise GreeProtocolError( - f"Failed fetching device info for {self._ip_addr}" - ) from e + error: Exception = GreeBindingError( + f"Could not perform binding with {self._mac_addr} with any transport" + ) - else: - if self._raw_info.get("mac", "") != self._mac_addr_controller: - raise GreeProtocolError( - f"Wrong device info for {self._ip_addr}. MAC mismatch {self._raw_info.get('mac', '')} not {self._mac_addr_controller}." + for transport, mac_controller, version in attempts: + await self._client.set_transport(transport) + try: + await self._client.bind( + controller_mac=mac_controller, + preferred_version=version, + preferred_key=self._preferred_encryption_key, ) - self._firmware_version = self._raw_info.get("firmware_version") - self._firmware_code = self._raw_info.get("firmware_code") - self._subdevicesCount = int(self._raw_info.get("subdevices_count", 0) or 0) - - async def fetch_sub_devices(self) -> list[GreeDiscoveredDevice]: - """Get the sub devices list.""" - _LOGGER.debug("Trying to get subdevices") - if not self._is_bound: - await self.bind_device() + except GreeError as err: + error = err + await self._client.unbind() + _LOGGER.warning( + "[%s] Failed binding via %s", + self.unique_id, + transport, + exc_info=True, + ) + except MqttError as err: + error = err + await self._client.unbind() + _LOGGER.warning( + "[%s] Failed binding via %s", + self.unique_id, + transport, + exc_info=True, + ) + else: + # Fetch initial information after sucessful bind + await self.fetch_device_info() + await self.fetch_device_status() + self._remove_unsupported_props() + return + + raise error + + async def unbind_device(self) -> None: + """Properly disconnect the device from transport.""" + if not self._client.bound: + return - assert self._cipher is not None + try: + await self._client.unbind() + except GreeConnectionError: + raise - if not self._subdevicesCount: - return [] + except Exception as err: + raise GreeBindingError( + f"Problem unbinding {self._mac_addr} in {self.transport}" + ) from err - if self._mac_addr != self._mac_addr_controller: - return [] # For VRF, a non main device does not have subdevices + async def fetch_device_info(self) -> None: + """Update the device info state fields.""" - discovered_devices: list[GreeDiscoveredDevice] = [] + _LOGGER.debug( + "[%s:%s] Trying to get device info", + self.unique_id, + self.transport, + ) try: - subs = await gree_get_sub_devices_list( - self._mac_addr_controller, - self._uid, - self._cipher, # NOTE: Check if this should use the generic or the device key - self._transport, + props = [prop.value for prop in InfoProp] + raw_info, _ = await self._client.query_props(props, len(props)) + + except GreeConnectionError, GreeProtocolError: + _LOGGER.exception( + "[%s:%s] Failed fetching device device info", + self.unique_id, + self.transport, ) - except GreeProtocolError: - self._is_available = False raise except Exception as err: - self._is_available = False - raise GreeError("Error getting subdevices") from err + _LOGGER.exception( + "[%s:%s] Failed fetching device device info", + self.unique_id, + self.transport, + ) + raise GreeProtocolError( + f"Failed fetching device info for {self._mac_addr} via {self.transport}" + ) from err else: - for sub_device in subs: - sub_mac = sub_device.get("mac", "") - if sub_mac: - discovered_sub_device = GreeDiscoveredDevice( - name=f"{sub_device.get('name', '') or f'Gree {sub_mac[:4]}@{self.mac_address_controller[-4:]}'}", - host=self._ip_addr, - mac=sub_mac, - port=self._port, - brand=sub_device.get("brand", "Gree"), - model=sub_device.get("mid", "HVAC"), - uid=self._uid, - subdevices=0, - ) - discovered_devices.append(discovered_sub_device) - _LOGGER.debug( - "Discovered sub-device: %s", - discovered_sub_device, - ) + _LOGGER.debug( + "[%s:%s] Got device info: %s", self.unique_id, self.transport, raw_info + ) + + self._state.process_new_state(raw_info) - _LOGGER.debug("Subdevices of '%s': %s", self._mac_addr_controller, subs) - self._is_available = True + _LOGGER.debug(self._state.info) - return discovered_devices + self._firmware_protocol_version = self._state.info.get( + InfoProp.PROTOCOL_VERSION, "" + ).lstrip("V") - async def fetch_device_status(self): + self._firmware_version, self._firmware_code = extract_fw_version( + self._state.info.get(InfoProp.HID, "") + ) + + async def fetch_device_status(self) -> None: """Get the device status (async).""" - _LOGGER.debug("Trying to get device '%s' status", self.mac_address) + _LOGGER.debug( + "[%s:%s] Trying to get status", + self.unique_id, + self.transport, + ) try: - status, _ = await self.query_props( - [prop.value for prop in self._props_to_update], - len(self._props_to_update), + status, _ = await self._client.query_props( + [prop.value for prop in self._state.polled_properties], + len(self._state.polled_properties), ) - for key, val in status.items(): - try: - prop = PROP_KEY_TO_ENUM[key] - self._raw_state[prop] = int(val) - except Exception: - _LOGGER.exception("Failed to parse %s=%r. Skipping", key, val) + _LOGGER.debug( + "[%s:%s] Got device status: %s", self.unique_id, self.transport, status + ) - self._is_available = True + self._state.process_new_state(status) except GreeConnectionError, GreeProtocolError: - self._is_available = False + _LOGGER.exception( + "[%s:%s] Failed fetching device device status", + self.unique_id, + self.transport, + ) raise except Exception as err: - self._is_available = False - raise GreeError("Error getting device status") from err - - self._remove_unsupported_props() + _LOGGER.exception( + "[%s:%s] Failed fetching device device status", + self.unique_id, + self.transport, + ) + raise GreeError(f"Error getting {self._mac_addr} status") from err - async def push_device_status(self): + async def push_device_status(self) -> None: """Send the new local device state to the device and updates local state if successfull.""" - if not self._is_bound: - await self.bind_device() - assert self._cipher is not None + _LOGGER.debug( + "[%s:%s] Trying to set status", + self.unique_id, + self.transport, + ) # If there is no change in the properties, do nothing - has_updated_states = any( - self._raw_state.get(k) != v for k, v in self._new_raw_state.items() - ) - if not has_updated_states: - _LOGGER.debug("No changes in the properties, skipping update to device") + if not self._state.has_pending_updates: + _LOGGER.info( + "[%s] No changes in properties, skipping update to device", + self.unique_id, + ) + self._state.clear_pending() return - self._new_raw_state[GreeProp.BEEPER] = 0 if self._beeper else 1 - self._new_raw_state[GreeProp.BEEPER_NEW] = 1 if self._beeper else 0 + # Theoretically, the device saves the beeper value when the property is sent with others, + # however remote commands overwrite the value to enable the beeper, so here we force our state + self._state.set(GreeProp.BEEPER, 0 if self._beeper else 1) + self._state.set(GreeProp.BEEPER_NEW, 1 if self._beeper else 0) try: - self._raw_state.update( - await gree_set_status( - self._mac_addr_controller, - self._mac_addr, - self._uid, - self._new_raw_state, - self._cipher, - self._transport, - ) + await self._client.set_props( + {k.value: v for k, v in self._state.pending.items()} ) - self._new_raw_state.clear() - self._is_available = True + + _LOGGER.debug("[%s:%s] Device status set", self.unique_id, self.transport) + self._state.clear_pending() + + await self.fetch_device_status() except GreeConnectionError, GreeProtocolError: - self._is_available = False + _LOGGER.exception( + "[%s:%s] Failed pushing device device status", + self.unique_id, + self.transport, + ) raise except Exception as err: - self._is_available = False - raise GreeError("Error setting device status") from err - - def _set_device_status(self, props: dict[GreeProp, int]) -> None: - """Sets a new local device status. Use 'update_device_status' to update the device.""" - - # Don't send props that are not part of the device capabilities - filtered_props = { - prop: value for prop, value in props.items() if self.supports_property(prop) - } - - self._new_raw_state.update(filtered_props) - - def _bool_from_raw_state(self, prop: GreeProp, default: int = 0) -> bool: - prop_value: int | None = self._get_prop_raw(prop, default) - - return bool(prop_value) + _LOGGER.exception( + "[%s:%s] Failed pushing device device status", + self.unique_id, + self.transport, + ) + raise GreeError(f"Error setting device {self._mac_addr} status") from err - def _remove_unsupported_props(self): + def _remove_unsupported_props(self) -> None: """Remove unsupported properties from the list to update.""" # Remove all unsupported properties - # A unsupported propery is one that the device returns - # with an empty string, or nothing at all - # If that is the case, _state_raw should not contain that property - # In case it still has it, we remove it here as well - for p in self._props_to_update: - if not self.supports_property(p): - self._props_to_update.remove(p) - self._raw_state.pop(p, None) - _LOGGER.debug("No longer updating property: %s", p) + self._state.invalidate_missing_properties() # Sensors should also be invalidated if their values are not expected (=0) - if ( - GreeProp.SENSOR_TEMPERATURE in self._props_to_update - and self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, 0) == 0 - ): - self._props_to_update.remove(GreeProp.SENSOR_TEMPERATURE) - self._raw_state.pop(GreeProp.SENSOR_TEMPERATURE, None) - _LOGGER.debug( - "No longer updating property due to bad value: %s", - GreeProp.SENSOR_TEMPERATURE, - ) + self._state.invalidate_missing_property_group( + [ + GreeProp.SENSOR_INDOOR_TEMPERATURE_1, + GreeProp.SENSOR_INDOOR_TEMPERATURE_2, + GreeProp.SENSOR_INDOOR_TEMPERATURE_3, + ] + ) - if ( - GreeProp.SENSOR_OUTSIDE_TEMPERATURE in self._props_to_update - and self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, 0) == 0 - ): - self._props_to_update.remove(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) - self._raw_state.pop(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None) - _LOGGER.debug( - "No longer updating property due to bad value: %s", - GreeProp.SENSOR_OUTSIDE_TEMPERATURE, - ) + self._state.invalidate_missing_property_group( + [ + GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, + GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, + ] + ) - if ( - GreeProp.SENSOR_HUMIDITY in self._props_to_update - and self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, 0) == 0 - ): - self._props_to_update.remove(GreeProp.SENSOR_HUMIDITY) - self._raw_state.pop(GreeProp.SENSOR_HUMIDITY, None) - _LOGGER.debug( - "No longer updating property due to bad value: %s", - GreeProp.SENSOR_HUMIDITY, - ) + self._state.invalidate_missing_property_group( + [ + GreeProp.SENSOR_HUMIDITY_1, + GreeProp.SENSOR_HUMIDITY_2, + ] + ) # As far as it is known, both values at 0 is not a valid combination. - # Might need to change this if problems are reported if ( - GreeProp.FEATURE_HUMIDITY_CONTROL in self._props_to_update - and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_CONTROL, 0) == 0 - and self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) == 0 + GreeProp.FEATURE_HUMIDITY_CONTROL in self._state.polled_properties + and self._state.get(GreeProp.FEATURE_HUMIDITY_CONTROL, 0) == 0 + and self._state.get(GreeProp.FEATURE_HUMIDITY_TARGET, 0) == 0 ): - self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY_CONTROL) - self._props_to_update.remove(GreeProp.FEATURE_HUMIDITY_TARGET) - self._raw_state.pop(GreeProp.FEATURE_HUMIDITY_CONTROL, None) - self._raw_state.pop(GreeProp.FEATURE_HUMIDITY_TARGET, None) - _LOGGER.debug( - "No longer updating property due to bad value: %s", - (GreeProp.FEATURE_HUMIDITY_CONTROL, GreeProp.FEATURE_HUMIDITY_TARGET), - ) - - def _get_prop_raw(self, prop: GreeProp, default: int | None = None) -> int | None: - """Get the raw value of a property. If does not exist, returns default.""" - if prop not in self._raw_state: - _LOGGER.warning( - "Property '%s' not found in state of device '%s'", prop, self.name - ) - return default - return self._raw_state.get(prop, default) - - def log_device_info(self): - """Log basic device information.""" - - capabilities = [] - if self.supports_property(GreeProp.SENSOR_TEMPERATURE): - capabilities.append("Temperature Sensor") - if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): - capabilities.append("Outside Temperature Sensor") - if self.supports_property(GreeProp.SENSOR_HUMIDITY): - capabilities.append("Humidity Sensor") - - _LOGGER.info( - "Capabilities: %s", ", ".join(capabilities) if capabilities else "None" - ) - - _LOGGER.info( - "Indoor Temperature: %s ºC", - self.indoors_temperature_c - if self.supports_property(GreeProp.SENSOR_TEMPERATURE) - else None, - ) - _LOGGER.info( - "Outddor Temperature: %s ºC", - self.outdoors_temperature_c - if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) - else None, - ) - _LOGGER.info( - "Target Temperature: %s º%s", - self.target_temperature, - self.target_temperature_unit.name, - ) - _LOGGER.info("Mode: %s", self.operation_mode.name) + self._state.remove(GreeProp.FEATURE_HUMIDITY_CONTROL) + self._state.remove(GreeProp.FEATURE_HUMIDITY_TARGET) def gather_diagnostics(self) -> dict[str, Any]: - """Returns diagnostic info for the device.""" + """Return diagnostic info for the device.""" data: dict[str, Any] = {} info = { - "ip": self._ip_addr, - "mac": self._mac_addr, - "mac_controller": self._mac_addr_controller, - "port": self._port, - "timeout": self._timeout, - "max_connections": self._max_connection_attempts, - "is_bound": self._is_bound, - "is_available": self._is_available, + "transport": str(self._client.transport), + "mac": self.mac_address, + "mac_controller": self.mac_address_controller, + "name": self.name, + "fw": self.firmware_version, + "is_bound": self._client.bound, + "is_available": self._client.available, "beeper": self.beeper, - "encryption": str(self.encryption_version), - "key": self.encryption_key[:5] + "[redacted]", + "encryption": str(self._client.encryption_version), + "key": redact_str(self._client.encryption_key), } data["info"] = info - data["raw_info"] = self._raw_info - data["state"] = {str(k): v for k, v in self._raw_state.items()} - data["state_unsaved"] = {str(k): v for k, v in self._new_raw_state.items()} + data["state_info"] = dict(self._state.info) + data["state"] = {str(k): v for k, v in self._state.raw.items()} + data["state_pending"] = {str(k): v for k, v in self._state.pending.items()} return data @@ -494,89 +392,89 @@ async def query_props( self, props: list[str], request_batch: int = 1, error_as_missing: bool = False ) -> tuple[dict[str, str], list[str]]: """Query the value of the given props.""" - - if not self._is_bound: - await self.bind_device() - - combined_state: dict[str, str] = {} - combined_missing: list[str] = [] - - assert self._cipher is not None - - for props_chunk in chunked(props, request_batch): - try: - state, missing = await gree_get_status( - self._mac_addr_controller, - self._mac_addr, - self._uid, - props_chunk, - self._cipher, - self._transport, - ) - combined_state.update(state) - if len(missing) != 0: - combined_missing.extend(missing) - - except Exception: - if error_as_missing: - combined_missing.extend(props_chunk) - else: - raise - - return combined_state, combined_missing + return await self._client.query_props(props, request_batch, error_as_missing) async def query_props_all( self, request_batch: int = 1, error_as_missing: bool = False ) -> tuple[dict[str, str], list[str]]: """Query all possible props.""" + return await self._client.query_all_props(request_batch, error_as_missing) - all_props = [ - *[prop.value for prop in GreeProp], - *[prop.value for prop in OtherProps], - ] + async def set_props(self, values: Mapping[str, int]) -> None: + """Allow setting generic property value set to the device. - return await self.query_props(all_props, request_batch, error_as_missing) + Caution: Don't set random property status. + """ + return await self._client.set_props(values) def supports_property(self, property: GreeProp) -> bool: - """Returns True if the device endpoint supports the property.""" + """Return True if the device endpoint supports the property.""" # We consider a property as unsupported if it is not present in the raw state list # This assumes that the full state is fetched at least once before this method is called + return self._state.supports(property) + + async def check_fw_updates(self) -> tuple[bool, FirmwareInfoResponse | None]: + """Check for device updates. Returns the latest firmware info if possible.""" + if not self._firmware_code: + _LOGGER.error( + "Unable to retrieve firmware because firmware code is unknown" + ) + return False, None - supported = property in self._raw_state and property in self._capabilities - return supported or property in ( - GreeProp.BEEPER, - GreeProp.BEEPER_NEW, + latest_fw = await gree_get_latest_firmware_info( + GreeRegion.EU, self._firmware_code ) + if not latest_fw: + _LOGGER.error("Unable to retrieve firmware because of a bad server request") + return False, None + + if not self._firmware_version: + _LOGGER.error( + "Unable to assess because current firmware version is unknown" + ) + return False, latest_fw + + if not latest_fw.version: + _LOGGER.error("Unable to assess because latest firmware version is unknown") + return False, latest_fw + + return Version(self._firmware_version) < Version(latest_fw.version), latest_fw + @property - def ip(self) -> str: - """The IP address assigned to the device.""" - return self._ip_addr + def transport(self) -> GreeBaseTransport | None: + """The Transport assigned to the device.""" + return self._client.transport - def set_ip(self, ip_addr: str): - """Updates the IP the device uses for communication.""" - self._ip_addr = ip_addr - self._transport.ip_addr = ip_addr + async def set_transport(self, transport: GreeBaseTransport) -> None: + """Update the transport used by the device for communication.""" + await self._client.set_transport(transport) + await self._client.rebind() + + @property + def api_client(self) -> DeviceApiClient: + """Clinet to interface with the device API.""" + return self._client @property def name(self) -> str: - """Returns the friendly name of the device.""" + """Friendly name of the device.""" return self._name @property - def encryption_key(self) -> str: - """Return the encryption key of the device.""" - return self._encryption_key + def encryption_key(self) -> str | None: + """Encryption key of the device.""" + return self._client.encryption_key @property def encryption_version(self) -> EncryptionVersion | None: """Return the encryption version of the device.""" - return self._encryption_version + return self._client.encryption_version @property def unique_id(self) -> str: """Return the unique ID of the device (MAC).""" - return self._uniqueid + return self._mac_addr @property def mac_address(self) -> str: @@ -586,33 +484,57 @@ def mac_address(self) -> str: @property def mac_address_controller(self) -> str: """Return the secondary MAC address of the device. For non VRF is the same as MAC otherwise is the MAC of the main controller (same as MAC for the main device).""" - return self._mac_addr_controller + return self._client.controller_mac @property def firmware_version(self) -> str | None: """Returns the firmware version.""" - if self._firmware_version and self._firmware_code: - return f"{self._firmware_version} ({self._firmware_code})" + fw_str = "" + if self._firmware_version: - return self._firmware_version - if self._firmware_code: - return self._firmware_code - return None + fw_str += f"{self._firmware_version} " + + if self._firmware_protocol_version.strip(): + fw_str += f"(Protocol: {self._firmware_protocol_version}) " + + return fw_str.strip() or None + + @property + def firmware_code(self) -> str | None: + "Code for the firmware WIFI module." + code: str = self._firmware_code or "" + if isinstance(self.transport, GreeUdpTransport): + code += " (UDP)" + else: + code += " (MQTT)" + return code.strip() + + @property + def device_model_id(self) -> str: + """The model of the unit.""" + mt = self._state.info.get(InfoProp.MODEL_TYPE, "") + v = self._state.info.get(InfoProp.VENDER, "") + model = "" + if mt.strip(): + model += mt + if v.strip() and model.strip(): + model += f" ({v})" + return model @property def available(self) -> bool: """Return True if the device is bound and last connection was successful.""" - return self._is_bound and self._is_available + return self._client.bound and self._client.available @property def is_bound(self) -> bool: """Return True if the device is bound.""" - return self._is_bound + return self._client.bound @property def has_hvac_error(self) -> bool: """Return if there is an error with the device.""" - return self._bool_from_raw_state(GreeProp.SENSOR_FAULT) + return self._state.get_bool(GreeProp.SENSOR_FAULT) @property def beeper(self) -> bool: @@ -626,134 +548,164 @@ def set_beeper(self, value: bool) -> None: @property def indoors_temperature_c(self) -> int | None: """Return the current temperature if available.""" - if self.supports_property(GreeProp.SENSOR_TEMPERATURE): - if self._temp_processor_indoors is None: - self._temp_processor_indoors = TempOffsetResolver() - - raw_c = self._get_prop_raw(GreeProp.SENSOR_TEMPERATURE, None) - return ( - int(self._temp_processor_indoors.evaluate(raw_c)) - if raw_c is not None - else None - ) + if self._temp_processor_indoors is None: + self._temp_processor_indoors = TempOffsetResolver() + + for prop in ( + GreeProp.SENSOR_INDOOR_TEMPERATURE_1, + GreeProp.SENSOR_INDOOR_TEMPERATURE_2, + GreeProp.SENSOR_INDOOR_TEMPERATURE_3, + ): + if self._state.supports(prop): + raw_c = self._state.get(prop, None) + return ( + int(self._temp_processor_indoors.evaluate(raw_c)) + if raw_c is not None + else None + ) return None @property def outdoors_temperature_c(self) -> int | None: """Return the current outside temperature if available.""" - if self.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE): - if self._temp_processor_outdoors is None: - self._temp_processor_outdoors = TempOffsetResolver() - - raw_c = self._get_prop_raw(GreeProp.SENSOR_OUTSIDE_TEMPERATURE, None) - return ( - int(self._temp_processor_outdoors.evaluate(raw_c)) - if raw_c is not None - else None - ) + + if self._temp_processor_outdoors is None: + self._temp_processor_outdoors = TempOffsetResolver() + + for prop in ( + GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, + GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, + ): + if self._state.supports(prop): + raw_c = self._state.get(prop, None) + return ( + int(self._temp_processor_outdoors.evaluate(raw_c)) + if raw_c is not None + else None + ) return None @property def humidity(self) -> int | None: """Return the current humidity if available.""" - return self._get_prop_raw(GreeProp.SENSOR_HUMIDITY, None) + + for prop in ( + GreeProp.SENSOR_HUMIDITY_1, + GreeProp.SENSOR_HUMIDITY_2, + ): + if self._state.supports(prop): + return self._state.get(prop, None) + + return None @property def power_mode(self) -> bool: """Return the current power mode.""" - return self._bool_from_raw_state(GreeProp.POWER) + return self._state.get_bool(GreeProp.POWER) - def set_power_mode(self, value: bool): - """Sets the device power mode.""" - self._set_device_status({GreeProp.POWER: 1 if value else 0}) + def set_power_mode(self, value: bool) -> None: + """Set the device power mode.""" + self._state.set_bool(GreeProp.POWER, value) @property def operation_mode(self) -> OperationMode: """Return the current operation mode.""" return OperationMode( - self._get_prop_raw(GreeProp.OP_MODE, OperationMode.auto.value) + self._state.get(GreeProp.OP_MODE) or OperationMode.auto.value ) - def set_operation_mode(self, mode: OperationMode): - """Sets the device operation mode.""" + def set_operation_mode(self, mode: OperationMode) -> None: + """Set the device operation mode.""" # Force disable Humidity Control - self.set_feature_humidity_control(HumidityControlMode.disabled) + if self.feature_humidity_control != HumidityControlMode.disabled: + _LOGGER.info( + "[%s] Humidity control disabled due to operation mode change to %s", + self.unique_id, + mode, + ) + self.set_feature_humidity_control(HumidityControlMode.disabled) + + # Disable Energy Saver when changing to modes that are not Cool + if mode != OperationMode.cool and self.feature_energy_saving: + self.set_feature_energy_saving(False) - self._set_device_status({GreeProp.OP_MODE: mode}) + # Disable Smart Heat when changing to modes that are not Hear + if mode != OperationMode.heat and self.feature_smart_heat: + self.set_feature_smart_heat(False) + + self._state.set(GreeProp.OP_MODE, mode) @property def fan_speed(self) -> FanSpeed: """Return the current fan speed.""" - return FanSpeed(self._get_prop_raw(GreeProp.FAN_SPEED, FanSpeed.auto.value)) + return FanSpeed(self._state.get(GreeProp.FAN_SPEED) or FanSpeed.auto.value) - def set_fan_speed(self, speed: FanSpeed): - """Sets the device fan speed mode. + def set_fan_speed(self, speed: FanSpeed) -> None: + """Set the device fan speed mode. Setting a fan speed other than 'Auto' will deactivate Energy Saving and Smart Heat features. """ if speed is not FanSpeed.auto and self.feature_energy_saving: self.set_feature_energy_saving(False) - _LOGGER.warning( - "%s: Energy saving mode disabled because of fan mode setting", - self.mac_address, + _LOGGER.info( + "[%s] Energy saving mode disabled because of fan mode setting", + self.unique_id, ) if speed is not FanSpeed.auto and self.feature_smart_heat: self.set_feature_smart_heat(False) - _LOGGER.warning( - "%s: Smart Heat mode disabled because of fan mode setting", - self.mac_address, + _LOGGER.info( + "[%s] Smart Heat mode disabled because of fan mode setting", + self.unique_id, ) - self._set_device_status({GreeProp.FAN_SPEED: speed}) + self._state.set(GreeProp.FAN_SPEED, speed) @property def vertical_swing_mode(self) -> VerticalSwingMode: """Return the current vertical swing setting.""" return VerticalSwingMode( - self._get_prop_raw(GreeProp.SWING_VERTICAL, VerticalSwingMode.default.value) + self._state.get(GreeProp.SWING_VERTICAL) or VerticalSwingMode.default.value ) - def set_vertical_swing_mode(self, swing_mode: VerticalSwingMode): - """Sets the device vertical swing mode.""" - self._set_device_status({GreeProp.SWING_VERTICAL: swing_mode}) + def set_vertical_swing_mode(self, swing_mode: VerticalSwingMode) -> None: + """Set the device vertical swing mode.""" + self._state.set(GreeProp.SWING_VERTICAL, swing_mode) @property def horizontal_swing_mode(self) -> HorizontalSwingMode: """Return the current horizontal swing setting.""" return HorizontalSwingMode( - self._get_prop_raw( - GreeProp.SWING_HORIZONTAL, HorizontalSwingMode.default.value - ) + self._state.get(GreeProp.SWING_HORIZONTAL) + or HorizontalSwingMode.default.value ) - def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode): - """Sets the device horizontal swing mode.""" - self._set_device_status({GreeProp.SWING_HORIZONTAL: swing_mode}) + def set_horizontal_swing_mode(self, swing_mode: HorizontalSwingMode) -> None: + """Set the device horizontal swing mode.""" + self._state.set(GreeProp.SWING_HORIZONTAL, swing_mode) @property def target_temperature_unit(self) -> TemperatureUnits: """Return the units of the target temperature.""" return TemperatureUnits( - self._get_prop_raw( - GreeProp.TARGET_TEMPERATURE_UNIT, TemperatureUnits.C.value - ) + self._state.get(GreeProp.TARGET_TEMPERATURE_UNIT) + or TemperatureUnits.C.value ) - def set_target_temperature_unit(self, units: TemperatureUnits): - """Sets the units of the target temperature.""" - self._set_device_status({GreeProp.TARGET_TEMPERATURE_UNIT: units}) + def set_target_temperature_unit(self, units: TemperatureUnits) -> None: + """Set the units of the target temperature.""" + self._state.set(GreeProp.TARGET_TEMPERATURE_UNIT, units) @property def target_temperature(self) -> float: """Return the target temperature in target_temperature_unit.""" - raw_c = self._get_prop_raw(GreeProp.TARGET_TEMPERATURE, 0) - tem_rec = self._get_prop_raw(GreeProp.TARGET_TEMPERATURE_BIT, 0) + raw_c = self._state.get(GreeProp.TARGET_TEMPERATURE, 0) + tem_rec = self._state.get(GreeProp.TARGET_TEMPERATURE_BIT, 0) if raw_c is not None and tem_rec is not None: if self.target_temperature_unit == TemperatureUnits.F: @@ -763,24 +715,7 @@ def target_temperature(self) -> float: return 0.0 def set_target_temperature(self, value: float) -> None: - """Sets the target temperature in target_temperature_unit. - - Changing the target temperature will deactivate Energy Saving and Smart Heat features. - """ - - if self.feature_energy_saving: - self.set_feature_energy_saving(False) - _LOGGER.warning( - "%s: Energy saving mode disabled because of target temperature change", - self.mac_address, - ) - - if self.feature_smart_heat: - self.set_feature_smart_heat(False) - _LOGGER.warning( - "%s: Smart Heat mode disabled because of target temperature change", - self.mac_address, - ) + """Set the target temperature in target_temperature_unit.""" if self.target_temperature_unit == TemperatureUnits.F: if not value.is_integer(): @@ -793,73 +728,81 @@ def set_target_temperature(self, value: float) -> None: else: raw_c, tem_rec = gree_get_target_temp_props_from_c(value) - self._set_device_status( + self._state.update( { GreeProp.TARGET_TEMPERATURE: raw_c, GreeProp.TARGET_TEMPERATURE_BIT: tem_rec, } ) + if self.feature_smart_heat or self.feature_energy_saving: + _LOGGER.info( + "[%s] Temperature set, but being ignored while smart heat or energy saving modes are enabled", + self.unique_id, + ) + @property def feature_light_sensor(self) -> bool: """Return the light sensor state.""" - return self._bool_from_raw_state(GreeProp.FEAT_SENSOR_LIGHT) + return self._state.get_bool(GreeProp.FEAT_SENSOR_LIGHT) def set_feature_light_sensor(self, value: bool) -> None: """Set the light sensor state.""" - self._set_device_status({GreeProp.FEAT_SENSOR_LIGHT: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_SENSOR_LIGHT, value) @property def feature_fresh_air(self) -> bool: """Return the fresh air mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_FRESH_AIR) + return self._state.get_bool(GreeProp.FEAT_FRESH_AIR) def set_feature_fresh_air(self, value: bool) -> None: """Set the fresh air mode state.""" - self._set_device_status({GreeProp.FEAT_FRESH_AIR: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_FRESH_AIR, value) @property def feature_x_fan(self) -> bool: """Return the x-fan mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_XFAN) + return self._state.get_bool(GreeProp.FEAT_XFAN) def set_feature_xfan(self, value: bool) -> None: """Set the x-fan mode state.""" - self._set_device_status({GreeProp.FEAT_XFAN: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_XFAN, value) @property def feature_health(self) -> bool: """Return the health mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_HEALTH) + return self._state.get_bool(GreeProp.FEAT_HEALTH) def set_feature_health(self, value: bool) -> None: """Set the health mode state.""" - self._set_device_status({GreeProp.FEAT_HEALTH: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_HEALTH, value) @property def feature_sleep(self) -> SleepMode: """Return the sleep mode state.""" - sleep_enabled = self._bool_from_raw_state(GreeProp.FEAT_SLEEP_MODE) + sleep_enabled = self._state.get_bool(GreeProp.FEAT_SLEEP_MODE) mode = SleepMode( - self._get_prop_raw(GreeProp.FEAT_SLEEP_MODE_TYPE, SleepMode.disabled.value) + self._state.get(GreeProp.FEAT_SLEEP_MODE_TYPE) or SleepMode.disabled.value ) if sleep_enabled and mode is SleepMode.disabled: _LOGGER.warning( - "Inconsistent Sleep mode properties. Mode enabled and type disabled" + "[%s] Inconsistent Sleep mode properties. Mode enabled and type disabled", + self.unique_id, ) return SleepMode.normal if not sleep_enabled and mode is not SleepMode.disabled: _LOGGER.warning( - "Inconsistent Sleep mode properties. Mode disabled and type enabled" + "[%s] Inconsistent Sleep mode properties. Mode disabled and type enabled", + self.unique_id, ) return SleepMode.disabled return mode - def set_feature_sleep(self, mode: SleepMode): + def set_feature_sleep(self, mode: SleepMode) -> None: """Set the sleep mode state. This feature is only available under `Cool` or `Heat` modes. @@ -877,7 +820,7 @@ def set_feature_sleep(self, mode: SleepMode): self.set_feature_energy_saving(False) self.set_feature_smart_heat(False) - self._set_device_status( + self._state.update( { GreeProp.FEAT_SLEEP_MODE: (1 if mode is not SleepMode.disabled else 0), GreeProp.FEAT_SLEEP_MODE_TYPE: mode.value, @@ -887,41 +830,41 @@ def set_feature_sleep(self, mode: SleepMode): @property def feature_light(self) -> bool: """Return the light state.""" - return self._bool_from_raw_state(GreeProp.FEAT_LIGHT) + return self._state.get_bool(GreeProp.FEAT_LIGHT) def set_feature_light(self, value: bool) -> None: """Set the light state.""" - self._set_device_status({GreeProp.FEAT_LIGHT: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_LIGHT, value) @property def feature_quiet(self) -> bool: """Return the quiet mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_QUIET_MODE) + return self._state.get_bool(GreeProp.FEAT_QUIET_MODE) def set_feature_quiet(self, value: bool) -> None: """Set the quiet mode state. - This mode is ignored if Energy Saving or Smart Heat features are active. + This mode is ignored until Energy Saving or Smart Heat features are disabled. """ - # Mirror physical behaviour + self._state.set_bool(GreeProp.FEAT_QUIET_MODE, value) + if value and (self.feature_energy_saving or self.feature_smart_heat): - raise GreeQuietIgnored( - "Quiet ignored because Energy Saving or Smart Heat are active" + _LOGGER.info( + "[%s] Quiet mode set, but being ignored while smart heat or energy saving modes are enabled", + self.unique_id, ) - self._set_device_status({GreeProp.FEAT_QUIET_MODE: 1 if value else 0}) - @property def feature_turbo(self) -> bool: """Return the turbo mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_TURBO_MODE) + return self._state.get_bool(GreeProp.FEAT_TURBO_MODE) def set_feature_turbo(self, value: bool) -> None: """Set the turbo mode state. This mode is only availabe under `Cool` or `Heat` modes. - This mode is ignored if Energy Saving or Smart Heat features are active. + This mode is ignored until Energy Saving or Smart Heat features are disabled. """ if value and self.operation_mode not in ( @@ -932,26 +875,25 @@ def set_feature_turbo(self, value: bool) -> None: "Turbo mode is only available under Cool or Heat modes" ) - # Mirror physical behaviour + self._state.set_bool(GreeProp.FEAT_TURBO_MODE, value) + if value and (self.feature_energy_saving or self.feature_smart_heat): - raise GreeTurboIgnored( - "Turbo ignored because Energy Saving or Smart Heat are active" + _LOGGER.info( + "[%s] Turbo mode set, but being ignored while smart heat or energy saving modes are enabled", + self.unique_id, ) - self._set_device_status({GreeProp.FEAT_TURBO_MODE: 1 if value else 0}) - @property def feature_smart_heat(self) -> bool: """Return the smart heat (8ºC / anti-freeze) mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_SMART_HEAT_8C) + return self._state.get_bool(GreeProp.FEAT_SMART_HEAT_8C) def set_feature_smart_heat(self, value: bool) -> None: """Set the smart heat (8ºC / anti-freeze) mode state. This mode is only availabe under `Heat` mode. This feature is incompatible with `Sleep` and `Energy Saving`, and will force disable them if activated. - This feature changes fan to `Auto` speed. - The device will ignore the temperature setting. + The device will ignore the temperature and fan settings. """ if value and self.operation_mode is not OperationMode.heat: @@ -963,25 +905,20 @@ def set_feature_smart_heat(self, value: bool) -> None: if value: self.set_feature_sleep(SleepMode.disabled) self.set_feature_energy_saving(False) - self.set_feature_turbo(False) - self.set_feature_quiet(False) - self.set_fan_speed(FanSpeed.auto) - # TODO: Keep the previous fan speed to apply when the feature is deactivated again - self._set_device_status({GreeProp.FEAT_SMART_HEAT_8C: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_SMART_HEAT_8C, value) @property def feature_energy_saving(self) -> bool: """Return the energy saving mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_ENERGY_SAVING) + return self._state.get_bool(GreeProp.FEAT_ENERGY_SAVING) def set_feature_energy_saving(self, value: bool) -> None: """Set the energy saving mode state. This feature is only available under `Cool` mode. This feature is incompatible with `Sleep` and `Smart Heat`, and will force disable them if activated. - This feature changes fan to `Auto` speed. - The device will ignore the temperature setting. + The device will ignore the temperature and fan settings. """ if value and self.operation_mode is not OperationMode.cool: @@ -993,34 +930,29 @@ def set_feature_energy_saving(self, value: bool) -> None: if value: self.set_feature_sleep(SleepMode.disabled) self.set_feature_smart_heat(False) - self.set_feature_turbo(False) - self.set_feature_quiet(False) - self.set_fan_speed(FanSpeed.auto) - # TODO: Keep the previous fan speed to apply when the feature is deactivated again - self._set_device_status({GreeProp.FEAT_ENERGY_SAVING: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_ENERGY_SAVING, value) @property def feature_anti_direct_blow(self) -> bool: """Return the anti direct blow mode state.""" - return self._bool_from_raw_state(GreeProp.FEAT_ANTI_DIRECT_BLOW) + return self._state.get_bool(GreeProp.FEAT_ANTI_DIRECT_BLOW) def set_feature_anti_direct_blow(self, value: bool) -> None: """Set the anti direct blow mode state.""" - self._set_device_status({GreeProp.FEAT_ANTI_DIRECT_BLOW: 1 if value else 0}) + self._state.set_bool(GreeProp.FEAT_ANTI_DIRECT_BLOW, value) @property def feature_humidity_control(self) -> HumidityControlMode: """Returns the current humidity control mode.""" return HumidityControlMode( - self._get_prop_raw( - GreeProp.FEATURE_HUMIDITY_CONTROL, HumidityControlMode.disabled.value - ) + self._state.get(GreeProp.FEATURE_HUMIDITY_CONTROL) + or HumidityControlMode.disabled.value ) def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: - """Sets the Humidy Control mode. + """Set the Humidy Control mode. `HumidityControlMode.smart_dry` is only available under `Cool` mode. `HumidityControlMode.continuous_dry` is only available under `Dry` mode. @@ -1070,7 +1002,7 @@ def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: case HumidityControlMode.continuous_dry: target = 3 # It's possible the device ignores this value in this mode - self._set_device_status( + self._state.update( { GreeProp.FEATURE_HUMIDITY_CONTROL: mode.value, GreeProp.FEATURE_HUMIDITY_TARGET: target, @@ -1081,13 +1013,13 @@ def set_feature_humidity_control(self, mode: HumidityControlMode) -> None: def feature_humidity_control_target(self) -> int: """Return the current set target humidity value.""" - raw_value: int = self._get_prop_raw(GreeProp.FEATURE_HUMIDITY_TARGET, 0) - return gree_get_target_humidity_p(raw_value) + raw_value: int | None = self._state.get(GreeProp.FEATURE_HUMIDITY_TARGET, 0) + return gree_get_target_humidity_p(raw_value or 0) def set_feature_humidity_control_target( self, humidity_target_percentage: int ) -> None: - """Sets the target humidity percentage (in multiples of 5). + """Set the target humidity percentage (in multiples of 5). Cool mode range: 40-80. Dry mode range: 30-70. @@ -1107,8 +1039,4 @@ def set_feature_humidity_control_target( humidity_target_percentage, MIN_HUM_DRY_P, MAX_HUM_DRY_P ) - self._set_device_status( - { - GreeProp.FEATURE_HUMIDITY_TARGET: target, - } - ) + self._state.set(GreeProp.FEATURE_HUMIDITY_TARGET, target) diff --git a/custom_components/gree_custom/aiogree/device_api_client.py b/custom_components/gree_custom/aiogree/device_api_client.py new file mode 100755 index 0000000..f8ad567 --- /dev/null +++ b/custom_components/gree_custom/aiogree/device_api_client.py @@ -0,0 +1,320 @@ +"""Client used to interface with the actual device through a Transport.""" + +from collections.abc import Callable, Mapping +import logging + +from .api import ( + BindingInfo, + GreeProp, + InfoProp, + OtherProps, + gree_get_status, + gree_process_status_pack, + gree_set_status, + gree_try_bind, +) +from .cipher import CipherBase, EncryptionVersion, get_cipher +from .errors import GreeBindingError, GreeError, GreeRuntimeError +from .helpers import chunked, gree_decrypt_pack, redact_str +from .transport import GreeBaseTransport + +_LOGGER = logging.getLogger(__name__) + + +class DeviceApiClient: + """Manager for the communication with the device API.""" + + def __init__( + self, + mac: str, + userid: int, + ) -> None: + """Initialize the client.""" + self.controller_mac = "" + self._mac = mac + self._userid = userid + + self._transport: GreeBaseTransport | None = None + + self._cipher: CipherBase | None = None + self._binding: BindingInfo | None = None + + self._bound = False + self._available = False + + self._listeners: list[Callable[[dict[str, str]], None]] = [] + + # + # Binding + # + + async def bind( + self, + controller_mac: str, + preferred_version: EncryptionVersion | None = None, + preferred_key: str | None = None, + ) -> None: + """Bind to the current transport using the suggested version and key.""" + if self._bound: + return + + if self._transport is None: + raise GreeBindingError("No transport configured") + + if not controller_mac or not bool(controller_mac.strip()): + raise GreeBindingError("No controller MAC provided") + + self.controller_mac = controller_mac + + _LOGGER.info( + "[%s:%s] Starting binding procedure", self.controller_mac, self._transport + ) + + await self._transport.subscribe(self.controller_mac) + + try: + result = await gree_try_bind( + self.controller_mac, + self._userid, + preferred_version, + preferred_key, + self._transport, + ) + + except Exception: + _LOGGER.exception("Error while binding") + await self._transport.unsubscribe(self.controller_mac) + raise + + _LOGGER.info( + "[%s] Device is bound with version %s and key %s via %s", + self.controller_mac, + result.encryption_version, + redact_str(result.encryption_key), + self._transport, + ) + + self._binding = result + + self._cipher = get_cipher(result.encryption_version, result.encryption_key) + + self._transport.add_listener( + self._mac, + self._handle_transport_message, + ) + + self._bound = True + self._available = True + + async def unbind(self) -> None: + """Unbind from the current transport.""" + if not self._bound: + return + + if not self._transport: + raise GreeBindingError("Cannot unbind when no transport is set.") + + self._transport.remove_listener( + self._mac, + self._handle_transport_message, + ) + + await self._transport.unsubscribe( + self.controller_mac, + ) + + self._bound = False + self._available = False + self._cipher = None + + async def rebind(self) -> None: + """Try binding with the current transport and existing binding info.""" + await self.unbind() + return await self.bind( + self.controller_mac, self.encryption_version, self.encryption_key + ) + + # + # Transport + # + + @property + def transport(self) -> GreeBaseTransport | None: + """The current client transport.""" + return self._transport + + async def set_transport( + self, + transport: GreeBaseTransport, + ) -> None: + """Set the client transport.""" + await self.unbind() + self._transport = transport + + # + # Query + # + + async def query_props( + self, + props: list[str], + request_batch: int = 1, + error_as_missing: bool = False, + ) -> tuple[dict[str, str], list[str]]: + """Query the status value of device properties.""" + if not self._bound: + await self.rebind() + + if not self._cipher: + raise GreeRuntimeError("No cipher set.") + + if not self._transport: + raise GreeRuntimeError("No transport set.") + + state: dict[str, str] = {} + missing: list[str] = [] + + for chunk in chunked(props, request_batch): + try: + result = await gree_get_status( + self.controller_mac, + self._mac, + self._userid, + chunk, + self._cipher, + self._transport, + ) + + state.update(result.prop_values) + missing.extend(result.missing_props) + + except GreeError: + if error_as_missing: + missing.extend(chunk) + else: + raise + + self._available = True + + return state, missing + + async def query_all_props( + self, + request_batch: int = 1, + error_as_missing: bool = False, + ) -> tuple[dict[str, str], list[str]]: + """Query all possible props.""" + + all_props = [ + *[prop.value for prop in GreeProp], + *[prop.value for prop in InfoProp], + *[prop.value for prop in OtherProps], + ] + + return await self.query_props(all_props, request_batch, error_as_missing) + + async def set_props( + self, + values: Mapping[str, int], + ) -> None: + """Send the state of multiple properties to the device.""" + if not self._bound: + await self.rebind() + + if not self._cipher: + raise GreeRuntimeError("No cipher set.") + + if not self._transport: + raise GreeRuntimeError("No transport set.") + + await gree_set_status( + self.controller_mac, + self._mac, + self._userid, + values, + self._cipher, + self._transport, + ) + + self._available = True + + # + # Transport Push Messages + # + + def add_status_listener( + self, + callback: Callable[[dict[str, str]], None], + ) -> None: + """Add a listener for status updates.""" + _LOGGER.debug("Adding Listener: %s", callback) + self._listeners.append(callback) + + def remove_status_listener( + self, + callback: Callable[[dict[str, str]], None], + ) -> None: + """Remove a listener from status updates.""" + _LOGGER.debug("Removing Listener: %s", callback) + try: + self._listeners.remove(callback) + except ValueError: + _LOGGER.warning("Callback to remove not in the listeners list") + + def _handle_transport_message( + self, + topic: str, + payload: dict, + ) -> None: + + if self._cipher is None: + return + + if "status" not in topic: + return + + response = gree_decrypt_pack( + payload, + self._cipher, + ) + + if pack := response.get("pack"): + result = gree_process_status_pack( + pack, + None, + ) + + for listener in self._listeners: + try: + listener(result.prop_values) + except Exception: + _LOGGER.exception("Error during listener execution") + + # + # Properties + # + + @property + def available(self) -> bool: + """Is the device available.""" + return self._available + + @property + def bound(self) -> bool: + """Is the device bound to the transport.""" + return self._bound + + @property + def binding_info(self) -> BindingInfo | None: + """Binding information for the last successful binding with a transport.""" + return self._binding + + @property + def encryption_key(self) -> str | None: + """The current device encryption key obtained after binding.""" + return None if self._binding is None else self._binding.encryption_key + + @property + def encryption_version(self) -> EncryptionVersion | None: + """The current device encryption version obtained after binding.""" + return None if self._binding is None else self._binding.encryption_version diff --git a/custom_components/gree_custom/aiogree/device_state.py b/custom_components/gree_custom/aiogree/device_state.py new file mode 100755 index 0000000..879c7e2 --- /dev/null +++ b/custom_components/gree_custom/aiogree/device_state.py @@ -0,0 +1,202 @@ +"""Contains the ``DeviceState`` class that holds and manages the device state.""" + +from collections.abc import Iterable +import logging +from types import MappingProxyType + +from .api import INFOPROP_KEY_TO_ENUM, PROP_KEY_TO_ENUM, GreeProp, InfoProp + +_LOGGER = logging.getLogger(__name__) + + +class DeviceState: + """Represents the local state of a Gree device.""" + + def __init__(self, device_id: str, capabilities: Iterable[GreeProp]) -> None: + """Initialize the device state.""" + self._device_id: str = device_id + + self._raw: dict[GreeProp, int] = {} + self._pending: dict[GreeProp, int] = {} + self._info: dict[InfoProp, str] = {} + + self._capabilities = set(capabilities) + + # Poll everything but beeper + self._props_to_poll: tuple[GreeProp, ...] = tuple( + p for p in GreeProp if p not in (GreeProp.BEEPER, GreeProp.BEEPER_NEW) + ) + + # + # State access + # + + def get(self, prop: GreeProp, default: int | None = None) -> int | None: + """Get the raw value of a property. + + Returns the pending value from ``pending`` if present, otherwise the + last known value from ``raw``. If the property does not exist in + either state, returns ``default``. + """ + + # Query first the transient state, so we can make changes to the device state + # before having to push it to the device, preventing the need for a push for each change + if prop in self._pending: + return self._pending[prop] + + if prop in self._raw: + return self._raw[prop] + + _LOGGER.info( + "[%s] Property '%s' not found in state of device. Returning default value", + self._device_id, + prop, + ) + return default + + def get_bool(self, prop: GreeProp, default: int = 0) -> bool: + """Get the bool value of a property.""" + prop_value: int | None = self.get(prop, default) + + return bool(prop_value) + + def set(self, prop: GreeProp, value: int) -> None: + """Set the pending state value of a property.""" + if self.supports(prop): + _LOGGER.debug("[%s] Setting property %s: %d", self._device_id, prop, value) + self._pending[prop] = value + else: + _LOGGER.error( + "[%s] Property %s is unsupported on this device", self._device_id, prop + ) + + def set_bool(self, prop: GreeProp, value: bool) -> None: + """Set the pending state value of a property with a bool.""" + self.set(prop, 1 if value else 0) + + def update(self, values: dict[GreeProp, int]) -> None: + """Update the pending state with multiple property values.""" + for prop, value in values.items(): + self.set(prop, value) + + def clear_pending(self) -> None: + """Clear the pending state.""" + self._pending.clear() + + # + # Raw protocol processing + # + + def process_new_state(self, new_state: dict[str, str]) -> None: + """Process a new state for the properties and update the state object.""" + unknown = [] + errors = [] + + for key, value in new_state.items(): + try: + if key in PROP_KEY_TO_ENUM: + prop = PROP_KEY_TO_ENUM[key] + + if prop in self._props_to_poll: + self._raw[prop] = int(value) + + elif key in INFOPROP_KEY_TO_ENUM: + self._info[INFOPROP_KEY_TO_ENUM[key]] = value + + else: + unknown.append(key) + + except ValueError, TypeError: + errors.append(key) + + if unknown: + _LOGGER.debug("[%s] Unknown properties: %s", self._device_id, unknown) + + if errors: + _LOGGER.debug("[%s] Invalid values: %s", self._device_id, errors) + + # + # Property helpers + # + + def supports(self, prop: GreeProp) -> bool: + """Validate that a property exists in the state. + + We consider a property as unsupported if it is not present in the raw state list + This assumes that the full state is updated at least once before this method is called + + Beeper is always returned as supported. + """ + return (prop in self._raw and prop in self._capabilities) or prop in ( + GreeProp.BEEPER, + GreeProp.BEEPER_NEW, + ) + + def remove(self, prop: GreeProp) -> None: + """Remove a property from being polled.""" + self._props_to_poll = tuple(p for p in self._props_to_poll if p != prop) + self._raw.pop(prop, None) + self._pending.pop(prop, None) + _LOGGER.debug( + "[%s] No longer updating property: %s", self._device_id, repr(prop) + ) + + def invalidate_missing_properties(self) -> None: + """Remove properties from polling if their state values are not valid.""" + + # Remove all unsupported properties + # A unsupported property is one that the device returns + # with an empty string, or nothing at all + # If that is the case, _state_raw should not contain that property + # In case it still has it, we remove it here as well + for p in self._props_to_poll: + if not self.supports(p): + self.remove(p) + + def invalidate_missing_property_group( + self, props: list[GreeProp], missing_value: int = 0 + ) -> None: + """Remove a group of properties from polling based on a ordered list of preference.""" + + # Keep the first (lowest priority number) non-zero value + preferred = next( + (p for p in props if self.get(p, missing_value) != missing_value), + None, + ) + + for prop in props: + if prop is preferred: + continue + + if prop not in self._props_to_poll: + continue + + self.remove(prop) + + @property + def has_pending_updates(self) -> bool: + """Does the state have pending values to be committed.""" + return any(self._raw.get(k) != v for k, v in self._pending.items()) + + # + # Read-only views + # + @property + def polled_properties(self) -> tuple[GreeProp, ...]: + """The currently polled properties.""" + return self._props_to_poll + + @property + def raw(self) -> MappingProxyType[GreeProp, int]: + """The current device state values.""" + return MappingProxyType(self._raw) + + @property + def pending(self) -> MappingProxyType[GreeProp, int]: + """The pending uncommitted device state values.""" + return MappingProxyType(self._pending) + + @property + def info(self) -> MappingProxyType[InfoProp, str]: + """The Device Info property values.""" + return MappingProxyType(self._info) diff --git a/custom_components/gree_custom/aiogree/errors.py b/custom_components/gree_custom/aiogree/errors.py old mode 100644 new mode 100755 index f288fd8..4c925dc --- a/custom_components/gree_custom/aiogree/errors.py +++ b/custom_components/gree_custom/aiogree/errors.py @@ -5,6 +5,14 @@ class GreeError(Exception): """Base error for the Gree integration.""" +class GreeCloudError(Exception): + """Error while communicating with the Gree Cloud.""" + + +class GreeCloudLoginError(GreeCloudError): + """Error while logging in to the Gree Cloud.""" + + class GreeConnectionError(GreeError): """Network communication with device failed.""" @@ -17,6 +25,10 @@ class GreeBindingError(GreeError): """Failed to obtain encryption key.""" +class GreeRuntimeError(GreeError): + """Problem with the runtime.""" + + class GreeUnsupportedState(GreeError): """The requested state/feature is not valid or available.""" @@ -37,14 +49,6 @@ class GreeTurboUnavailable(GreeUnsupportedState): """Turbo mode is only available under Cool and Heat modes.""" -class GreeTurboIgnored(GreeUnsupportedState): - """Turbo mode is ignored when Energy Saving or Smart Heat are enabled.""" - - -class GreeQuietIgnored(GreeUnsupportedState): - """Quiet mode is ignored when Energy Saving or Smart Heat are enabled.""" - - class GreeHumidityControlUnavailable(GreeUnsupportedState): """Humidity Control is only available under Cool mode.""" diff --git a/custom_components/gree_custom/aiogree/helpers.py b/custom_components/gree_custom/aiogree/helpers.py old mode 100644 new mode 100755 index e88f862..b4b2b69 --- a/custom_components/gree_custom/aiogree/helpers.py +++ b/custom_components/gree_custom/aiogree/helpers.py @@ -1,14 +1,112 @@ """Helpers for the Gree device API.""" +from collections.abc import Iterable, Iterator +from itertools import islice +import json import logging +from typing import Any, TypeVar -from .const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F +from .cipher import CipherBase +from .const import MAX_PACK_SIZE, MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F +from .errors import GreeError TEMSEN_OFFSET = 40 _LOGGER = logging.getLogger(__name__) +def gree_encrypt_pack( + json_payload: dict[str, Any], + cipher: CipherBase, +) -> dict[str, Any]: + """Encrypt a protocol pack for transmission to the device.""" + + if cipher is None: + raise GreeError("Cipher must not be None") + + len1 = len(json.dumps(json_payload).encode("utf-8")) + # encrypt pack if present + if pack := json_payload.get("pack"): + len2 = len(json.dumps(pack).encode("utf-8")) + # WARNING: Packs over MAX_PACK_SIZE bytes may fail to get a response + if len2 >= MAX_PACK_SIZE: + _LOGGER.warning( + "Pack length is over %d bytes. Expect no response", MAX_PACK_SIZE + ) + + json_payload["pack"], tag = cipher.encrypt(json.dumps(pack)) + if tag is not None: + json_payload["tag"] = tag + + len3 = len(json_payload["pack"].encode("utf-8")) + + len4 = len(json.dumps(json_payload).encode("utf-8")) + _LOGGER.debug( + "Before Encryption(payload=%d, pack=%d) | After Encryption(payload=%d, pack=%d)", + len1, + len2, + len3, + len4, + ) + return json_payload + + +def gree_decrypt_pack( + recv_json: dict[str, Any], + cipher: CipherBase, +) -> dict: + """Decode and decrypt a response from a Gree device.""" + + if cipher is None: + raise GreeError("Cipher must not be None") + + if encoded_pack := recv_json.get("pack"): + tag = recv_json.get("tag") + decrypted_pack = cipher.decrypt(encoded_pack, tag) + # Replace encrypted pack with decrypted data + recv_json["pack"] = json.loads(decrypted_pack) + + return recv_json + + +def gree_extract_macs(raw_mac: str) -> tuple[str, str]: + """Extract the (mac, mac_controller) from a given raw_mac.""" + normalized_mac = raw_mac.replace(":", "").replace("-", "").strip().lower() + + # For VRF units, the MAC will be of >12 characters and end in "00" + # where for MQTT the first 12 are the main_device MAC and the full string the sub_device + # For imported configs in mac@main_mac format separate them + # TODO: Cloud has a "pmac" field. Check if it is "parent mac" aka main_device + if "@" in normalized_mac: + mac, mac_controller = normalized_mac.split("@", 1) + elif len(normalized_mac) > 12 and normalized_mac.endswith("00"): + mac = normalized_mac + mac_controller = normalized_mac[:12] + else: + mac = mac_controller = normalized_mac + + return mac, mac_controller + + +T = TypeVar("T") + + +def chunked(iterable: Iterable[T], size: int) -> Iterator[list[T]]: + """Create chunks of data.""" + it = iter(iterable) + while chunk := list(islice(it, size)): + yield chunk + + +def redact_str(to_redact: str | None) -> str: + """Redact an encryption key.""" + return ( + to_redact[:5] + "[redacted]" + if (to_redact and to_redact.strip()) + else "[no_key]" + ) + + class TempOffsetResolver: """Detect whether this sensor reports temperatures in °C or in (°C + 40).""" @@ -150,8 +248,8 @@ def gree_get_target_temperature_f(SetTem: int, TemRec: int) -> float: if TemRec == 1: # SetTem is closer to its higher bound, so we consider SetTem as the lower limit - min_celsius = SetTem - max_celsius = SetTem + 0.4999 # Just below the next rounding threshold + min_celsius: float = SetTem + max_celsius: float = SetTem + 0.4999 # Just below the next rounding threshold else: # SetTem is closer to its lower bound, so we consider SetTem-1 as the potential lower limit min_celsius = SetTem - 0.4999 # Just above the previous rounding threshold @@ -178,7 +276,7 @@ def gree_get_target_temperature_c(SetTem: int, TemRec: int) -> float: def gree_get_target_humidity_prop_from_p( desired_humidity_percentage: int, min_val: int, max_val: int ) -> int: - """Calculates the prop value for a given humidity percentage.""" + """Compute the prop value for a given humidity percentage.""" if desired_humidity_percentage > max_val: _LOGGER.warning( diff --git a/custom_components/gree_custom/aiogree/transport.py b/custom_components/gree_custom/aiogree/transport.py old mode 100644 new mode 100755 index 8432bfc..d0c2747 --- a/custom_components/gree_custom/aiogree/transport.py +++ b/custom_components/gree_custom/aiogree/transport.py @@ -1,184 +1,128 @@ """Handles network connections.""" -import asyncio +from abc import ABC, abstractmethod +from collections import Counter, defaultdict +from collections.abc import Callable import json import logging +from typing import Any -import asyncio_dgram - -from .errors import GreeConnectionError +from .cipher import CipherBase +from .helpers import gree_decrypt_pack, gree_encrypt_pack _LOGGER = logging.getLogger(__name__) -class GreeTransport: - """Handles the connection with the Gree device.""" +class GreeBaseTransport(ABC): + """Base transport interface.""" + + batch_support: bool = False + + def __init__(self) -> None: + """Init transport.""" + self._listeners: dict[str, set[Callable[[str, dict], None]]] = defaultdict(set) + self.connected_devices: Counter[str] = Counter() + + @abstractmethod + async def connect(self) -> None: + """Establish connection to endpoint.""" + + @abstractmethod + async def disconnect(self) -> None: + """Terminate connection to endpoint.""" + + @abstractmethod + async def subscribe(self, mac_controller: str) -> None: + """Subscribe the transport to a device.""" + + @abstractmethod + async def unsubscribe(self, mac_controller: str) -> None: + """Unsubscribe the transport from a device.""" - def __init__( - self, ip_addr: str, port: int, max_retries: int = 3, timeout: float = 2.0 + @abstractmethod + async def request(self, mac_controller: str, json_str: str) -> str: + """Send raw bytes and return the response.""" + + def add_listener( + self, target_mac: str, listener: Callable[[str, dict], None] ) -> None: - """Initialize the connection object.""" - self.ip_addr = ip_addr - self.port = port - self.max_retries = max_retries - self.timeout = timeout - - self._stream: asyncio_dgram.DatagramClient | None = None - self._lock: asyncio.Lock = asyncio.Lock() - - async def _get_stream(self) -> asyncio_dgram.DatagramClient: - """Create stream once and reuse it while possible.""" - if self._stream is None: - _LOGGER.debug("Creating stream for %s", self.ip_addr) - self._stream = await asyncio_dgram.connect((self.ip_addr, self.port)) - - return self._stream - - async def _reset_stream(self) -> None: - """Safely reset UDP stream if it gets into a bad state.""" - _LOGGER.debug("Reseting stream for %s", self.ip_addr) - if self._stream is not None: - try: - self._stream.close() - except Exception: - _LOGGER.exception("Could not close stream") - self._stream = None - - async def udp_request( - self, - data: bytes, - ) -> bytes: - """Send a payload data to the device and reads the response.""" - - last_error: Exception | None = None - - async with self._lock: # prevents concurrent recv/send corruption - for attempt in range(self.max_retries): - stream: asyncio_dgram.DatagramClient | None = None - - try: - stream = await self._get_stream() - - await stream.send(data) - - received_data, _ = await asyncio.wait_for( - stream.recv(), timeout=self.timeout - ) - - except TimeoutError as err: - last_error = err - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - self.ip_addr, - attempt + 1, - self.max_retries, - ) - await self._reset_stream() - - except Exception as err: # noqa: BLE001 - last_error = err - _LOGGER.warning( - "Error communicating with %s. Attempt %d/%d", - self.ip_addr, - attempt + 1, - self.max_retries, - ) - await self._reset_stream() + """Register a listener for messages for a given device. Callback has the message type and data.""" + self._listeners[target_mac].add(listener) - else: - return received_data + def remove_listener( + self, target_mac: str, listener: Callable[[str, dict], None] + ) -> None: + """Unregister a listener for messages for a given device. Callback has the message type and data.""" + listeners = self._listeners.get(target_mac) + if listeners is None: + return - # Apply backoff before retrying - if attempt < self.max_retries - 1: - await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + listeners.discard(listener) - raise GreeConnectionError( - f"Failed to communicate with device '{self.ip_addr}:{self.port}' after {self.max_retries} attempts" - ) from last_error + if not listeners: + del self._listeners[target_mac] - async def request_json(self, payload: dict) -> dict: + async def request_json( + self, mac_controller: str, payload: dict[str, Any], cipher: CipherBase + ) -> dict[str, Any]: """Send and receive a JSON payload.""" - raw = await self.udp_request(json.dumps(payload).encode("utf-8")) - return json.loads(raw.decode("utf-8")) - - -class UDPDiscoveryProtocol(asyncio.DatagramProtocol): - """Helper Protocol to handle incoming UDP discovery responses. - - Responses will be added to a 'responses' field which can be queried. - """ - - def __init__(self, responses: dict[str, dict]) -> None: - """Setup Discovery Transport. Use the responses to query the received data.""" - self.responses = responses - self.transport = None - - def connection_made(self, transport: asyncio.DatagramTransport): - """Called when the UDP socket is set up.""" - self.transport = transport - - def datagram_received(self, data: bytes, addr: tuple[str, int]): - """Called when a UDP packet is received.""" - try: - # Decode the payload - payload = json.loads(data.decode("utf-8", errors="ignore")) - ip_address = addr[0] - - self.responses[ip_address] = payload - _LOGGER.debug("Received reply from %s", ip_address) - - except json.JSONDecodeError: - _LOGGER.exception("Could not parse JSON response from %s: %s", addr, data) - except Exception: - _LOGGER.exception("Unexpected error processing packet from %s", addr) - - def error_received(self, exc): - """Called on underlying network errors.""" - _LOGGER.error("UDP network error received: %s", exc) - - def connection_lost(self, exc): - """Called when the socket is closed.""" - - -async def async_udp_broadcast_request( - broadcast_addresses: list[str], port: int, json_data: str, timeout: int -) -> dict[str, dict]: - """Send a UDP broadcast and waits for responses.""" - loop = asyncio.get_running_loop() - responses: dict[str, dict] = {} - - # Remove duplicates - broadcast_addresses = list(dict.fromkeys(broadcast_addresses)) - - try: - transport, _ = await loop.create_datagram_endpoint( - lambda: UDPDiscoveryProtocol(responses), - local_addr=( - "0.0.0.0", - 0, - ), # Listen on all interfaces, random ephemeral port - allow_broadcast=True, - ) - except OSError as err: - _LOGGER.error("Failed to bind UDP socket: %s", err) - return responses - - try: - # Send out the broadcast payload - payload = json_data.encode("utf-8") - for addr in broadcast_addresses: - try: - _LOGGER.debug("Sending broadcast to %s:%s", addr, port) - transport.sendto(payload, (addr, port)) - except Exception: - _LOGGER.exception("Failed sending to %s", addr) - - # Wait for devices to reply asynchronously - _LOGGER.debug("Waiting %d seconds for UDP replies... ", timeout) - await asyncio.sleep(timeout) - - finally: - transport.close() - - _LOGGER.debug("Discovery finished. Got %d responses", len(responses)) - return responses + + requests: list[dict[str, Any]] + + pack = payload.get("pack") + if ( + pack + and not self.batch_support + and pack.get("t") == "cmd" + and len(pack.get("opt", [])) > 1 + ): + requests = [] + + for opt, value in zip(pack["opt"], pack["p"], strict=True): + request = payload.copy() + request["pack"] = { + **pack, + "opt": [opt], + "p": [value], + } + requests.append(request) + else: + requests = [payload] + + responses: list[dict[str, Any]] = [] + + for request in requests: + request = gree_encrypt_pack(request, cipher) + + raw_request = json.dumps(request) + raw_response = await self.request(mac_controller, raw_request) + + response = json.loads(raw_response) + response = gree_decrypt_pack(response, cipher) + + responses.append(response) + + if len(responses) == 1: + return responses[0] + + # Merge responses + merged = responses[-1].copy() + merged_pack: dict[str, Any] = {} + + for response in responses: + pack = response.get("pack") + if not isinstance(pack, dict): + continue + + for key, value in pack.items(): + if isinstance(value, list): + merged_pack.setdefault(key, []).extend(value) + else: + merged_pack[key] = value + + if merged_pack: + merged["pack"] = merged_pack + else: + merged.pop("pack", None) + + return merged diff --git a/custom_components/gree_custom/aiogree/transport_mqtt.py b/custom_components/gree_custom/aiogree/transport_mqtt.py new file mode 100755 index 0000000..1f4df90 --- /dev/null +++ b/custom_components/gree_custom/aiogree/transport_mqtt.py @@ -0,0 +1,260 @@ +"""Handles MQTT connections.""" + +import asyncio +import contextlib +import json +import logging +import random +import ssl +from typing import override + +import aiomqtt + +from .cloud_api import GreeRegion +from .errors import GreeRuntimeError +from .transport import GreeBaseTransport + +_LOGGER = logging.getLogger(__name__) + + +MQTT_SERVERS = { + GreeRegion.AU: "mqtt-au.gree.com", + GreeRegion.CN: "mqtt-cn.gree.com", + GreeRegion.AS: "mqtt-as.gree.com", + GreeRegion.EU: "mqtt-eu.gree.com", + GreeRegion.IN: "mqtt-in.gree.com", + GreeRegion.LA: "mqtt-la.gree.com", + GreeRegion.ME: "mqtt-me.gree.com", + GreeRegion.US: "mqtt-us.gree.com", + GreeRegion.RU: "mqtt-ru.gree.com", + GreeRegion.SA: "mqtt-sa.gree.com", +} + + +class GreeMqttTransport(GreeBaseTransport): + """MQTT transport.""" + + batch_support = True + + def __init__( + self, + user_id: str, + token: str, + region: GreeRegion, + port: int = 1984, + keepalive: int = 60, + timeout: float = 10.0, + ) -> None: + """Initialize the MQTT transport object.""" + super().__init__() + + self._user_id = user_id + self._token = token + self._region = region + self._port = port + + self._timeout = timeout + self._keepalive = keepalive + + # Stable for the lifetime of this MQTT session. + self._cid = str(random.randint(1_000_000_000, 9_999_999_999)) + + self._client: aiomqtt.Client | None = None + self._connected = False + + self._receive_task: asyncio.Task | None = None + + self._request_lock = asyncio.Lock() + self._pending: asyncio.Future[str] | None = None + + @override + def __str__(self) -> str: + """Representation of the MQTT transport.""" + return f"MQTT({self._user_id}, {self._region.name})" + + @override + async def connect(self) -> None: + if self._connected: + return + + # Create TLS context for secure connection + tls_context = ssl.create_default_context() + # Allow self-signed certificates (Gree broker uses custom cert) + tls_context.check_hostname = False + tls_context.verify_mode = ssl.CERT_NONE + + self._client = aiomqtt.Client( + hostname=MQTT_SERVERS[self._region], + port=self._port, + username=self._user_id, + password=self._token, + identifier=f"app_{self._cid}", + protocol=aiomqtt.ProtocolVersion.V311, + keepalive=self._keepalive, + tls_context=tls_context, + timeout=self._timeout, + ) + + await self._client.__aenter__() # pylint: disable=unnecessary-dunder-call + self._connected = True + + # Create receiving task + self._receive_task = asyncio.create_task(self._receive_loop()) + self._receive_task.add_done_callback(self._receive_task_done) + + _LOGGER.debug("Connected to MQTT broker %s:%d", self._region.value, self._port) + + @override + async def disconnect(self) -> None: + if not self._connected or not self._client: + return + + # Close MQTT client + try: + await self._client.__aexit__(None, None, None) # pylint: disable=unnecessary-dunder-call + except Exception: + _LOGGER.exception("Error closing MQTT client") + finally: + self._client = None + + # Clear receive task + if self._receive_task: + self._receive_task.cancel() + + with contextlib.suppress(asyncio.CancelledError): + await self._receive_task + + self._receive_task.remove_done_callback(self._receive_task_done) + self._receive_task = None + + self._pending = None + + self._connected = False + _LOGGER.debug("Disconnected from MQTT broker") + + @override + async def subscribe(self, mac_controller: str) -> None: + await self.connect() + + if not self._connected or not self._client: + raise GreeRuntimeError("MQTT transport not connected") + + if mac_controller not in self.connected_devices: + topics = [ + f"response/{mac_controller}/#", + f"status/{mac_controller}/#", + f"connect/{mac_controller}", + ] + + for topic in topics: + await self._client.subscribe(topic, qos=1) + _LOGGER.debug("Subscribed to topic: %s", topic) + + self.connected_devices[mac_controller] += 1 + + @override + async def unsubscribe(self, mac_controller: str) -> None: + if not self._connected or not self._client: + raise GreeRuntimeError("MQTT transport not connected") + + if self.connected_devices[mac_controller] > 1: + self.connected_devices[mac_controller] -= 1 + else: + self.connected_devices.pop(mac_controller, None) + topics = [ + f"response/{mac_controller}/#", + f"status/{mac_controller}/#", + f"connect/{mac_controller}", + ] + + for topic in topics: + await self._client.unsubscribe(topic) + _LOGGER.debug("Unsubscribed from topic: %s", topic) + + if len(self.connected_devices) == 0: + return await self.disconnect() + + return None + + @override + async def request(self, mac_controller: str, json_str: str) -> str: + """Publish one MQTT request and wait for its response.""" + + if not self._connected or not self._client: + raise GreeRuntimeError("Transport not connected") + + async with self._request_lock: + future: asyncio.Future[str] = asyncio.get_running_loop().create_future() + self._pending = future + + try: + # Responses match 1:1 requests + await self._client.publish( + topic=f"request/{mac_controller}", + payload=json_str, + qos=1, + ) + + return await asyncio.wait_for( + future, + timeout=self._timeout, + ) + + finally: + self._pending = None + + def _receive_task_done(self, task: asyncio.Task[None]) -> None: + """Handle receive task completion.""" + if task.cancelled(): + _LOGGER.debug("MQTT receive loop cancelled") + return + + exception = task.exception() + if exception: + _LOGGER.debug( + "MQTT receive loop stopped with exception", + exc_info=exception, + ) + else: + _LOGGER.debug("MQTT receive loop stopped normally") + + async def _receive_loop(self) -> None: + """Receive MQTT messages.""" + + if not self._connected or not self._client: + raise GreeRuntimeError("Transport not connected.") + + async for message in self._client.messages: + topic = str(message.topic) + payload = message.payload.decode() + + _LOGGER.debug("Received MQTT Message with topic: %s", topic) + + # + # response/ completes pending request + # + if ( + topic.startswith("response/") + and self._pending + and not self._pending.done() + ): + self._pending.set_result(payload) + continue + + # + # status/ and connect/ + # + for target_mac, listeners in self._listeners.items(): + if target_mac not in topic: + continue + + for listener in listeners: + try: + listener(topic, json.loads(payload)) + except Exception: + _LOGGER.exception("MQTT listener raised") + + @property + def cid(self) -> str: + """MQTT client identifier used in request envelopes.""" + return self._cid diff --git a/custom_components/gree_custom/aiogree/transport_udp.py b/custom_components/gree_custom/aiogree/transport_udp.py new file mode 100755 index 0000000..bb6aa81 --- /dev/null +++ b/custom_components/gree_custom/aiogree/transport_udp.py @@ -0,0 +1,251 @@ +"""Handles UDP connections.""" + +import asyncio +import json +import logging +from typing import Any, cast, override + +import asyncio_dgram + +from .cipher import CipherBase, EncryptionVersion, get_cipher +from .errors import GreeConnectionError, GreeError +from .helpers import gree_decrypt_pack, gree_encrypt_pack +from .transport import GreeBaseTransport + +_LOGGER = logging.getLogger(__name__) + + +class GreeUdpTransport(GreeBaseTransport): + """Gree UDP protocol implementation.""" + + batch_support = True + + def __init__( + self, ip_addr: str, port: int = 7000, max_retries: int = 3, timeout: float = 2.0 + ) -> None: + """Initialize the transport object.""" + super().__init__() + self.ip_addr = ip_addr + self.port = port + self.max_retries = max_retries + self.timeout = timeout + + self._stream: asyncio_dgram.DatagramClient | None = None + self._request_lock: asyncio.Lock = asyncio.Lock() + self._stream_lock: asyncio.Lock = asyncio.Lock() + + @override + def __str__(self) -> str: + """Representation of the class.""" + return f"Local({self.ip_addr})" + + async def _get_stream(self) -> asyncio_dgram.DatagramClient: + """Create stream once and reuse it while possible.""" + async with self._stream_lock: + if self._stream is None: + _LOGGER.debug("Creating stream for %s", self.ip_addr) + self._stream = await asyncio_dgram.connect((self.ip_addr, self.port)) + _LOGGER.debug("Stream created") + + return self._stream + + def _reset_stream(self) -> None: + """Safely reset UDP stream if it gets into a bad state.""" + _LOGGER.debug("Resetting stream for %s", self.ip_addr) + + if self._stream is not None: + try: + self._stream.close() + except Exception: + _LOGGER.exception("Could not close stream") + self._stream = None + + async def set_ip(self, ip_addr: str) -> None: + """Set the IP used in the transport.""" + async with self._stream_lock: + self.ip_addr = ip_addr + self._reset_stream() + + @override + async def connect(self) -> None: + if self._stream is None: + await self._get_stream() + + try: + # when connecting, perform a targeted scan so the device can respond to the consecutive bind request + # if this fails, the device probably does not support local connections + await self.request_json( + "", {"t": "scan"}, get_cipher(EncryptionVersion.V1) + ) + except GreeError: + self._reset_stream() + raise + + @override + async def disconnect(self) -> None: + self._reset_stream() + + @override + async def subscribe(self, mac_controller: str) -> None: + await self.connect() + self.connected_devices[mac_controller] += 1 + + @override + async def unsubscribe(self, mac_controller: str) -> None: + if self.connected_devices[mac_controller] > 1: + self.connected_devices[mac_controller] -= 1 + else: + self.connected_devices.pop(mac_controller, None) + + if len(self.connected_devices) == 0: + return await self.disconnect() + return None + + @override + async def request( + self, + mac_controller: str, + json_str: str, + ) -> str: + + last_error: Exception | None = None + + async with self._request_lock: # prevents concurrent recv/send corruption + for attempt in range(self.max_retries): + try: + stream: asyncio_dgram.DatagramClient = await self._get_stream() + + await stream.send(json_str.encode()) + + received_data, _ = await asyncio.wait_for( + stream.recv(), timeout=self.timeout + ) + + except Exception as err: # noqa: BLE001 + last_error = err + _LOGGER.warning( + "Error communicating with %s. Attempt %d/%d", + self.ip_addr, + attempt + 1, + self.max_retries, + ) + self._reset_stream() + + else: + return received_data.decode() + + # Apply backoff before retrying + if attempt < self.max_retries - 1: + await asyncio.sleep(0.5 + attempt * 0.3) # 0.5s, 0.8s, 1.1s, ... + + raise GreeConnectionError( + f"Failed to communicate with device '{self.ip_addr}:{self.port}' after {self.max_retries} attempts" + ) from last_error + + +class UDPDiscoveryProtocol(asyncio.DatagramProtocol): + """Helper Protocol to handle incoming UDP discovery responses. + + Responses will be added to a 'responses' field which can be queried. + """ + + def __init__(self, responses: dict[str, dict], cipher: CipherBase) -> None: + """Initialize Discovery Transport. Use the responses to query the received data.""" + self._cipher = cipher + self.responses = responses + self.transport: asyncio.DatagramTransport | None = None + + @override + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """After UDP socket is set up.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + @override + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """After a UDP packet is received.""" + try: + # Decode the payload + json_payload = json.loads(data.decode("utf-8", errors="ignore")) + json_payload = gree_decrypt_pack(json_payload, self._cipher) + ip_address = addr[0] + + self.responses[ip_address] = json_payload + _LOGGER.debug("Received reply from %s", ip_address) + + except json.JSONDecodeError: + _LOGGER.exception("Could not parse JSON response from %s: %s", addr, data) + except Exception: + _LOGGER.exception("Unexpected error processing packet from %s", addr) + + @override + def error_received(self, exc: Exception) -> None: + """After underlying network errors.""" + _LOGGER.error("UDP network error received: %s", exc) + + @override + def connection_lost(self, exc: Exception | None) -> None: + """After the socket is closed.""" + + def send(self, json_payload: dict[str, Any], addr: tuple[str, int]) -> None: + """Send a JSON payload to a target address.""" + _LOGGER.debug("Sending broadcast to %s", addr) + + if not self.transport: + raise RuntimeError("Transport not initialized") + + # encrypt pack if present + json_payload = gree_encrypt_pack(json_payload, self._cipher) + + raw_request = json.dumps(json_payload).encode("utf-8") + + self.transport.sendto(raw_request, addr) + + +async def async_udp_broadcast_request( + broadcast_addresses: list[str], + port: int, + json_data: dict[str, Any], + timeout: int, + cipher: CipherBase, +) -> dict[str, dict]: + """Send a UDP broadcast and waits for responses.""" + loop = asyncio.get_running_loop() + responses: dict[str, dict] = {} + + # Remove duplicates + broadcast_addresses = list(dict.fromkeys(broadcast_addresses)) + + if len(broadcast_addresses) == 0: + _LOGGER.info("No broadcast addresses to scan") + return {} + + try: + transport, protocol = await loop.create_datagram_endpoint( + lambda: UDPDiscoveryProtocol(responses, cipher), + local_addr=( + "0.0.0.0", + 0, + ), # Listen on all interfaces, random ephemeral port + allow_broadcast=True, + ) + except OSError as err: + _LOGGER.error("Failed to bind UDP socket: %s", err) + return responses + + try: + # Send out the broadcast payload + for addr in broadcast_addresses: + try: + protocol.send(json_data, (addr, port)) + except Exception: + _LOGGER.exception("Failed sending to %s", addr) + + # Wait for devices to reply asynchronously + _LOGGER.debug("Waiting %d seconds for UDP replies... ", timeout) + await asyncio.sleep(timeout) + + finally: + transport.close() + + _LOGGER.debug("Discovery finished. Got %d responses", len(responses)) + return responses diff --git a/custom_components/gree_custom/binary_sensor.py b/custom_components/gree_custom/binary_sensor.py old mode 100644 new mode 100755 index e26626e..f7f558b --- a/custom_components/gree_custom/binary_sensor.py +++ b/custom_components/gree_custom/binary_sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable import logging +from typing import override from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -16,7 +17,7 @@ from .const import GATTR_FAULTS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import iter_platform_context, supported_descriptions +from .platform_helpers import supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -36,6 +37,7 @@ class GreeBinarySensorDescription( device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_func=lambda device: device.has_hvac_error, + auto_device_support=True, ), ] @@ -51,22 +53,24 @@ async def async_setup_entry( entities: list[GreeBinarySensor] = [] - for ctx in iter_platform_context(entry): + for coordinator in entry.runtime_data.values(): descriptions = supported_descriptions( SENSOR_TYPES, - ctx.coordinator.device, - ctx.device_config, + coordinator.device, + coordinator.device_config, ) _LOGGER.debug( "Adding Binary Sensor Entities for device '%s': %s", - ctx.coordinator.device.mac_address, + coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( [ - GreeBinarySensor(description, ctx.coordinator, ctx.check_availability) + GreeBinarySensor( + description, coordinator, coordinator.check_availability + ) for description in descriptions ] ) @@ -101,6 +105,7 @@ def __init__( ) @property + @override def is_on(self) -> bool | None: """Return the state of the sensor.""" return self.entity_description.value_func(self.device) diff --git a/custom_components/gree_custom/climate.py b/custom_components/gree_custom/climate.py old mode 100644 new mode 100755 index 26607ab..968d2ad --- a/custom_components/gree_custom/climate.py +++ b/custom_components/gree_custom/climate.py @@ -1,16 +1,17 @@ """Gree Climate Entity for Home Assistant.""" import logging +from typing import Any, override from homeassistant.components.climate import ( - ATTR_FAN_MODE, - ATTR_HVAC_MODE, - ATTR_SWING_HORIZONTAL_MODE, - ATTR_SWING_MODE, + ATTR_FAN_MODE, # pyright: ignore[reportPrivateImportUsage] + ATTR_HVAC_MODE, # pyright: ignore[reportPrivateImportUsage] + ATTR_SWING_HORIZONTAL_MODE, # pyright: ignore[reportPrivateImportUsage] + ATTR_SWING_MODE, # pyright: ignore[reportPrivateImportUsage] ClimateEntity, ClimateEntityDescription, - ClimateEntityFeature, - HVACMode, + ClimateEntityFeature, # pyright: ignore[reportPrivateImportUsage] + HVACMode, # pyright: ignore[reportPrivateImportUsage] ) from homeassistant.const import ( ATTR_TEMPERATURE, @@ -33,12 +34,13 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.unit_conversion import TemperatureConverter -from .aiogree.api import FanSpeed, GreeProp, HorizontalSwingMode, VerticalSwingMode +from .aiogree.api import FanSpeed, HorizontalSwingMode, VerticalSwingMode from .aiogree.const import MAX_TEMP_C, MAX_TEMP_F, MIN_TEMP_C, MIN_TEMP_F -from .aiogree.errors import GreeQuietIgnored, GreeTurboIgnored, GreeTurboUnavailable +from .aiogree.errors import GreeTurboUnavailable from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, + CONF_DEVICE_OPTIONS, CONF_FAN_MODES, CONF_HVAC_MODES, CONF_SWING_HORIZONTAL_MODES, @@ -58,7 +60,6 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import iter_platform_context _LOGGER = logging.getLogger(__name__) @@ -82,21 +83,15 @@ async def async_setup_entry( entities: list[GreeClimate] = [] - for ctx in iter_platform_context(entry): + for coordinator in entry.runtime_data.values(): + options: dict[str, Any] = coordinator.device_config.get(CONF_DEVICE_OPTIONS, {}) + hvac_modes: list[HVACMode] = [ HVACMode[mode.upper()] - for mode in ( - ctx.device_config[CONF_HVAC_MODES] - if ctx.device_config[CONF_HVAC_MODES] is not None - else DEFAULT_HVAC_MODES - ) + for mode in options.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES) ] - fan_modes: list[str] = ( - ctx.device_config[CONF_FAN_MODES] - if ctx.device_config[CONF_FAN_MODES] is not None - else DEFAULT_FAN_MODES - ) + fan_modes: list[str] = options.get(CONF_FAN_MODES, DEFAULT_FAN_MODES) fan_modes = sorted( fan_modes, key=lambda mode: ( @@ -104,11 +99,7 @@ async def async_setup_entry( ), ) - swing_modes: list[str] = ( - ctx.device_config[CONF_SWING_MODES] - if ctx.device_config[CONF_SWING_MODES] is not None - else DEFAULT_SWING_MODES - ) + swing_modes: list[str] = options.get(CONF_SWING_MODES, DEFAULT_SWING_MODES) swing_modes = sorted( swing_modes, key=lambda mode: ( @@ -118,10 +109,8 @@ async def async_setup_entry( ), ) - swing_horizontal_modes: list[str] = ( - ctx.device_config[CONF_SWING_HORIZONTAL_MODES] - if ctx.device_config[CONF_SWING_HORIZONTAL_MODES] is not None - else DEFAULT_SWING_HORIZONTAL_MODES + swing_horizontal_modes: list[str] = options.get( + CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES ) swing_horizontal_modes = sorted( swing_horizontal_modes, @@ -140,7 +129,7 @@ async def async_setup_entry( _LOGGER.debug( "Adding Climate Entity for device '%s'", - ctx.coordinator.device.mac_address, + coordinator.device.mac_address, ) entities.append( @@ -149,22 +138,20 @@ async def async_setup_entry( key=GATTR_CLIMATE, translation_key=GATTR_CLIMATE, ), - ctx.coordinator, + coordinator, hvac_modes, fan_modes, swing_modes, swing_horizontal_modes, - temperature_step=ctx.device_config.get( + temperature_step=options.get( CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP ), - restore_state=ctx.restore_state, - check_availability=ctx.check_availability, - external_temperature_sensor_id=ctx.device_config.get( + restore_state=coordinator.restore_states, + check_availability=coordinator.check_availability, + external_temperature_sensor_id=options.get( ATTR_EXTERNAL_TEMPERATURE_SENSOR ), - external_humidity_sensor_id=ctx.device_config.get( - ATTR_EXTERNAL_HUMIDITY_SENSOR - ), + external_humidity_sensor_id=options.get(ATTR_EXTERNAL_HUMIDITY_SENSOR), ) ) @@ -174,6 +161,8 @@ async def async_setup_entry( class GreeClimate(GreeEntity, ClimateEntity, RestoreEntity): # pyright: ignore[reportIncompatibleVariableOverride] """Climate Entity.""" + entity_description: GreeClimateDescription + def __init__( self, description: GreeClimateDescription, @@ -239,7 +228,8 @@ def __init__( repr(self._attr_supported_features), ) - async def async_added_to_hass(self): + @override + async def async_added_to_hass(self) -> None: """When this entity is added to hass.""" await super().async_added_to_hass() @@ -265,7 +255,7 @@ async def async_added_to_hass(self): ) ) - # When using an external himidity sensor, subscribe to its state changes for updating the current humidity + # When using an external humidity sensor, subscribe to its state changes for updating the current humidity if self._external_humidity_sensor and self._external_humidity_sensor != "None": self._update_current_humidity_from_external( self.hass.states.get(self._external_humidity_sensor) @@ -285,141 +275,143 @@ async def async_added_to_hass(self): ) ) - async def _restore_entity_state(self): + async def _restore_entity_state(self) -> None: # noqa: C901 last_state = await self.async_get_last_state() - if last_state is not None: - _LOGGER.debug( - "Restoring state for %s:\n%s", - self.unique_id, - last_state, - ) + if last_state is None: + return - # hvac mode - if last_state.state not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]: - last_hvac_mode: HVACMode | None = HVACMode(last_state.state) - if ( - last_hvac_mode - and last_hvac_mode != self._attr_hvac_mode - and last_hvac_mode in self._attr_hvac_modes - ): - try: - await self.async_set_hvac_mode(last_hvac_mode) - except Exception: - _LOGGER.exception( - "Failed to restore the hvac_mode: %s", last_hvac_mode - ) - else: - _LOGGER.debug( - "No need to restore the hvac_mode: %s", - last_hvac_mode, - ) + _LOGGER.debug( + "Restoring state for %s:\n%s", + self.unique_id, + last_state, + ) - # fan mode - last_fan_mode: str | None = last_state.attributes.get(ATTR_FAN_MODE) + # hvac mode + if last_state.state not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE]: + last_hvac_mode: HVACMode | None = HVACMode(last_state.state) if ( - last_fan_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self._attr_fan_modes - and last_fan_mode != self._attr_fan_mode - and last_fan_mode in self._attr_fan_modes + last_hvac_mode + and last_hvac_mode != self._attr_hvac_mode + and last_hvac_mode in self._attr_hvac_modes ): try: - await self.async_set_fan_mode(last_fan_mode) + await self.async_set_hvac_mode(last_hvac_mode) except Exception: _LOGGER.exception( - "Failed to restore the fan_mode: %s", last_fan_mode + "Failed to restore the hvac_mode: %s", last_hvac_mode ) else: _LOGGER.debug( - "No need to restore the fan_mode: %s", - last_fan_mode, + "No need to restore the hvac_mode: %s", + last_hvac_mode, ) - # swings - last_swing_mode: str | None = last_state.attributes.get(ATTR_SWING_MODE) - if ( - last_swing_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self._attr_swing_modes - and last_swing_mode != self._attr_swing_mode - and last_swing_mode in self._attr_swing_modes - ): - try: - await self.async_set_swing_mode(last_swing_mode) - except Exception: - _LOGGER.exception( - "Failed to restore the swing_mode: %s", last_swing_mode - ) - else: - _LOGGER.debug( - "No need to restore the swing_mode: %s", - last_swing_mode, + # fan mode + last_fan_mode: str | None = last_state.attributes.get(ATTR_FAN_MODE) + if ( + last_fan_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] + and self._attr_fan_modes + and last_fan_mode != self._attr_fan_mode + and last_fan_mode in self._attr_fan_modes + ): + try: + await self.async_set_fan_mode(last_fan_mode) + except Exception: + _LOGGER.exception("Failed to restore the fan_mode: %s", last_fan_mode) + else: + _LOGGER.debug( + "No need to restore the fan_mode: %s", + last_fan_mode, + ) + + # swings + last_swing_mode: str | None = last_state.attributes.get(ATTR_SWING_MODE) + if ( + last_swing_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] + and self._attr_swing_modes + and last_swing_mode != self._attr_swing_mode + and last_swing_mode in self._attr_swing_modes + ): + try: + await self.async_set_swing_mode(last_swing_mode) + except Exception: + _LOGGER.exception( + "Failed to restore the swing_mode: %s", last_swing_mode ) + else: + _LOGGER.debug( + "No need to restore the swing_mode: %s", + last_swing_mode, + ) - last_swing_horizontal_mode: str | None = last_state.attributes.get( - ATTR_SWING_HORIZONTAL_MODE + last_swing_horizontal_mode: str | None = last_state.attributes.get( + ATTR_SWING_HORIZONTAL_MODE + ) + if ( + last_swing_horizontal_mode not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] + and self.swing_horizontal_modes + and last_swing_horizontal_mode != self.swing_horizontal_mode + and last_swing_horizontal_mode in self.swing_horizontal_modes + ): + try: + await self.async_set_swing_horizontal_mode(last_swing_horizontal_mode) + except Exception: + _LOGGER.exception( + "Failed to restore the swing_horizontal_mode: %s", + last_swing_horizontal_mode, + ) + else: + _LOGGER.debug( + "No need to restore the swing_horizontal_mode: %s", + last_swing_horizontal_mode, + ) + + # target temp + last_target_temperature: float | str | None = last_state.attributes.get( + ATTR_TEMPERATURE + ) + if ( + last_target_temperature is not None + and last_target_temperature + not in [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ] + and isinstance(last_target_temperature, float) + ): + # since the ºC and ºF ranges don't overlap we can guess the last state units + last_unit: UnitOfTemperature = ( + UnitOfTemperature.CELSIUS + if last_target_temperature <= MAX_TEMP_C + else UnitOfTemperature.FAHRENHEIT + ) + last_target_temperature = TemperatureConverter.convert( + last_target_temperature, + last_unit, + self._attr_temperature_unit, ) if ( - last_swing_horizontal_mode - not in [None, STATE_UNKNOWN, STATE_UNAVAILABLE] - and self.swing_horizontal_modes - and last_swing_horizontal_mode != self.swing_horizontal_mode - and last_swing_horizontal_mode in self.swing_horizontal_modes + self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE + and last_target_temperature != self._attr_target_temperature ): try: - await self.async_set_swing_horizontal_mode( - last_swing_horizontal_mode + await self.async_set_temperature( + **{ATTR_TEMPERATURE: last_target_temperature} ) except Exception: _LOGGER.exception( - "Failed to restore the swing_horizontal_mode: %s", - last_swing_horizontal_mode, + "Failed to restore the target_temperature: %s%s", + last_target_temperature, + last_unit, ) else: _LOGGER.debug( - "No need to restore the swing_horizontal_mode: %s", - last_swing_horizontal_mode, - ) - - # target temp - last_target_temperature: float | None = last_state.attributes.get( - ATTR_TEMPERATURE - ) - if last_target_temperature is not None and last_target_temperature not in [ - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ]: - # since the ºC and ºF ranges don't overlap we can guess the last state units - last_unit: UnitOfTemperature = ( - UnitOfTemperature.CELSIUS - if last_target_temperature <= MAX_TEMP_C - else UnitOfTemperature.FAHRENHEIT - ) - last_target_temperature = TemperatureConverter.convert( + "No need to restore the target_temperature: %s%s", last_target_temperature, - last_unit, - self._attr_temperature_unit, + self.temperature_unit, ) - if ( - self._attr_supported_features - & ClimateEntityFeature.TARGET_TEMPERATURE - and last_target_temperature != self._attr_target_temperature - ): - try: - await self.async_set_temperature( - **{ATTR_TEMPERATURE: last_target_temperature} - ) - except Exception: - _LOGGER.exception( - "Failed to restore the target_temperature: %s%s", - last_target_temperature, - last_unit, - ) - else: - _LOGGER.debug( - "No need to restore the target_temperature: %s%s", - last_target_temperature, - self.temperature_unit, - ) + @override def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" _LOGGER.debug("Updating Climate Entity for %s", self.device.unique_id) @@ -433,7 +425,9 @@ def _external_temperature_sensor_listener( new_state = event.data.get("new_state") self._update_current_temperature_from_external(new_state) - def _update_current_temperature_from_external(self, new_state: State | None): + def _update_current_temperature_from_external( + self, new_state: State | None + ) -> None: """Update current temperature based on external sensor data.""" if new_state and new_state.state not in ( STATE_UNKNOWN, @@ -495,13 +489,13 @@ def _update_current_humidity_from_external(self, new_state: State | None) -> Non ) self._attr_current_humidity = value - async def _handle_unit_change(self, event): + async def _handle_unit_change(self, event: Event) -> None: """Handle HA unit system change (°C <-> °F).""" # Force refresh from coordinator await self.coordinator.async_request_refresh() - def _update_attributes(self): - """Updates the entity attributes with the device values.""" + def _update_attributes(self) -> None: + """Update the entity attributes with the device values.""" self._attr_available = self.device.available if ( @@ -559,7 +553,8 @@ def _update_attributes(self): if self.hass: self.async_write_ha_state() - async def async_turn_on(self): + @override + async def async_turn_on(self) -> None: """Turn on.""" _LOGGER.debug("turn_on(%s)", self.device.unique_id) @@ -590,7 +585,8 @@ async def async_turn_on(self): finally: await self.coordinator.async_request_refresh() - async def async_turn_off(self): + @override + async def async_turn_off(self) -> None: """Turn off.""" _LOGGER.debug("turn_off(%s)", self.device.unique_id) @@ -623,14 +619,15 @@ async def async_turn_off(self): await self.coordinator.async_request_refresh() def get_hvac_mode(self) -> HVACMode: - """Converts Gree Operation Modes to HA.""" + """Convert Gree Operation Modes to HA.""" return ( HVACMode.OFF if not self.device.power_mode else HVAC_MODES_GREE_TO_HA[self.device.operation_mode] ) - async def async_set_hvac_mode(self, hvac_mode: HVACMode): + @override + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode.""" _LOGGER.debug("set_hvac_mode(%s, %s)", self.device.unique_id, hvac_mode) @@ -655,7 +652,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): await self.async_turn_on() - # This will be called in the turn on + # This will be called in the turn on: # await self._device.update_device_status() # notify coordinator listeners of state change so that dependent entities are updated immediately @@ -671,7 +668,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): self.async_write_ha_state() def get_fan_mode(self) -> str: - """Converts Gree Fan Modes to HA. Accounts for the 2 special modes.""" + """Convert Gree Fan Modes to HA. Accounts for the 2 special modes.""" if ( self._attr_fan_modes and GATTR_FEAT_QUIET_MODE in self._attr_fan_modes @@ -688,7 +685,8 @@ def get_fan_mode(self) -> str: return self.device.fan_speed.name - async def async_set_fan_mode(self, fan_mode: str): + @override + async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" _LOGGER.debug( "set_fan_mode(%s, %s -> %s)", @@ -721,16 +719,6 @@ async def async_set_fan_mode(self, fan_mode: str): translation_domain=DOMAIN, translation_key="turbo_availability" ) from err - except GreeTurboIgnored as err: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="turbo_ignored" - ) from err - - except GreeQuietIgnored as err: - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="quiet_ignored" - ) from err - except Exception as err: _LOGGER.exception("Error in '%s'", "async_set_fan_mode") raise HomeAssistantError( @@ -740,10 +728,11 @@ async def async_set_fan_mode(self, fan_mode: str): self.async_write_ha_state() def get_swing_mode(self) -> str: - """Converts Gree Swing Modes to HA.""" + """Convert Gree Swing Modes to HA.""" return self.device.vertical_swing_mode.name - async def async_set_swing_mode(self, swing_mode): + @override + async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" _LOGGER.debug("async_set_swing_mode(%s, %s)", self.device.unique_id, swing_mode) @@ -769,10 +758,11 @@ async def async_set_swing_mode(self, swing_mode): self.async_write_ha_state() def get_swing_horizontal_mode(self) -> str: - """Converts Gree Swing Horizontal Modes to HA.""" + """Convert Gree Swing Horizontal Modes to HA.""" return self.device.horizontal_swing_mode.name - async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: """Set new target horizontal swing operation.""" _LOGGER.debug( "async_set_swing_horizontal_mode(%s, %s)", @@ -804,19 +794,15 @@ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode): self.async_write_ha_state() def get_temp_units(self) -> UnitOfTemperature: - """Returns the device units of temperature.""" + """Return the device units of temperature.""" return UNITS_GREE_TO_HA[self.device.target_temperature_unit] def get_current_temp(self) -> float | None: - """Returns the current temperature of the room. Accounting for units.""" + """Return the current temperature of the room. Accounting for units.""" # Gree API always return current temperature in ºC # so here we need to convert to the unit of the entity (same as device) - if ( - self.hass - and self.device.supports_property(GreeProp.SENSOR_TEMPERATURE) - and self.device.indoors_temperature_c is not None - ): + if self.hass and self.device.indoors_temperature_c is not None: return TemperatureConverter.convert( float(self.device.indoors_temperature_c), UnitOfTemperature.CELSIUS, @@ -826,15 +812,11 @@ def get_current_temp(self) -> float | None: return None def get_outside_current_temp(self) -> float | None: - """Returns the current outdoor temperature. Accounting for units.""" + """Return the current outdoor temperature. Accounting for units.""" # Gree API always return temperature in ºC # so here we need to convert to the unit of the entity (same as device) - if ( - self.hass - and self.device.supports_property(GreeProp.SENSOR_OUTSIDE_TEMPERATURE) - and self.device.outdoors_temperature_c is not None - ): + if self.hass and self.device.outdoors_temperature_c is not None: return TemperatureConverter.convert( float(self.device.outdoors_temperature_c), UnitOfTemperature.CELSIUS, @@ -844,23 +826,21 @@ def get_outside_current_temp(self) -> float | None: return None def get_current_humidity(self) -> float | None: - """Returns the current humidity of the room.""" + """Return the current humidity of the room.""" # Gree API always return current humidity in % - if ( - self.device.supports_property(GreeProp.SENSOR_HUMIDITY) - and self.device.humidity is not None - ): + if self.device.humidity is not None: return float(self.device.humidity) return None def get_current_target_temp(self) -> float | None: - """Returns the current target temperature set on the device.""" + """Return the current target temperature set on the device.""" # Device already return in the temperature_units return self.device.target_temperature - async def async_set_temperature(self, **kwargs): + @override + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" _LOGGER.debug("async_set_temperature(%s, %s)", self.device.unique_id, kwargs) @@ -884,7 +864,7 @@ async def async_set_temperature(self, **kwargs): ): temperature = None _LOGGER.warning( - "Ignoring temperature when setting the device mode to AUTO. Will be overriden by the device factory settings" + "Ignoring temperature when setting the device mode to AUTO. Will be overridden by the device factory settings" ) try: diff --git a/custom_components/gree_custom/config_flow.py b/custom_components/gree_custom/config_flow.py old mode 100644 new mode 100755 index 1b0d007..65edd06 --- a/custom_components/gree_custom/config_flow.py +++ b/custom_components/gree_custom/config_flow.py @@ -3,27 +3,43 @@ from collections.abc import Mapping from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging -from typing import Any +from typing import Any, override +from aiomqtt import MqttError import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import ( + CONF_BASE, + CONF_DISCOVERY, + CONF_EMAIL, CONF_HOST, - CONF_MAC, + CONF_NAME, + CONF_PASSWORD, CONF_PORT, + CONF_REGION, CONF_SCAN_INTERVAL, CONF_TIMEOUT, + CONF_TOKEN, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import section -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, NumberSelector, NumberSelectorConfig, NumberSelectorMode, + SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -31,17 +47,39 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.storage import Store -from .aiogree.api import GreeDiscoveredDevice, GreeProp, discover_gree_devices +from .aiogree.api import ( + GreeDiscoveredDevice, + GreeProp, + gree_discover_device_local, + gree_discover_devices_cloud, + gree_discover_devices_local, + gree_merge_discovered_devices, +) from .aiogree.cipher import EncryptionVersion +from .aiogree.cloud_api import GreeCloudApi, GreeRegion from .aiogree.device import GreeDevice -from .aiogree.errors import GreeBindingError, GreeConnectionError +from .aiogree.errors import ( + GreeBindingError, + GreeCloudLoginError, + GreeConnectionError, + GreeError, +) +from .aiogree.transport_mqtt import GreeMqttTransport +from .aiogree.transport_udp import GreeUdpTransport from .const import ( ATTR_EXTERNAL_HUMIDITY_SENSOR, ATTR_EXTERNAL_TEMPERATURE_SENSOR, - CONF_ADVANCED, - CONF_DEV_NAME, + ATTR_FEATURES_TO_PROP_MAP, + CONF_ALL_DEVICE_CONNECTIONS, + CONF_ALL_DEVICE_OPTIONS, + CONF_CLOUD, + CONF_DEVICE_CONNECTION, + CONF_DEVICE_CONNECTION_CLOUD, + CONF_DEVICE_CONNECTION_LOCAL, + CONF_DEVICE_OPTIONS, CONF_DEVICES, CONF_DISABLE_AVAILABLE_CHECK, CONF_DISCOVERY_PREFS_KEY, @@ -53,144 +91,254 @@ CONF_FAN_MODES, CONF_FEATURES, CONF_HVAC_MODES, + CONF_MAC_CONTROLLER_CLOUD, + CONF_MAC_CONTROLLER_LOCAL, CONF_MAX_ONLINE_ATTEMPTS, + CONF_PREFER_CLOUD, CONF_RESTORE_STATES, CONF_SWING_HORIZONTAL_MODES, CONF_SWING_MODES, CONF_TEMPERATURE_STEP, CONF_UID, + CONFENTRY_ID_LOCAL_ONLY, + CURRENT_CONF_VERSION, DEFAULT_CONNECTION_MAX_ATTEMPTS, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_DEVICE_PORT, DEFAULT_DEVICE_UID, DEFAULT_DISABLE_AVAILABLE_CHECK, DEFAULT_DISCOVERY_TIMEOUT, + DEFAULT_ENCRYPTION_KEY, + DEFAULT_ENCRYPTION_VERSION, DEFAULT_FAN_MODES, DEFAULT_HVAC_MODES, + DEFAULT_PREFER_CLOUD, DEFAULT_RESTORE_STATES, DEFAULT_SCAN_INTERVAL, DEFAULT_SWING_HORIZONTAL_MODES, DEFAULT_SWING_MODES, DEFAULT_TARGET_TEMP_STEP, DOMAIN, - GATTR_ANTI_DIRECT_BLOW, - GATTR_BEEPER, - GATTR_FAULTS, - GATTR_FEAT_ENERGY_SAVING, - GATTR_FEAT_FRESH_AIR, - GATTR_FEAT_HEALTH, - GATTR_FEAT_HUMIDITY, - GATTR_FEAT_LIGHT, + ENCRYPTION_VERSION_AUTO, GATTR_FEAT_QUIET_MODE, - GATTR_FEAT_SENSOR_LIGHT, - GATTR_FEAT_SLEEP_MODE, - GATTR_FEAT_SMART_HEAT_8C, GATTR_FEAT_TURBO, - GATTR_FEAT_XFAN, MAX_UNICAST_SCAN_HOSTS, MIN_SCAN_INTERVAL, ) from .coordinator import GreeConfigEntry -from .helpers import get_discovery_addresses +from .helpers import ( + create_discovered_from_config, + get_config_entries, + get_configured_macs_in_entries, + get_discovery_addresses, + get_entity_ids_from_unique_ids, + get_entry_matching_mac, +) _LOGGER = logging.getLogger(__name__) -def build_main_schema(data: Mapping | None) -> vol.Schema: - """Builds the main option schema.""" - if data: - _LOGGER.debug("Building main schema with previous values: %s", data) +SETUP_SCHEMA = vol.Schema( + { + vol.Required(CONF_DISCOVERY, default=["cloud", "local"]): SelectSelector( + SelectSelectorConfig( + options=["cloud", "local"], + multiple=True, + translation_key=CONF_DISCOVERY, + ) + ) + } +) + + +def _setup_cloud_schema(defaults_values: dict | None = None) -> vol.Schema: + defaults = defaults_values or {} return vol.Schema( { vol.Required( - CONF_HOST, - default="" if data is None else data.get(CONF_HOST, ""), + CONF_EMAIL, + default=defaults.get(CONF_EMAIL, ""), ): str, vol.Required( - CONF_MAC, - default="" if data is None else data.get(CONF_MAC, ""), + CONF_PASSWORD, + default=defaults.get(CONF_PASSWORD, ""), ): str, - vol.Required(CONF_ADVANCED): section( + vol.Required( + CONF_REGION, + default=defaults.get(CONF_REGION), + ): SelectSelector( + SelectSelectorConfig( + options=[region.value for region in GreeRegion], + multiple=False, + ) + ), + } + ) + + +def _setup_local_schema(default_values: dict | None = None) -> vol.Schema: + defaults = default_values or {} + + return vol.Schema( + { + vol.Optional( + CONF_EXTRA_SCAN_NETWORKS, + description={ + "suggested_value": defaults.get(CONF_EXTRA_SCAN_NETWORKS, []) + }, + ): TextSelector(TextSelectorConfig(multiple=True, multiline=False)), + vol.Optional( + CONF_EXTRA_SCAN_HOSTS, + description={ + "suggested_value": defaults.get(CONF_EXTRA_SCAN_HOSTS, []) + }, + ): TextSelector(TextSelectorConfig(multiple=True, multiline=False)), + } + ) + + +def _setup_picker_schema( + default: list[str], options: dict[str, GreeDiscoveredDevice] +) -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_DEVICES, default=default): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=m, label=d.friendly_name) + for m, d in options.items() + ], + multiple=True, + ) + ) + } + ) + + +def _setup_device_connection_options_schema( + device_info: GreeDiscoveredDevice, default_values: dict | None = None +) -> vol.Schema: + defaults: dict = default_values or {} + defaults_local = defaults.get(CONF_DEVICE_CONNECTION_LOCAL, {}) + defaults_cloud = defaults.get(CONF_DEVICE_CONNECTION_CLOUD, {}) + + return vol.Schema( + { + vol.Required( + CONF_SCAN_INTERVAL, + default=defaults.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL)), + vol.Required( + CONF_DISABLE_AVAILABLE_CHECK, + default=defaults.get( + CONF_DISABLE_AVAILABLE_CHECK, + DEFAULT_DISABLE_AVAILABLE_CHECK, + ), + ): cv.boolean, + vol.Optional( + CONF_ENCRYPTION_KEY, + default=( + defaults.get(CONF_ENCRYPTION_KEY) + or device_info.key + or DEFAULT_ENCRYPTION_KEY + ), + ): TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)), + vol.Required( + CONF_UID, + default=defaults.get(CONF_UID, device_info.user_id), + ): cv.positive_int, + vol.Required(CONF_DEVICE_CONNECTION_LOCAL): section( vol.Schema( { - vol.Required( + vol.Optional( + CONF_MAC_CONTROLLER_LOCAL, + default=( + defaults_local.get(CONF_MAC_CONTROLLER_LOCAL) + or device_info.mac_controller_local + ), + ): str, + vol.Optional( + CONF_HOST, + default=( + defaults_local.get(CONF_HOST) or device_info.host or "" + ), + ): str, + vol.Optional( CONF_PORT, - default=DEFAULT_DEVICE_PORT - if data is None or data.get(CONF_ADVANCED) is None - else data[CONF_ADVANCED].get( - CONF_PORT, DEFAULT_DEVICE_PORT + default=( + defaults_local.get(CONF_PORT) + or device_info.port + or DEFAULT_DEVICE_PORT ), ): cv.port, vol.Required( - CONF_ENCRYPTION_VERSION, - default="Auto-Detect" - if data is None or data.get(CONF_ADVANCED) is None - else data[CONF_ADVANCED].get( - CONF_ENCRYPTION_VERSION, "Auto-Detect" + CONF_TIMEOUT, + default=defaults_local.get( + CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT ), - ): vol.In(["Auto-Detect", 1, 2]), - vol.Optional( - CONF_ENCRYPTION_KEY, - default="" - if data is None or data.get(CONF_ADVANCED) is None - else data[CONF_ADVANCED].get(CONF_ENCRYPTION_KEY, ""), - ): TextSelector( - TextSelectorConfig(type=TextSelectorType.PASSWORD) - ), - vol.Required( - CONF_UID, - default=DEFAULT_DEVICE_UID - if data is None or data.get(CONF_ADVANCED) is None - else data[CONF_ADVANCED].get(CONF_UID, DEFAULT_DEVICE_UID), ): cv.positive_int, vol.Required( - CONF_DISABLE_AVAILABLE_CHECK, - default=False - if data is None - else data.get( - CONF_DISABLE_AVAILABLE_CHECK, - DEFAULT_DISABLE_AVAILABLE_CHECK, + CONF_ENCRYPTION_VERSION, + default=defaults_local.get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION ), - ): cv.boolean, + ): SelectSelector( + SelectSelectorConfig( + translation_key=CONF_ENCRYPTION_VERSION, + options=[ + ENCRYPTION_VERSION_AUTO, + *( + str(version.value) + for version in EncryptionVersion + ), + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), vol.Required( CONF_MAX_ONLINE_ATTEMPTS, - default=DEFAULT_CONNECTION_MAX_ATTEMPTS - if data is None - else data.get( + default=defaults_local.get( CONF_MAX_ONLINE_ATTEMPTS, DEFAULT_CONNECTION_MAX_ATTEMPTS, ), ): cv.positive_int, + } + ) + ), + vol.Required(CONF_DEVICE_CONNECTION_CLOUD): section( + vol.Schema( + { vol.Required( - CONF_TIMEOUT, - default=DEFAULT_CONNECTION_TIMEOUT - if data is None - else data.get(CONF_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT), - ): cv.positive_int, + CONF_PREFER_CLOUD, + default=defaults_cloud.get( + CONF_PREFER_CLOUD, + DEFAULT_PREFER_CLOUD, + ), + ): cv.boolean, + vol.Optional( + CONF_MAC_CONTROLLER_CLOUD, + default=defaults_cloud.get(CONF_MAC_CONTROLLER_CLOUD) + or device_info.mac_controller_mqtt, + ): str, } - ), - {"collapsed": True}, + ) ), } ) -def build_options_schema( - hass: HomeAssistant, device: GreeDevice, data: Mapping | None +def _setup_device_options_schema( # noqa: C901 + hass: HomeAssistant, device: GreeDevice, default_values: Mapping | None ) -> vol.Schema: - """Builds the device option schema.""" - if data: - _LOGGER.debug("Building device options schema with previous values: %s", data) + defaults = default_values or {} schema: dict = {} schema.update( { vol.Required( - CONF_DEV_NAME, - default=f"Gree AC {device.unique_id}" - if data is None - else data.get(CONF_DEV_NAME, f"Gree AC {device.unique_id}"), + CONF_NAME, + default=defaults.get(CONF_NAME, device.name), ): str } ) @@ -200,9 +348,7 @@ def build_options_schema( { vol.Optional( CONF_HVAC_MODES, - default=DEFAULT_HVAC_MODES - if data is None - else data.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), + default=defaults.get(CONF_HVAC_MODES, DEFAULT_HVAC_MODES), ): SelectSelector( config=SelectSelectorConfig( options=DEFAULT_HVAC_MODES, @@ -213,22 +359,22 @@ def build_options_schema( } ) + fan_mapping = { + GreeProp.FAN_SPEED: DEFAULT_FAN_MODES, + GreeProp.FEAT_TURBO_MODE: [GATTR_FEAT_TURBO], + GreeProp.FEAT_QUIET_MODE: [GATTR_FEAT_QUIET_MODE], + } valid_fan_modes = [] - if device.supports_property(GreeProp.FAN_SPEED): - valid_fan_modes = list(DEFAULT_FAN_MODES) - if device.supports_property(GreeProp.FEAT_TURBO_MODE): - valid_fan_modes.append(GATTR_FEAT_TURBO) - if device.supports_property(GreeProp.FEAT_QUIET_MODE): - valid_fan_modes.append(GATTR_FEAT_QUIET_MODE) + for prop, modes in fan_mapping.items(): + if device.supports_property(prop): + valid_fan_modes.extend(modes) if valid_fan_modes: schema.update( { vol.Optional( CONF_FAN_MODES, - default=valid_fan_modes - if data is None - else data.get(CONF_FAN_MODES, valid_fan_modes), + default=defaults.get(CONF_FAN_MODES, valid_fan_modes), ): SelectSelector( config=SelectSelectorConfig( options=valid_fan_modes, @@ -244,9 +390,7 @@ def build_options_schema( { vol.Optional( CONF_SWING_MODES, - default=DEFAULT_SWING_MODES - if data is None - else data.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), + default=defaults.get(CONF_SWING_MODES, DEFAULT_SWING_MODES), ): SelectSelector( config=SelectSelectorConfig( options=DEFAULT_SWING_MODES, @@ -262,9 +406,7 @@ def build_options_schema( { vol.Optional( CONF_SWING_HORIZONTAL_MODES, - default=DEFAULT_SWING_HORIZONTAL_MODES - if data is None - else data.get( + default=defaults.get( CONF_SWING_HORIZONTAL_MODES, DEFAULT_SWING_HORIZONTAL_MODES ), ): SelectSelector( @@ -277,57 +419,35 @@ def build_options_schema( } ) - valid_features = [GATTR_BEEPER] - if device.supports_property(GreeProp.FEAT_FRESH_AIR): - valid_features.append(GATTR_FEAT_FRESH_AIR) - if device.supports_property(GreeProp.FEAT_XFAN): - valid_features.append(GATTR_FEAT_XFAN) - if device.supports_property(GreeProp.FEAT_SLEEP_MODE) or device.supports_property( - GreeProp.FEAT_SLEEP_MODE_SWING - ): - valid_features.append(GATTR_FEAT_SLEEP_MODE) - if device.supports_property(GreeProp.FEAT_SMART_HEAT_8C): - valid_features.append(GATTR_FEAT_SMART_HEAT_8C) - if device.supports_property(GreeProp.FEAT_LIGHT): - valid_features.append(GATTR_FEAT_LIGHT) - if device.supports_property(GreeProp.FEAT_SENSOR_LIGHT): - valid_features.append(GATTR_FEAT_SENSOR_LIGHT) - if device.supports_property(GreeProp.FEAT_HEALTH): - valid_features.append(GATTR_FEAT_HEALTH) - if device.supports_property(GreeProp.FEAT_ANTI_DIRECT_BLOW): - valid_features.append(GATTR_ANTI_DIRECT_BLOW) - if device.supports_property(GreeProp.FEAT_ENERGY_SAVING): - valid_features.append(GATTR_FEAT_ENERGY_SAVING) - if device.supports_property(GreeProp.SENSOR_FAULT): - valid_features.append(GATTR_FAULTS) - if device.supports_property(GreeProp.FEATURE_HUMIDITY_CONTROL): - valid_features.append(GATTR_FEAT_HUMIDITY) + valid_features = [] + for feat, props in ATTR_FEATURES_TO_PROP_MAP.items(): + if all(device.supports_property(p) for p in props): + valid_features.append(feat) - schema.update( - { - vol.Optional( - CONF_FEATURES, - default=valid_features - if data is None - else data.get(CONF_FEATURES, valid_features), - ): SelectSelector( - config=SelectSelectorConfig( - options=valid_features, - multiple=True, - translation_key=CONF_FEATURES, + if valid_features: + schema.update( + { + vol.Optional( + CONF_FEATURES, + default=defaults.get(CONF_FEATURES, valid_features), + ): SelectSelector( + config=SelectSelectorConfig( + options=valid_features, + multiple=True, + translation_key=CONF_FEATURES, + ) ) - ) - } - ) + } + ) if device.supports_property(GreeProp.TARGET_TEMPERATURE): schema.update( { vol.Required( CONF_TEMPERATURE_STEP, - default=DEFAULT_TARGET_TEMP_STEP - if data is None - else data.get(CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP), + default=defaults.get( + CONF_TEMPERATURE_STEP, DEFAULT_TARGET_TEMP_STEP + ), ): NumberSelector( NumberSelectorConfig( min=0.5, @@ -342,713 +462,854 @@ def build_options_schema( schema.update( { - # Ideally we would use an Optional EntitySelector for external sensors. - # Currently we can't because unsetting the value in the UI makes HA - # populate the user_input with the previous set value, making the user - # unable to unset the external sensors. - vol.Required( + vol.Optional( ATTR_EXTERNAL_TEMPERATURE_SENSOR, - default="None" - if data is None - else data.get(ATTR_EXTERNAL_TEMPERATURE_SENSOR, "None"), - ): SelectSelector( - config=SelectSelectorConfig( - options=get_temperature_sensor_options(hass), + description={ + "suggested_value": defaults.get( + ATTR_EXTERNAL_TEMPERATURE_SENSOR, "" + ) + }, + ): EntitySelector( + config=EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=SensorDeviceClass.TEMPERATURE, multiple=False, - mode=SelectSelectorMode.DROPDOWN, - translation_key=ATTR_EXTERNAL_TEMPERATURE_SENSOR, + exclude_entities=get_entity_ids_from_unique_ids( + hass, + SENSOR_DOMAIN, + [ + f"{device.mac_address}_indoor_temperature", + f"{device.mac_address}_outdoor_temperature", + ], + ), ) ), - vol.Required( + vol.Optional( ATTR_EXTERNAL_HUMIDITY_SENSOR, - default="None" - if data is None - else data.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, "None"), - ): SelectSelector( - config=SelectSelectorConfig( - options=get_humidity_sensor_options(hass), + description={ + "suggested_value": defaults.get(ATTR_EXTERNAL_HUMIDITY_SENSOR, "") + }, + ): EntitySelector( + config=EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=SensorDeviceClass.HUMIDITY, multiple=False, - mode=SelectSelectorMode.DROPDOWN, - translation_key=ATTR_EXTERNAL_HUMIDITY_SENSOR, + exclude_entities=get_entity_ids_from_unique_ids( + hass, + SENSOR_DOMAIN, + [ + f"{device.mac_address}_room_humidity", + ], + ), ) ), vol.Required( CONF_RESTORE_STATES, - default=True - if data is None - else data.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), + default=defaults.get(CONF_RESTORE_STATES, DEFAULT_RESTORE_STATES), ): cv.boolean, - vol.Required( - CONF_SCAN_INTERVAL, - default=DEFAULT_SCAN_INTERVAL - if data is None - else data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - ): vol.All(vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL)), } ) - return vol.Schema(schema) + return vol.Schema(schema) -def get_temperature_sensor_options(hass: HomeAssistant) -> list[str]: - """Get list of available temperature sensor entities.""" - options: list[str] = [ - "None" - ] # Include None as option since otherwise the user can't unset the external sensor - - # Get all entities from the registry - for state in hass.states.async_all(): - # Look for temperature sensors - if state.entity_id.startswith("sensor."): - # Check for explicit device_class - if state.attributes.get("device_class") == "temperature": - options.append(state.entity_id) - return options +class SetupConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the integration.""" + VERSION = CURRENT_CONF_VERSION -def get_humidity_sensor_options(hass: HomeAssistant) -> list[str]: - """Get list of available temperature sensor entities.""" - options: list[str] = [ - "None" - ] # Include None as option since otherwise the user can't unset the external sensor + def __init__(self) -> None: + """Initialize the flow.""" + self._selected_setup_methods: list[str] = [] + self._current_setup_method_index = 0 + self._pref_storage: Store | None = None + + self._extra_networks: list[str] = [] + self._extra_hosts: list[str] = [] + + self._config_data: dict = {} + self._config_data["device_connections"] = {} + self._config_data["device_options"] = {} + self._cloud_api: GreeCloudApi | None = None + + self._discovered_devices_cloud: dict[str, GreeDiscoveredDevice] = {} + self._discovered_devices_local: dict[str, GreeDiscoveredDevice] = {} + self._discovered_devices: dict[str, GreeDiscoveredDevice] = {} + self._selected_devices: list[GreeDiscoveredDevice] = [] + self._current_setup_device_index = 0 + + self._mqtt_transport: GreeMqttTransport | None = None + self._local_transports: dict[str, GreeUdpTransport] = {} + self._devices: dict[str, GreeDevice] = {} - # Get all entities from the registry - for state in hass.states.async_all(): - # Look for temperature sensors - if state.entity_id.startswith("sensor."): - # Check for explicit device_class - if state.attributes.get("device_class") == "humidity": - options.append(state.entity_id) + self._connections_by_controller: dict[str, Any] = {} + self._options_by_controller: dict[str, Any] = {} + self._options_by_model: dict[str, Any] = {} - return options + @override + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle discovery via dhcp.""" + _LOGGER.debug("Gree device discovered from dhcp: %s", discovery_info) -def apply_schema_defaults(schema: vol.Schema, data: dict) -> dict: - """Fill in defaults for missing required keys (including nested).""" - data = dict(data or {}) - result = {} + # Check what's under that device: Main device and sub-devices + # If it does not respond locally, there's no use of this information + discover = await gree_discover_device_local( + discovery_info.ip, DEFAULT_DISCOVERY_TIMEOUT, DEFAULT_DEVICE_UID + ) - for key_obj, validator in schema.schema.items(): - key = key_obj.schema # actual string name - value = data.get(key, vol.UNDEFINED) + entries_to_reload: list[GreeConfigEntry] = [] + for d in list(discover): + entry_match = get_entry_matching_mac(self.hass, d.mac) - # Extract default if missing - if value is vol.UNDEFINED: - default = getattr(key_obj, "default", vol.UNDEFINED) - if default is not vol.UNDEFINED: - value = default() if callable(default) else default + if entry_match: + _LOGGER.debug( + "Device '%s' is already configured in entry %s", + discovery_info, + entry_match.title, + ) - # Handle nested schema recursively - if isinstance(validator, vol.Schema) and isinstance(value, dict): - value = apply_schema_defaults(validator, value) + discover.remove(d) + + # update data + new_data = dict(entry_match.data) + new_data[CONF_DEVICES][discovery_info.macaddress][ + CONF_DEVICE_CONNECTION + ][CONF_DEVICE_CONNECTION_LOCAL][CONF_HOST] = discovery_info.ip + # TODO: Check if this only returns True if the IP Changed + if ( + self.hass.config_entries.async_update_entry( + entry_match, data=new_data + ) + and entry_match.unique_id + ): + if entry_match not in entries_to_reload: + _LOGGER.debug( + "Entry '%s' marked for reload", + entry_match.title, + ) + entries_to_reload.append(entry_match) + + for e in entries_to_reload: + _LOGGER.debug( + "Entry '%s' reloading", + e.title, + ) + self.hass.config_entries.async_schedule_reload(e.entry_id) - # Run individual field validator (type checks etc.) - if value is not vol.UNDEFINED: - value = validator(value) if callable(validator) else value + return self.async_abort(reason="reconfigure_successful") - result[key] = value + @override + async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: + """Handle the initial step - how to add devices.""" + errors: dict[str, str] = {} + if user_input is not None: + self._selected_setup_methods = user_input[CONF_DISCOVERY] - return result + if not self._selected_setup_methods: + errors[CONF_DISCOVERY] = "no_methods_selected" + if not errors: + if self._selected_setup_methods == ["local"]: + await self.async_set_unique_id(CONFENTRY_ID_LOCAL_ONLY) + else: + # Cloud must come first + self._selected_setup_methods = sorted( + self._selected_setup_methods, key=lambda x: x != "cloud" + ) -def format_mac_id(mac_addr: str) -> str: - """Returns a formated mac address for use as unique id.""" - if "@" in mac_addr: - _mac_addr_sub, _ = mac_addr.lower().split("@", 1) - return format_mac(_mac_addr_sub) - return format_mac(mac_addr) + self._current_setup_method_index = 0 + return await self._setup_next_setup_method() + return self.async_show_form( + step_id="user", data_schema=SETUP_SCHEMA, errors=errors + ) -DEVICE_OPTIONS_KEYS = { - CONF_TIMEOUT, - CONF_MAX_ONLINE_ATTEMPTS, - CONF_DISABLE_AVAILABLE_CHECK, - ATTR_EXTERNAL_HUMIDITY_SENSOR, - ATTR_EXTERNAL_TEMPERATURE_SENSOR, - CONF_FEATURES, - CONF_SWING_HORIZONTAL_MODES, - CONF_SWING_MODES, - CONF_FAN_MODES, - CONF_HVAC_MODES, -} # keys in the device_options schema + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Process a Reauth request.""" + reauth_entry = self._get_reauth_entry() + _LOGGER.debug("Reauth entry: %s", reauth_entry.title) + return await self.async_step_cloud_add() -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow from user.""" + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of an existing entry.""" + reconfigure_entry = self._get_reconfigure_entry() + reconfigure_data = dict(reconfigure_entry.data) + _LOGGER.debug("Reconfiguring: %s", reconfigure_entry.title) - VERSION = 2 - _discovered_devices: list[GreeDiscoveredDevice] | None = None - _discovery_selected_device: GreeDiscoveredDevice | None = None - _discovery_performed: bool = False + # If on the local-only entry, exit early and continue with the local method only + if reconfigure_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: + self._selected_setup_methods = ["local"] + self._current_setup_method_index = 0 + await self.async_set_unique_id(CONFENTRY_ID_LOCAL_ONLY) + return await self._setup_next_setup_method() - def __init__(self) -> None: - """Initialize the config flow.""" - self._step_main_data: dict | None = None - self._main_mac: str = "" - self._discovered_subdevices: list[GreeDiscoveredDevice] | None = None - self._device_configs: dict = {} - self._selected_subdevices_macs: list = [] - self._reconfiguring_entry: GreeConfigEntry | None = None - self._devices: dict[str, GreeDevice] = {} - self._is_reconfigure: bool = False - - self.pref_storage = None - - async def async_step_import( - self, import_config: dict - ) -> config_entries.ConfigFlowResult: - """Handle import from configuration.yaml.""" - _LOGGER.debug("Importing config entry: %s", import_config) - - mac = import_config.get(CONF_MAC, "") - - if not mac: - _LOGGER.error("No MAC for imported device: %s", import_config) - raise ValueError(f"No MAC for imported device: {import_config}") - - # Combine the schemas - schema1 = build_main_schema(import_config) - data = apply_schema_defaults(schema1, import_config) - - device: GreeDevice = GreeDevice( - f"Temporary Device for {data[CONF_MAC]}", - data[CONF_HOST], - data[CONF_MAC], - data[CONF_ADVANCED][CONF_PORT], - data[CONF_ADVANCED][CONF_ENCRYPTION_KEY], - EncryptionVersion(int(data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION])) - if data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] != "Auto-Detect" - else None, - data[CONF_ADVANCED][CONF_UID], - max_connection_attempts=2, # Use fewer attempts for testing the device - timeout=2, # Use smaller timeout for testing the device - ) - await device.fetch_device_status() + # If entry has cloud, ask if user wants to add local + # A user cannot remove cloud from an entry, because the entry is keyed by the cloud account + # For that, remove the entry and add to the local-only entry - data[CONF_MAC] = device.mac_address_controller - data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] = ( - int(device.encryption_version) if device.encryption_version else 0 + if user_input is not None: + self._selected_setup_methods = ["cloud"] + if user_input.get("include_local", False): + self._selected_setup_methods.append("local") + self._current_setup_method_index = 0 + return await self._setup_next_setup_method() + + # Pre-select the include local option if any devices have local configurations + has_local = any( + d.get(CONF_DEVICE_CONNECTION, {}) + .get(CONF_DEVICE_CONNECTION_LOCAL, {}) + .get(CONF_HOST) + is not None + for d in reconfigure_data.get(CONF_DEVICES, {}).values() ) - data[CONF_ADVANCED][CONF_ENCRYPTION_KEY] = device.encryption_key - - device_configs: list[dict] = import_config.get(CONF_DEVICES, []) - - # add the main device to the configs if not present - if not self._get_device_conf( - import_config, device.mac_address - ) and not self._get_device_conf(import_config, import_config[CONF_MAC]): - device_configs.append({CONF_MAC: device.mac_address}) - - data[CONF_DEVICES] = [] - for dev_config in device_configs: - mac = dev_config.get(CONF_MAC, "") - - if not mac: - _LOGGER.error("No MAC for imported device: %s", dev_config) - continue - - dev: GreeDevice = GreeDevice( - f"Temporary Device for {mac}", - data[CONF_HOST], - mac, - data[CONF_ADVANCED][CONF_PORT], - data[CONF_ADVANCED][CONF_ENCRYPTION_KEY], - EncryptionVersion(int(data[CONF_ADVANCED][CONF_ENCRYPTION_VERSION])), - data[CONF_ADVANCED][CONF_UID], - max_connection_attempts=2, # Use fewer attempts for testing the device - timeout=2, # Use smaller timeout for testing the device - ) - await dev.fetch_device_status() - schema_dev = build_options_schema(self.hass, dev, dev_config) - data[CONF_DEVICES].append( + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( { - **apply_schema_defaults(schema_dev, import_config), - CONF_MAC: dev.mac_address, + vol.Required( + "include_local", + default=has_local, + ): cv.boolean } - ) - - unique_id = format_mac_id(device.mac_address_controller) - entry = next( - ( - e - for e in self.hass.config_entries.async_entries(DOMAIN) - if e.unique_id == unique_id ), - None, ) - await self.async_set_unique_id(unique_id) + async def _setup_next_setup_method( + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Invoke the next selected setup method.""" - if entry: - return self.async_update_reload_and_abort( - entry, - title=f"Gree System at {data[CONF_HOST]}", - data=data, + if self._current_setup_method_index >= len(self._selected_setup_methods): + discovered = gree_merge_discovered_devices( + local_devices=list(self._discovered_devices_local.values()), + cloud_devices=list(self._discovered_devices_cloud.values()), ) + self._discovered_devices = {d.mac: d for d in discovered} - return self.async_create_entry( - title=f"Gree System at {data[CONF_HOST]}", data=data - ) + return await self.async_step_device_picker() - async def async_step_user( - self, user_input: dict | None = None - ) -> config_entries.ConfigFlowResult: - """Handle the initial step - show discovery or manual entry.""" - if user_input is not None: - choice = user_input.get("discovery") - if choice == "discover": - return await self.async_step_manual_discovery() - if choice == "discover_extended": - return await self.async_step_discovery_options() - return await self.async_step_manual_add() - - # Show discovery vs manual choice - data_schema = vol.Schema( - { - vol.Required("discovery", default="discover"): SelectSelector( - SelectSelectorConfig( - options=["discover", "discover_extended", "manual"], - translation_key="discovery_method", - ) - ) - } - ) - return self.async_show_form(step_id="user", data_schema=data_schema) + method = self._selected_setup_methods[self._current_setup_method_index] + self._current_setup_method_index += 1 - async def async_step_manual_discovery( + match method: + case "cloud": + return await self.async_step_cloud_add() + case "local": + return await self.async_step_local_add() + case _: + return await self._setup_next_setup_method() + + async def async_step_cloud_add( self, user_input: dict | None = None - ) -> config_entries.ConfigFlowResult: - """Handle device discovery.""" + ) -> ConfigFlowResult: + """Gather cloud info for later discovery.""" + errors: dict[str, str] = {} if user_input is not None: - # User selected a discovered device - selected_device = user_input["device"] + self._cloud_api = GreeCloudApi( + region=GreeRegion(user_input[CONF_REGION]), + username=user_input[CONF_EMAIL], + password=user_input[CONF_PASSWORD], + ) + try: + credentials = await self._cloud_api.login() - assert self._discovered_devices + # Also create the transport here so possible errors are shown + self._mqtt_transport = GreeMqttTransport( + user_id=str(credentials.user_id), + token=credentials.token, + region=self._cloud_api.region, + ) + await self._mqtt_transport.connect() - for device in self._discovered_devices: - device_id = device.mac - if device_id == selected_device: - # Check if already configured - await self.async_set_unique_id(format_mac_id(device.mac)) + # Use the user_id as the unique_id since it is more stable than the email + await self.async_set_unique_id(str(self._cloud_api.user_id)) + + # Exit early if there is a config entry with this user_id + if self.source == SOURCE_USER: self._abort_if_unique_id_configured() - # Store selected device for next step - self._discovery_selected_device = device - return await self.async_step_manual_add() + # Ensure reconfigure is of the same user_id + self._abort_if_unique_id_mismatch() + + self._config_data[CONF_CLOUD] = { + **user_input, + CONF_TOKEN: credentials.token, + CONF_UID: credentials.user_id, + } - # If no matching device found, something went wrong - go to manual - return await self.async_step_manual_add() + # If a reauth simply exit and update the entry with new data if necessary + if self.source == SOURCE_REAUTH: + return await self._async_finish() - # Discover devices - self._discovery_performed = True - self._discovered_devices = await self._discover_devices(self.hass) + discovered = await gree_discover_devices_cloud(self._cloud_api) + self._discovered_devices_cloud = {d.mac: d for d in discovered} - if not self._discovered_devices: - # No devices found, go to manual entry - return await self.async_step_manual_add() - - # Create device selection options - device_options = {} - for device in self._discovered_devices: - device_id = device.mac - if device.subdevices > 0: - device_options[device_id] = ( - f"IP: {device.host}, MAC: {device.mac}, Subdevices: {device.subdevices}" + _LOGGER.info( + "Discovered %d devices from the cloud account: %s", + len(self._discovered_devices_cloud), + self._cloud_api.username, ) - else: - device_options[device_id] = f"IP: {device.host}, MAC: {device.mac}" - - data_schema = vol.Schema({vol.Required("device"): vol.In(device_options)}) + return await self._setup_next_setup_method() + + except GreeCloudLoginError: + errors[CONF_BASE] = "cloud_bad_login" + except MqttError: + errors[CONF_BASE] = "cloud_bad_login" + except GreeError: + errors[CONF_BASE] = "cloud_unknown" + + finally: + await self._cloud_api.close() + + # During reconfigure or reauth, inject existing configuration + defaults: dict[str, Any] = user_input or {} + if not user_input: + if self.source == SOURCE_RECONFIGURE: + defaults = self._get_reconfigure_entry().data.get(CONF_CLOUD, {}) + elif self.source == SOURCE_REAUTH: + defaults = self._get_reauth_entry().data.get(CONF_CLOUD, {}) return self.async_show_form( - step_id="manual_discovery", - data_schema=data_schema, - description_placeholders={ - "devices_found": str(len(self._discovered_devices)) - }, + step_id="cloud_add", + data_schema=_setup_cloud_schema(defaults), + errors=errors, ) - async def async_step_discovery_options( - self, user_input: dict[str, Any] | None = None - ) -> config_entries.ConfigFlowResult: - """Collect optional cross-VLAN scan ranges before running discovery.""" + def _evaluate_and_cap_max_hosts( + self, extra_networks: list[str], extra_hosts: list[str] + ) -> dict[str, str]: + errors: dict[str, str] = {} + num_hosts = 0 + for cidr in extra_networks: + try: + net = ip_network(cidr, strict=False) + except ValueError: + errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network" + break + + if not isinstance(net, IPv4Network): + errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network" + break + + # /31 => 2 usable, /32 => 1 usable, otherwise subtract net+broadcast + usable = net.num_addresses if net.prefixlen >= 31 else net.num_addresses - 2 + if usable > MAX_UNICAST_SCAN_HOSTS: + errors[CONF_EXTRA_SCAN_NETWORKS] = "network_too_large" + break + num_hosts += usable + + for ip in extra_hosts: + try: + addr = ip_address(ip) + except ValueError: + errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host" + break + + if not isinstance(addr, IPv4Address): + errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host" + break + num_hosts += 1 + + if num_hosts > MAX_UNICAST_SCAN_HOSTS: + errors[CONF_BASE] = "too_many_targets" + + return errors + + async def async_step_local_add( + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Gather local discovery info for later.""" errors: dict[str, str] = {} - networks_raw = "" - hosts_raw = "" - self.pref_storage = self.pref_storage or Store( + + self._pref_storage = self._pref_storage or Store( self.hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY ) - # BUG: HA persists the old value if a field is empty. Workaround is to send a [space]. if user_input is not None: - networks_raw: str = (user_input.get(CONF_EXTRA_SCAN_NETWORKS, "")).strip() - hosts_raw: str = (user_input.get(CONF_EXTRA_SCAN_HOSTS, "")).strip() + self._extra_networks = user_input.get(CONF_EXTRA_SCAN_NETWORKS, []) + self._extra_hosts = user_input.get(CONF_EXTRA_SCAN_HOSTS, []) - extra_networks: list[str] = ( - [s.strip() for s in networks_raw.split(",") if s.strip()] - if networks_raw - else [] - ) - extra_hosts: list[str] = ( - [s.strip() for s in hosts_raw.split(",") if s.strip()] - if hosts_raw - else [] + errors = self._evaluate_and_cap_max_hosts( + self._extra_networks, self._extra_hosts ) - num_hosts = 0 - for cidr in extra_networks: - try: - net = ip_network(cidr, strict=False) - except ValueError: - errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network" - break - - if not isinstance(net, IPv4Network): - errors[CONF_EXTRA_SCAN_NETWORKS] = "invalid_network" - break - - # /31 => 2 usable, /32 => 1 usable, otherwise subtract net+broadcast - usable = ( - net.num_addresses if net.prefixlen >= 31 else net.num_addresses - 2 - ) - if usable > MAX_UNICAST_SCAN_HOSTS: - errors[CONF_EXTRA_SCAN_NETWORKS] = "network_too_large" - break - num_hosts += usable - - for ip in extra_hosts: - try: - addr = ip_address(ip) - except ValueError: - errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host" - break - - if not isinstance(addr, IPv4Address): - errors[CONF_EXTRA_SCAN_HOSTS] = "invalid_host" - break - num_hosts += 1 - - if num_hosts > MAX_UNICAST_SCAN_HOSTS: - errors["base"] = "too_many_targets" - if not errors: - # Persist last-used values for this HA session - await self.pref_storage.async_save( + # Persist values in the HA storage for future config flows to access + await self._pref_storage.async_save( { - CONF_EXTRA_SCAN_NETWORKS: extra_networks, - CONF_EXTRA_SCAN_HOSTS: extra_hosts, + CONF_EXTRA_SCAN_NETWORKS: self._extra_networks, + CONF_EXTRA_SCAN_HOSTS: self._extra_hosts, } ) + self._config_data["local"] = user_input + + # Discover local devices: main devices and sub-devices of controllers (VRF) + discovered = await gree_discover_devices_local( + broadcast_addresses=await get_discovery_addresses(self.hass), + timeout=DEFAULT_DISCOVERY_TIMEOUT, + user_id=0, + ) + + # if reconfiguring the local-only: Only consider local discovered that are not in other cloud entries + # if reconfiguring cloud with local: Only consider local discovered that are not in other cloud entries + # if adding a local-only: Only consider new local + # if adding a cloud with local: Only consider local that match cloud discovered + + if self.source == SOURCE_RECONFIGURE: + to_ignore = [CONFENTRY_ID_LOCAL_ONLY] + if self.unique_id: + to_ignore.append(self.unique_id) + other_configured = get_configured_macs_in_entries( + self.hass, ignore_entries=to_ignore + ) + else: + other_configured = get_configured_macs_in_entries( + self.hass, + ignore_entries=None + if self.unique_id == CONFENTRY_ID_LOCAL_ONLY + else [CONFENTRY_ID_LOCAL_ONLY], + ) + discovered = [ + dev for dev in discovered if dev.mac not in other_configured + ] + + # Because local discovery always happens after cloud discovery + # Filter out discovered devices that are not in the cloud discovery + if self._discovered_devices_cloud: + discovered = [ + dev + for dev in discovered + if dev.mac in self._discovered_devices_cloud + ] - # self._extra_networks = extra_networks or None - # self._extra_hosts = extra_hosts or None - return await self.async_step_manual_discovery() + self._discovered_devices_local = {d.mac: d for d in discovered} - # Prefill from previous run (if any) or from current submission + _LOGGER.info( + "Discovered %d devices from local discovery", + len(self._discovered_devices_local), + ) + return await self._setup_next_setup_method() - prefs = await self.pref_storage.async_load() or {} - default_networks: str = networks_raw or ", ".join( - prefs.get(CONF_EXTRA_SCAN_NETWORKS, []) + # Pre-fill from previous run (in storage, if any) or from current submission + prefs = await self._pref_storage.async_load() or {} + default_networks: list[str] = self._extra_networks or prefs.get( + CONF_EXTRA_SCAN_NETWORKS, [] ) - default_hosts: str = hosts_raw or ", ".join( - prefs.get(CONF_EXTRA_SCAN_HOSTS, []) + default_hosts: list[str] = self._extra_hosts or prefs.get( + CONF_EXTRA_SCAN_HOSTS, [] ) - # TODO: Use a TextSelector with multiple set to True. Unfortunately, as of now, HA UI has a bug where the focus on the textfield exits at every character - data_schema = vol.Schema( - { - vol.Optional(CONF_EXTRA_SCAN_NETWORKS, default=default_networks): str, - vol.Optional(CONF_EXTRA_SCAN_HOSTS, default=default_hosts): str, - } + return self.async_show_form( + step_id="local_add", + data_schema=_setup_local_schema( + { + CONF_EXTRA_SCAN_NETWORKS: default_networks, + CONF_EXTRA_SCAN_HOSTS: default_hosts, + } + ), + errors=errors, ) + + async def async_step_device_picker( + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Let users pick devices to add, edit or remove.""" + errors: dict[str, str] = {} + + if user_input is not None: + selected: list[str] = user_input.get(CONF_DEVICES, []) + + if selected: + self._selected_devices = [ + self._discovered_devices[key] for key in selected + ] + + return await self.async_step_connection_options() + + errors[CONF_DEVICES] = "no_devices_selected" + + selected = list(self._discovered_devices.keys()) + + # Pre-fill for reconfigure + if self.source == SOURCE_RECONFIGURE: + configured_devices: dict[str, Any] = self._get_reconfigure_entry().data.get( + CONF_DEVICES, {} + ) + + # Don't select things by default that were not there before + selected = [m for m in selected if m in configured_devices] + + # Add back missing devices that weren't discovered but were in config + for mac, dev_conf in configured_devices.items(): + if mac not in self._discovered_devices: + self._discovered_devices[mac] = create_discovered_from_config( + mac, dev_conf + ) + selected.append(mac) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_to_add") + return self.async_show_form( - step_id="discovery_options", - data_schema=data_schema, + step_id="device_picker", + data_schema=_setup_picker_schema(selected, self._discovered_devices), + description_placeholders={ + "devices_found": str(len(self._discovered_devices)) + }, errors=errors, ) - async def async_step_manual_add( - self, user_input: dict | None = None, reconfigure_input: dict | None = None - ) -> config_entries.ConfigFlowResult: - """Handle the manual add of a device.""" - errors = {} + async def async_step_connection_options( # noqa: C901 + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Iterate through the selected devices to configure their connection options.""" + errors: dict[str, str] = {} + d = self._selected_devices[self._current_setup_device_index] if user_input is not None: + local = user_input.get(CONF_DEVICE_CONNECTION_LOCAL, {}) + cloud = user_input.get(CONF_DEVICE_CONNECTION_CLOUD, {}) + device = GreeDevice( + name=d.name, + mac_addr=d.mac, + preferred_encryption_key=user_input.get(CONF_ENCRYPTION_KEY, d.key), + user_id=user_input.get(CONF_UID, d.user_id), + ) + + # Ensure the device can bind try: - _main_device = GreeDevice( - f"Gree Device {user_input[CONF_MAC]}", - user_input[CONF_HOST], - user_input[CONF_MAC], - user_input[CONF_ADVANCED][CONF_PORT], - user_input[CONF_ADVANCED][CONF_ENCRYPTION_KEY], - EncryptionVersion( - int(user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION]) - ) - if user_input[CONF_ADVANCED][CONF_ENCRYPTION_VERSION] - != "Auto-Detect" - else None, - user_input[CONF_ADVANCED][CONF_UID], - max_connection_attempts=2, # Use fewer attempts for testing the device - timeout=2, # Use smaller timeout for testing the device + mac_local_controller = local.get( + CONF_MAC_CONTROLLER_LOCAL, d.mac_controller_local + ) + mac_mqtt_controller = cloud.get( + CONF_MAC_CONTROLLER_CLOUD, d.mac_controller_mqtt + ) + encryption_version_value = local.get( + CONF_ENCRYPTION_VERSION, DEFAULT_ENCRYPTION_VERSION ) - self._main_mac = _main_device.mac_address_controller - await self.async_set_unique_id(format_mac_id(self._main_mac)) - if self._is_reconfigure: - self._abort_if_unique_id_mismatch() - else: - self._abort_if_unique_id_configured() + ip = local.get(CONF_HOST, "") + port = local.get(CONF_PORT, "") - self._devices[_main_device.mac_address] = _main_device - - # self._discovered_subdevices = await get_sub_devices( - # _main_device.mac_address, user_input[CONF_HOST], 0, 2, 2 - # ) - self._discovered_subdevices = await self._devices[ - _main_device.mac_address - ].bind_device() - - self._discovered_subdevices = await self._devices[ - _main_device.mac_address - ].fetch_sub_devices() - - for d in self._discovered_subdevices: - subdev = GreeDevice( - d.name, - user_input[CONF_HOST], - f"{d.mac}@{_main_device.mac_address_controller}", - user_input[CONF_ADVANCED][CONF_PORT], - _main_device.encryption_key, - _main_device.encryption_version, - user_input[CONF_ADVANCED][CONF_UID], - max_connection_attempts=2, # Use fewer attempts for testing the device - timeout=2, # Use smaller timeout for testing the device - ) - self._devices[subdev.mac_address] = subdev + local_transport = self._local_transports.get( + mac_local_controller, GreeUdpTransport(ip_addr=ip, port=port) + ) + + await device.bind_with_transport( + preferred_local_version=( + None + if encryption_version_value == ENCRYPTION_VERSION_AUTO + else EncryptionVersion(int(encryption_version_value)) + ), + local_controller_mac=mac_local_controller, + local_transport=( + local_transport + if not cloud.get(CONF_PREFER_CLOUD, DEFAULT_PREFER_CLOUD) + else None + ), + mqtt_controller_mac=mac_mqtt_controller, + mqtt_transport=self._mqtt_transport, + ) + + if mac_local_controller: + self._local_transports[mac_local_controller] = local_transport + + # Save the correct version if local succeeded + if ( + isinstance(device.transport, GreeUdpTransport) + and device.encryption_version + ): + user_input[CONF_DEVICE_CONNECTION_LOCAL][ + CONF_ENCRYPTION_VERSION + ] = str(device.encryption_version.value) + + user_input[CONF_ENCRYPTION_KEY] = device.encryption_key + self._config_data[CONF_ALL_DEVICE_CONNECTIONS][d.mac] = user_input + + if mac_local_controller: + self._connections_by_controller[mac_local_controller] = user_input + if mac_mqtt_controller: + self._connections_by_controller[mac_mqtt_controller] = user_input + + self._devices[d.mac] = device + + if self._current_setup_device_index >= len(self._selected_devices) - 1: + self._current_setup_device_index = 0 + return await self.async_step_device_options() + + self._current_setup_device_index += 1 + return await self.async_step_connection_options() - await self._devices[_main_device.mac_address].fetch_device_status() except GreeBindingError: - errors["base"] = "cannot_bind" + errors[CONF_BASE] = "cannot_bind" _LOGGER.exception("Error while binding") except GreeConnectionError: - errors["base"] = "cannot_connect" + errors[CONF_BASE] = "cannot_connect" _LOGGER.exception("Cannot connect") + except MqttError: + errors[CONF_BASE] = "cannot_connect_mqtt" + _LOGGER.exception("Cannot connect to MQTT") except Exception: - errors["base"] = "unknown" + errors[CONF_BASE] = "unknown" _LOGGER.exception("Unknown error while binding") - else: - if self._step_main_data: - self._step_main_data.update(user_input) - else: - self._step_main_data = user_input - self._step_main_data[CONF_MAC] = _main_device.mac_address_controller - self._step_main_data[CONF_ADVANCED].update( - { - CONF_ENCRYPTION_VERSION: _main_device.encryption_version, - CONF_ENCRYPTION_KEY: _main_device.encryption_key, - } - ) - return await self.async_step_device_options() - - elif self._discovery_selected_device is not None: - user_input = {} - # user_input[CONF_NAME] = self._selected_device.name - user_input[CONF_HOST] = self._discovery_selected_device.host - user_input[CONF_MAC] = self._discovery_selected_device.mac - user_input[CONF_ADVANCED] = {} - user_input[CONF_ADVANCED][CONF_PORT] = self._discovery_selected_device.port - user_input[CONF_ADVANCED][CONF_UID] = self._discovery_selected_device.uid - elif self._discovery_performed and self._discovery_selected_device is None: - errors["base"] = "no_devices_found" - elif reconfigure_input is not None: - user_input = reconfigure_input - self._step_main_data = reconfigure_input + default = user_input + + # During user setup find if the device is already configured so we can prefill a cloud device with already configured local device + found_device_entry = get_entry_matching_mac(self.hass, d.mac) + if not default and self.source == SOURCE_USER and found_device_entry: + default = ( + found_device_entry.data.get(CONF_DEVICES, {}) + .get(d.mac, {}) + .get(CONF_DEVICE_CONNECTION, None) + ) + + # During reconfigure inject previous connection options + if not default and self.source == SOURCE_RECONFIGURE: + default = ( + self._get_reconfigure_entry() + .data.get(CONF_DEVICES, {}) + .get(d.mac, {}) + .get(CONF_DEVICE_CONNECTION, None) + ) + + defaults = ( + default + or self._connections_by_controller.get(d.mac_controller_local) + or self._connections_by_controller.get(d.mac_controller_mqtt) + ) return self.async_show_form( - step_id="manual_add", - data_schema=build_main_schema(user_input), + step_id="connection_options", + data_schema=_setup_device_connection_options_schema(d, defaults), + description_placeholders={ + "device_name": str(d.friendly_name), + "device_idx": str(self._current_setup_device_index + 1), + "device_cnt": str(len(self._selected_devices)), + "discovered_ip": str(d.host or "None"), + "discovered_mac_local": str(d.mac_controller_local or "None"), + "discovered_mac_cloud": str(d.mac_controller_mqtt or "None"), + }, errors=errors, ) async def async_step_device_options( - self, - user_input: dict | None = None, - index: int | None = None, - ) -> config_entries.ConfigFlowResult: - """Second step: configure features/modes.""" - if ( - user_input is not None - and self._step_main_data is not None - and self._devices[self._main_mac] is not None - ): - await self.async_set_unique_id(format_mac_id(self._main_mac)) - if self._is_reconfigure: - self._abort_if_unique_id_mismatch() - else: - self._abort_if_unique_id_configured() - - # Configuring the main device - # If it has no subdevices, finalyze entry - # Otherwise repeat form while iterating the subdevices - if index is None: - self._device_configs[self._main_mac] = { - # Ignore the subdevice selection item - k: v - for k, v in user_input.items() - if k != CONF_DEVICES - } - - self._selected_subdevices_macs = user_input.get(CONF_DEVICES, []) - # Remove the device configs for the ones not selected so they are removed from the entry - self._device_configs = { - k: v - for k, v in self._device_configs.items() - if k in self._selected_subdevices_macs or k == self._main_mac - } + self, user_input: dict | None = None + ) -> ConfigFlowResult: + """Iterate through the selected devices to configure their options.""" + errors: dict[str, str] = {} + d = self._selected_devices[self._current_setup_device_index] + device = self._devices[d.mac] - if self._selected_subdevices_macs: - return await self.async_step_device_options(None, 0) + if user_input is not None: + self._config_data[CONF_ALL_DEVICE_OPTIONS][d.mac] = user_input + self._options_by_controller[device.mac_address_controller] = user_input - if self._is_reconfigure: - return self._update_entry() - return self._create_final_entry() + if device.device_model_id: + self._options_by_model[device.device_model_id] = user_input - # If configuring a subdevice iterate the chosen subdevices - # If the last subdevice, finalyze the entry - self._device_configs[self._selected_subdevices_macs[index]] = user_input - if index + 1 < len(self._selected_subdevices_macs): - return await self.async_step_device_options(None, index + 1) + if self._current_setup_device_index >= len(self._selected_devices) - 1: + self._current_setup_device_index = 0 + return await self._async_finish() - if self._is_reconfigure: - return self._update_entry() - return self._create_final_entry() + self._current_setup_device_index += 1 + return await self.async_step_device_options() - if self._step_main_data is None: - raise ValueError("No data from main options") + default: dict[str, Any] | None = user_input - if self._devices[self._main_mac] is None: - raise ValueError("No device created in main options step") + # During user setup find if the device is already configured + if not default and self.source == SOURCE_USER: + found_device_entry = get_entry_matching_mac(self.hass, d.mac) + if found_device_entry: + default = ( + found_device_entry.data.get(CONF_DEVICES, {}) + .get(d.mac, {}) + .get(CONF_DEVICE_OPTIONS, None) + ) + elif model_options := self._options_by_model.get(device.device_model_id): + default = { + key: model_options[key] + for key in ( + CONF_HVAC_MODES, + CONF_FAN_MODES, + CONF_SWING_MODES, + CONF_SWING_HORIZONTAL_MODES, + CONF_FEATURES, + ) + if key in model_options + } - device: GreeDevice = self._devices[self._main_mac] + # During reconfigure inject previous device options + if not default and self.source == SOURCE_RECONFIGURE: + default = ( + self._get_reconfigure_entry() + .data.get(CONF_DEVICES, {}) + .get(d.mac, {}) + .get(CONF_DEVICE_OPTIONS, {}) + ) - if index is not None and self._discovered_subdevices: - device = self._devices[self._selected_subdevices_macs[index]] + data_schema = _setup_device_options_schema( + hass=self.hass, + device=device, + default_values=( + default + or self._options_by_controller.get(device.mac_address_controller) + ), + ) - await device.fetch_device_status() + return self.async_show_form( + step_id="device_options", + data_schema=data_schema, + description_placeholders={ + "device_idx": str(self._current_setup_device_index + 1), + "device_cnt": str(len(self._selected_devices)), + "device_name": str(d.friendly_name), + }, + errors=errors, + ) - conf_input = user_input - if self._is_reconfigure: - conf_input = self._get_device_conf(self._step_main_data, device.mac_address) + async def _async_finish(self) -> ConfigFlowResult: # noqa: C901 + """Create or update the entry.""" - schema = build_options_schema(self.hass, device, conf_input) + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_CLOUD: self._config_data.get(CONF_CLOUD, {})}, + ) - # If we are configuring the main device, - # add list of subdevices to include if any - if index is None and self._discovered_subdevices: - subdev_options = {d.mac: d.name for d in self._discovered_subdevices} - selected_options = subdev_options.keys() + device_registry = dr.async_get(self.hass) - # If reconfiguring, only preselect the devices already configured - if self._is_reconfigure: - configured_device_macs = [ - device["mac"] for device in self._step_main_data["devices"] - ] - selected_options = [ - mac for mac in subdev_options if mac in configured_device_macs - ] - schema.extend( - { - vol.Required( - CONF_DEVICES, default=selected_options - ): cv.multi_select(subdev_options) - } - ) + device_configs: dict[str, Any] = {} + for d in self._selected_devices: + mac = str(self._devices[d.mac].mac_address) + device_configs[mac] = { + CONF_DEVICE_CONNECTION: self._config_data[CONF_ALL_DEVICE_CONNECTIONS][ + d.mac + ], + CONF_DEVICE_OPTIONS: self._config_data[CONF_ALL_DEVICE_OPTIONS][d.mac], + } - return self.async_show_form( - step_id="device_options", - data_schema=schema, + local_entry = next( + iter( + get_config_entries(self.hass, match_entries=[CONFENTRY_ID_LOCAL_ONLY]) + ), + None, ) - async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): - """Handle reconfiguration of an existing entry.""" - entry: GreeConfigEntry = self._get_reconfigure_entry() + # Handling migration from Local-only to Cloud entry + if self.unique_id != CONFENTRY_ID_LOCAL_ONLY: + if local_entry: + local_devices = dict(local_entry.data.get(CONF_DEVICES, {})) + moved_any = False + for mac in list(local_devices.keys()): + if mac in self._discovered_devices_cloud: + # Move this device to the new cloud entry + if mac not in device_configs: + device_configs[mac] = local_devices[mac] + local_devices.pop(mac) + moved_any = True + + # remove from registry, it will be added by the new entry + dev = device_registry.async_get_device( + identifiers={(DOMAIN, mac)} + ) + if dev: + device_registry.async_remove_device(dev.id) + + if moved_any: + if local_devices: + new_local_data = { + **local_entry.data, + CONF_DEVICES: local_devices, + } + self.hass.config_entries.async_update_entry( + local_entry, data=new_local_data + ) + self.hass.config_entries.async_schedule_reload( + local_entry.entry_id + ) + else: + # No other devices, remove the entry + await self.hass.config_entries.async_remove( + local_entry.entry_id + ) + + # For adding a local entry which comes only with new local devices despite it + # being possible for the entry to exist already with other devices + # Readd the ones not picked in this flow but are in the local entry already + if ( + self.source == SOURCE_USER + and self.unique_id == CONFENTRY_ID_LOCAL_ONLY + and local_entry + ): + device_configs = { + **local_entry.data.get(CONF_DEVICES, {}), + **device_configs, + } - _LOGGER.debug("Reconfiguring: %s", entry) - await self.async_set_unique_id(entry.unique_id) - self._reconfiguring_entry = entry - self._is_reconfigure = True + data = { + CONF_CLOUD: self._config_data.get(CONF_CLOUD), + CONF_DEVICES: device_configs, + } - return await self.async_step_manual_add( - None, dict(entry.data) if entry.data is not None else None - ) + # Determine update target + update_entry = None + if self.source == SOURCE_RECONFIGURE: + update_entry = self._get_reconfigure_entry() + elif self.unique_id == CONFENTRY_ID_LOCAL_ONLY: + update_entry = next( + iter( + get_config_entries( + self.hass, match_entries=[CONFENTRY_ID_LOCAL_ONLY] + ) + ), + None, + ) - # return self.async_show_form( - # step_id="reconfigure", - # data_schema=build_main_schema( - # entry.data if entry.data is not None else user_input - # ), - # errors=errors, - # ) - - async def _discover_devices( - self, hass: HomeAssistant - ) -> list[GreeDiscoveredDevice]: - """Discover devices in the network.""" - - return await discover_gree_devices( - await get_discovery_addresses(hass), DEFAULT_DISCOVERY_TIMEOUT + title = self._config_data.get(CONF_CLOUD, {}).get( + CONF_EMAIL, "Local-only Devices" ) - def _create_final_entry(self): - """Build final entry data.""" - data: dict = {} - - if self._step_main_data: - data = self._step_main_data.copy() - - # build devices list: main + subdevices - devices = [] - for mac, conf in self._device_configs.items(): - devices.append({**conf, CONF_MAC: mac}) + if update_entry: + # remove devices that are no longer provided by the entry + # they will be re-added if they exist in another entry + previous_configured: dict[str, Any] = update_entry.data.get( + CONF_DEVICES, {} + ) + for m in previous_configured: + if self.source == SOURCE_RECONFIGURE and m not in device_configs: + dev = device_registry.async_get_device(identifiers={(DOMAIN, m)}) + if dev: + device_registry.async_remove_device(dev.id) + + if update_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY and not device_configs: + # No other devices, remove the local-only entry + # If a cloud entry, ignore and keep it so we preserve account data if the user wants + await self.hass.config_entries.async_remove(update_entry.entry_id) + return self.async_abort(reason="reconfigure_successful") - data[CONF_DEVICES] = devices + return self.async_update_reload_and_abort( + update_entry, + title=title, + data_updates=data, + reason="reconfigure_successful", + ) _LOGGER.debug( "New entry with config: %s", async_redact_data(data, ["encryption_key"]), ) return self.async_create_entry( - title=f"Gree System at {data[CONF_HOST]}", data=data - ) - - def _update_entry(self): - """Build final entry data.""" - data: dict = {} - - if self._reconfiguring_entry is None: - raise ValueError("Error updating entry which is not set") - - if self._step_main_data: - data = self._step_main_data.copy() - - # build devices list: main + subdevices - devices = [] - for mac, conf in self._device_configs.items(): - devices.append({**conf, CONF_MAC: mac}) - - data[CONF_DEVICES] = devices - - _LOGGER.debug("Updating entry with config: %s", data) - - return self.async_update_reload_and_abort( - self._reconfiguring_entry, - title=f"Gree System at {data[CONF_HOST]}", + title=title, data=data, ) - - def _get_device_conf(self, config: dict, mac: str) -> dict | None: - configured_devices = config.get(CONF_DEVICES, []) - return next((d for d in configured_devices if d.get(CONF_MAC) == mac), None) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index f23a2e1..3ea6d03 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -1,6 +1,8 @@ """Constants for the Gree integration.""" -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import ( + HVACMode, # pyright: ignore[reportPrivateImportUsage] +) from homeassistant.const import UnitOfTemperature from .aiogree.api import ( @@ -14,12 +16,24 @@ DOMAIN = "gree_custom" +CURRENT_CONF_VERSION = 3 + +CONFENTRY_ID_LOCAL_ONLY = "local_only" CONF_EXTRA_SCAN_NETWORKS = "extra_scan_networks" CONF_EXTRA_SCAN_HOSTS = "extra_scan_hosts" CONF_DISCOVERY_PREFS_KEY = DOMAIN + "_discovery_prefs" CONF_DISCOVERY_PREFS_VERSION = 1 +CONF_CLOUD = "cloud" +CONF_MAC_CONTROLLER_LOCAL = "mac_controller_local" +CONF_MAC_CONTROLLER_CLOUD = "mac_controller_cloud" CONF_ADVANCED = "advanced" +CONF_DEVICE_CONNECTION = "connection" +CONF_DEVICE_CONNECTION_LOCAL = "local" +CONF_DEVICE_CONNECTION_CLOUD = "cloud" +CONF_DEVICE_OPTIONS = "options" +CONF_ALL_DEVICE_CONNECTIONS = "device_connections" +CONF_ALL_DEVICE_OPTIONS = "device_options" CONF_UID = "uid" CONF_ENCRYPTION_KEY = "encryption_key" CONF_ENCRYPTION_VERSION = "encryption_version" @@ -27,20 +41,23 @@ CONF_MAX_ONLINE_ATTEMPTS = "max_online_attempts" CONF_RESTORE_STATES = "restore_states" CONF_DEVICES = "devices" -CONF_DEV_NAME = "device_name" CONF_HVAC_MODES = "hvac_modes" CONF_FAN_MODES = "fan_modes" CONF_SWING_MODES = "swing_modes" CONF_SWING_HORIZONTAL_MODES = "swing_horizontal_modes" CONF_FEATURES = "features" CONF_TEMPERATURE_STEP = "target_temp_step" +CONF_PREFER_CLOUD = "prefer_cloud" DEFAULT_TARGET_TEMP_STEP = 1 -DEFAULT_ENCRYPTION_VERSION = None +ENCRYPTION_VERSION_AUTO = "0" +DEFAULT_ENCRYPTION_VERSION = ENCRYPTION_VERSION_AUTO +DEFAULT_ENCRYPTION_KEY = "" DEFAULT_DISABLE_AVAILABLE_CHECK = False DEFAULT_RESTORE_STATES = True MIN_SCAN_INTERVAL = 5 DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_PREFER_CLOUD = False DEFAULT_DEVICE_UID = 0 DEFAULT_DEVICE_PORT = 7000 @@ -75,9 +92,9 @@ GATTR_FEAT_QUIET_MODE = "quiet" # Turbo mode sets fan speed to the maximum. Fan speed cannot be changed while active and only available in Dry and Cool mode GATTR_FEAT_TURBO = "turbo" -# Humidy Control. Allows dry mode under cooling operation +# Humidity Control. Allows dry mode under cooling operation GATTR_FEAT_HUMIDITY = "humidity_control" -# Humidy Control Target. Sets humidity target for humidity control +# Humidity Control Target. Sets humidity target for humidity control GATTR_FEAT_HUMIDITY_TARGET = "humidity_control_target" GATTR_TEMP_UNITS = "temperature_units" @@ -95,28 +112,46 @@ ATTR_SVC_PROPS = "prop_list" # Map each feature constant to its corresponding GreeProp -CONF_TO_PROP_FEATURE_MAP = { +ATTR_FEATURES_TO_PROP_MAP: dict[str, list[GreeProp]] = { + GATTR_BEEPER: [GreeProp.BEEPER, GreeProp.BEEPER_NEW], + GATTR_FEAT_FRESH_AIR: [GreeProp.FEAT_FRESH_AIR], + GATTR_FEAT_XFAN: [GreeProp.FEAT_XFAN], + GATTR_FEAT_SLEEP_MODE: [ + GreeProp.FEAT_SLEEP_MODE, + GreeProp.FEAT_SLEEP_MODE_TYPE, + ], + GATTR_FEAT_SMART_HEAT_8C: [GreeProp.FEAT_SMART_HEAT_8C], + GATTR_FEAT_LIGHT: [GreeProp.FEAT_LIGHT], + GATTR_FEAT_SENSOR_LIGHT: [GreeProp.FEAT_LIGHT, GreeProp.FEAT_SENSOR_LIGHT], + GATTR_FEAT_HEALTH: [GreeProp.FEAT_HEALTH], + GATTR_ANTI_DIRECT_BLOW: [GreeProp.FEAT_ANTI_DIRECT_BLOW], + GATTR_FEAT_ENERGY_SAVING: [GreeProp.FEAT_ENERGY_SAVING], + GATTR_FEAT_HUMIDITY: [GreeProp.FEATURE_HUMIDITY_CONTROL], +} + +ATTR_SENSORS_TO_PROP_MAP: dict[str, list[GreeProp]] = { + GATTR_INDOOR_TEMPERATURE: [ + GreeProp.SENSOR_INDOOR_TEMPERATURE_1, + GreeProp.SENSOR_INDOOR_TEMPERATURE_2, + GreeProp.SENSOR_INDOOR_TEMPERATURE_3, + ], + GATTR_OUTDOOR_TEMPERATURE: [ + GreeProp.SENSOR_OUTSIDE_TEMPERATURE_1, + GreeProp.SENSOR_OUTSIDE_TEMPERATURE_2, + ], + GATTR_HUMIDITY: [GreeProp.SENSOR_HUMIDITY_1, GreeProp.SENSOR_HUMIDITY_2], + GATTR_FAULTS: [GreeProp.SENSOR_FAULT], +} + +CONF_TO_PROP_FEATURE_MAP: dict[str, list[GreeProp]] = { + GATTR_TEMP_UNITS: [GreeProp.TARGET_TEMPERATURE_UNIT], # SENSORS - GATTR_INDOOR_TEMPERATURE: GreeProp.SENSOR_TEMPERATURE, - GATTR_OUTDOOR_TEMPERATURE: GreeProp.SENSOR_OUTSIDE_TEMPERATURE, - GATTR_HUMIDITY: GreeProp.SENSOR_HUMIDITY, - GATTR_FAULTS: GreeProp.SENSOR_FAULT, - # SELECT - GATTR_FEAT_HUMIDITY: GreeProp.FEATURE_HUMIDITY_CONTROL, - GATTR_TEMP_UNITS: GreeProp.TARGET_TEMPERATURE_UNIT, + **ATTR_SENSORS_TO_PROP_MAP, # FEATURES - GATTR_BEEPER: GreeProp.BEEPER, - GATTR_FEAT_LIGHT: GreeProp.FEAT_LIGHT, - GATTR_FEAT_SENSOR_LIGHT: GreeProp.FEAT_SENSOR_LIGHT, - GATTR_FEAT_FRESH_AIR: GreeProp.FEAT_FRESH_AIR, - GATTR_FEAT_XFAN: GreeProp.FEAT_XFAN, - GATTR_FEAT_SLEEP_MODE: GreeProp.FEAT_SLEEP_MODE, - GATTR_FEAT_SMART_HEAT_8C: GreeProp.FEAT_SMART_HEAT_8C, - GATTR_FEAT_HEALTH: GreeProp.FEAT_HEALTH, - GATTR_ANTI_DIRECT_BLOW: GreeProp.FEAT_ANTI_DIRECT_BLOW, - GATTR_FEAT_ENERGY_SAVING: GreeProp.FEAT_ENERGY_SAVING, + **ATTR_FEATURES_TO_PROP_MAP, } + # HVAC modes - these come from Home Assistant and are standard DEFAULT_HVAC_MODES = [ HVACMode.AUTO, diff --git a/custom_components/gree_custom/coordinator.py b/custom_components/gree_custom/coordinator.py old mode 100644 new mode 100755 index bd5a8a9..b71f320 --- a/custom_components/gree_custom/coordinator.py +++ b/custom_components/gree_custom/coordinator.py @@ -1,24 +1,22 @@ """Data update coordinator for Gree integration.""" +from datetime import timedelta import logging -from typing import Any +from typing import Any, override from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import ( - DataUpdateCoordinator, - UpdateFailed, - timedelta, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .aiogree.api import OperationMode from .aiogree.device import GreeDevice from .aiogree.errors import GreeBindingError, GreeConnectionError from .helpers import try_find_new_ip _LOGGER = logging.getLogger(__name__) -# Home Assistant config entry containing Gree coordinators keyed by normalized MAC addresses ("xxxxxxxxxxxx"). +# Home Assistant config entry where the runtime data are Gree coordinators keyed by normalized MAC addresses ("xxxxxxxxxxxx"). type GreeConfigEntry = ConfigEntry[dict[str, GreeCoordinator]] @@ -29,8 +27,11 @@ def __init__( self, hass: HomeAssistant, config_entry: GreeConfigEntry, - device: GreeDevice, scan_interval: int, + check_availability: bool, + restore_states: bool, + device_config: dict[str, Any], + device: GreeDevice, ) -> None: """Initialize the coordinator for a Gree device.""" super().__init__( @@ -40,22 +41,43 @@ def __init__( config_entry=config_entry, update_interval=timedelta(seconds=scan_interval), always_update=True, + setup_method=self._setup, + update_method=self._update_data, ) + + self.check_availability: bool = check_availability + self.restore_states: bool = restore_states + self.device_config: dict[str, Any] = device_config self.device: GreeDevice = device self._feature_auto_xfan: bool = False self._feature_auto_light: bool = False - async def _async_setup(self): + async def _setup(self) -> None: """Bind to the device before the first coordinator refresh. This is called automatically by `coordinator.async_config_entry_first_refresh()` and performs one-time initialization required before regular updates begin. """ - await self.device.bind_device() + self.device.api_client.add_status_listener(self._device_pushed_status) + # await self.device.bind() + # We shouldn't arrive here without a bind successfully performed elsewhere + + def _device_pushed_status(self, status: dict[str, str]) -> None: + _LOGGER.debug("[%s] Got data pushed from the device", self.device.unique_id) + self.async_update_listeners() + + @override + async def async_shutdown(self) -> None: + """Clean up the coordinator and Gree device resources.""" + self.device.api_client.remove_status_listener(self._device_pushed_status) - async def _async_update_data(self): - """Updates the device with he latest state. + await self.device.unbind_device() + + await super().async_shutdown() + + async def _update_data(self) -> None: + """Update the device with he latest state. If communication fails due to a connection error, the coordinator attempts to discover the device's new IP address and retries the @@ -65,7 +87,9 @@ async def _async_update_data(self): await self.device.fetch_device_status() except GreeConnectionError as err: - if not await try_find_new_ip(self.hass, self.device, self.config_entry): + if not self.config_entry or not await try_find_new_ip( + self.hass, self.device, self.config_entry + ): raise UpdateFailed("Error getting state from device") from err # retry once after IP recovery @@ -82,7 +106,7 @@ async def _async_update_data(self): _LOGGER.exception("Error getting state from device") raise UpdateFailed("Error getting state from device") from err - async def push_device_status(self): + async def push_device_status(self) -> None: """Push the current transient state to the device. If communication fails because the device IP has changed, attempt @@ -91,7 +115,9 @@ async def push_device_status(self): try: await self.device.push_device_status() except GreeConnectionError: - if not await try_find_new_ip(self.hass, self.device, self.config_entry): + if not self.config_entry or not await try_find_new_ip( + self.hass, self.device, self.config_entry + ): raise # propagate original error if recovery fails # retry once after recovering IP @@ -117,14 +143,29 @@ def feature_auto_light(self) -> bool: return self._feature_auto_light def set_feature_auto_light(self, value: bool) -> None: - """Sets the state of the Auto Display Light Feature.""" + """Set the state of the Auto Display Light Feature.""" self._feature_auto_light = value + # Immediately apply Light + desired_light = value if self.device.power_mode else False + if self.device.feature_light != desired_light: + self.device.set_feature_light(desired_light) + self.hass.async_create_task(self._push_status_and_refresh()) + @property def feature_auto_xfan(self) -> bool: """Returns the state of the Auto X-Fan Feature.""" return self._feature_auto_xfan def set_feature_auto_xfan(self, value: bool) -> None: - """Sets the state of the Auto X-Fan Feature.""" + """Set the state of the Auto X-Fan Feature.""" self._feature_auto_xfan = value + + # Immediately apply X-Fan + if self.device.operation_mode == OperationMode.cool: + self.device.set_feature_xfan(value) + self.hass.async_create_task(self._push_status_and_refresh()) + + async def _push_status_and_refresh(self) -> None: + await self.push_device_status() + await self.async_request_refresh() diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py old mode 100644 new mode 100755 index 2af4071..143ae5d --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -3,6 +3,9 @@ import logging from typing import Any +from config.custom_components.gree_custom.const import CONF_ENCRYPTION_KEY +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -25,12 +28,13 @@ async def async_get_config_entry_diagnostics( data[i] = c.get_coordinator_diagnostics() diagnostics = {"entry_data": dict(entry.data.copy()), "data": data} - redacted = diagnostics - redacted["entry_data"]["advanced"] = diagnostics["entry_data"]["advanced"].copy() - redacted["entry_data"]["advanced"]["encryption_key"] = ( - diagnostics["entry_data"]["advanced"]["encryption_key"][:5] + "[redacted]" - ) - return redacted + # redacted = diagnostics + # redacted["entry_data"]["advanced"] = diagnostics["entry_data"]["advanced"].copy() + # redacted["entry_data"]["advanced"]["encryption_key"] = redact_str( + # diagnostics["entry_data"]["advanced"]["encryption_key"] + # ) + + return async_redact_data(diagnostics, [CONF_ENCRYPTION_KEY, CONF_PASSWORD]) async def async_get_device_diagnostics( @@ -47,6 +51,9 @@ async def async_get_device_diagnostics( mac = identifier break + if not mac: + raise RuntimeError(f"No MAC found for the device: {device.identifiers}") + coordinator: GreeCoordinator | None = entry.runtime_data.get(mac, None) return { diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index aa8d6b5..c1698d6 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -1,9 +1,10 @@ """Base entity for Gree integration.""" from collections.abc import Callable +from typing import override -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .aiogree.device import GreeDevice @@ -33,11 +34,13 @@ def __init__( self.check_availability = check_availability @property + @override def unique_id(self) -> str | None: """Returns a unique id for the entity.""" return f"{self.device.mac_address}_{self.entity_description.key}" @property + @override def device_info(self) -> DeviceInfo: """Return the device info.""" if self.device.mac_address != self.device.mac_address_controller: @@ -47,7 +50,7 @@ def device_info(self) -> DeviceInfo: name=self.device.name, manufacturer="Gree", sw_version=self.device.firmware_version, - via_device=(DOMAIN, self.device.mac_address_controller), + # via_device=(DOMAIN, self.device.mac_address_controller), ) return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)}, @@ -55,10 +58,13 @@ def device_info(self) -> DeviceInfo: name=self.device.name, manufacturer="Gree", sw_version=self.device.firmware_version, + hw_version=self.device.firmware_code, + model_id=self.device.device_model_id, ) @property - def available(self): # pyright: ignore[reportIncompatibleVariableOverride] + @override + def available(self) -> bool: """Return True if entity is available. If entity has 'check_availability' enabled this uses the device available state diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py old mode 100644 new mode 100755 index bde4cb2..f77ce58 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -1,22 +1,37 @@ """Helpers for the Gree integration.""" +from collections.abc import Mapping from ipaddress import IPv4Address, IPv4Network, ip_address, ip_network import logging +from typing import Any from homeassistant.components import network from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.storage import Store -from .aiogree.api import GreeDiscoveredDevice, discover_gree_devices +from .aiogree.api import GreeDiscoveredDevice, gree_discover_devices from .aiogree.device import GreeDevice +from .aiogree.transport_udp import GreeUdpTransport from .const import ( + CONF_DEVICE_CONNECTION, + CONF_DEVICE_CONNECTION_CLOUD, + CONF_DEVICE_CONNECTION_LOCAL, + CONF_DEVICES, CONF_DISCOVERY_PREFS_KEY, CONF_DISCOVERY_PREFS_VERSION, + CONF_ENCRYPTION_KEY, CONF_EXTRA_SCAN_HOSTS, CONF_EXTRA_SCAN_NETWORKS, + CONF_MAC_CONTROLLER_CLOUD, + CONF_MAC_CONTROLLER_LOCAL, + CONF_UID, + CURRENT_CONF_VERSION, + DEFAULT_DEVICE_UID, DEFAULT_DISCOVERY_TIMEOUT, + DOMAIN, MAX_UNICAST_SCAN_HOSTS, ) @@ -24,14 +39,14 @@ async def _get_hass_broadcast_addr(hass: HomeAssistant) -> list[str]: - """Returns the broadcast adresses from HA.""" + """Return the broadcast addresses from HA.""" broadcast_addresses: list[str] = [] try: # This returns every broadcast address for every enabled network adapter in HA # If only the default adapter is enabled, HA only returns 255.255.255.255 ha_broadcast_addresses: set[ - network.IPv4Address + IPv4Address ] = await network.async_get_ipv4_broadcast_addresses(hass) ha_broadcast_strings: list[str] = [str(addr) for addr in ha_broadcast_addresses] @@ -116,7 +131,9 @@ async def get_discovery_addresses( addresses.extend(broadcast_addresses) # Collect unicast addresses from HASS prefs - pref_storage = Store(hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY) + pref_storage: Store = Store( + hass, CONF_DISCOVERY_PREFS_VERSION, CONF_DISCOVERY_PREFS_KEY + ) prefs = await pref_storage.async_load() or {} extra_networks: list[str] = prefs.get(CONF_EXTRA_SCAN_NETWORKS, []) @@ -132,18 +149,24 @@ async def try_find_new_ip( device: GreeDevice, config_entry: ConfigEntry, ) -> bool: - """This will try find the IP of this device controller MAC address and update it.""" + """Try find the IP of this device controller MAC address and update it.""" _LOGGER.debug( "Trying to find a new IP address for %s", device.mac_address_controller ) - previous_ip = device.ip + if not device.transport or not isinstance(device.transport, GreeUdpTransport): + _LOGGER.error("Can't find the IP of a device that is not local") + return False + + previous_ip = device.transport.ip_addr # Perform device discovery discovery_addresses = await get_discovery_addresses(hass) - discovered_devices: list[GreeDiscoveredDevice] = await discover_gree_devices( - discovery_addresses, DEFAULT_DISCOVERY_TIMEOUT + discovered_devices: list[GreeDiscoveredDevice] = await gree_discover_devices( + cloud_api=None, + broadcast_addresses=discovery_addresses, + timeout=DEFAULT_DISCOVERY_TIMEOUT, ) # Search for a match device @@ -152,7 +175,7 @@ async def try_find_new_ip( None, ) - if not match_device: + if not match_device or not match_device.host: _LOGGER.debug( "No device with mac '%s' found in the discovered devices", device.mac_address_controller, @@ -167,20 +190,149 @@ async def try_find_new_ip( return False # Update the device IP - device.set_ip(match_device.host) - - # Update config entry to save the new IP - new_data = {**config_entry.data, CONF_HOST: device.ip} - if not hass.config_entries.async_update_entry( - config_entry, title=f"Gree System at {device.ip}", data=new_data - ): - _LOGGER.debug("Failed to save new IP in config entry data") + # await device.unbind_device() + await device.transport.set_ip(match_device.host) _LOGGER.info( "IP for device with mac '%s' updated: %s -> %s", device.mac_address_controller, previous_ip, - device.ip, + device.transport.ip_addr, ) + # Update config entry to save the new IP + try: + new_data = config_entry.data + new_data[CONF_DEVICES][device.mac_address][CONF_DEVICE_CONNECTION][ + CONF_DEVICE_CONNECTION_LOCAL + ][CONF_HOST] = device.transport.ip_addr + + if not hass.config_entries.async_update_entry(config_entry, data=new_data): + _LOGGER.debug("Failed to save new IP in config entry data") + else: + _LOGGER.debug("Config entry updated with new IP") + except KeyError: + _LOGGER.exception("Config entry data does not contain the required keys") + return True + + +def create_discovered_from_config(mac: str, conf: dict) -> GreeDiscoveredDevice: + """Return a GreeDiscoveredDevice based on config data.""" + conn = conf.get(CONF_DEVICE_CONNECTION, {}) + local = conn.get(CONF_DEVICE_CONNECTION_LOCAL, {}) + cloud = conn.get(CONF_DEVICE_CONNECTION_CLOUD, {}) + return GreeDiscoveredDevice( + mac=mac, + mac_controller_local=local.get(CONF_MAC_CONTROLLER_LOCAL, ""), + mac_controller_mqtt=cloud.get(CONF_MAC_CONTROLLER_CLOUD, ""), + user_id=conn.get(CONF_UID, DEFAULT_DEVICE_UID), + key=conn.get(CONF_ENCRYPTION_KEY, ""), + host=local.get(CONF_HOST, ""), + port=local.get(CONF_PORT, ""), + ) + + +def get_entity_ids_from_unique_ids( + hass: HomeAssistant, + entity_domain: str, + unique_ids: list[str], +) -> list[str]: + """Resolve a list of unique_ids to their entity_ids in the given domain/platform. + + Entities that aren't registered yet (unique_id not found) are skipped. + """ + ent_reg = er.async_get(hass) + + entity_ids = [ + ent_reg.async_get_entity_id(entity_domain, DOMAIN, unique_id) + for unique_id in unique_ids + ] + + return [entity_id for entity_id in entity_ids if entity_id is not None] + + +def get_config_entries( + hass: HomeAssistant, + ignore_entries: list[str] | None = None, + match_entries: list[str] | None = None, +) -> list[ConfigEntry]: + """Get this integration config entries with filters.""" + entries: list[ConfigEntry] = hass.config_entries.async_entries(DOMAIN) + + return [ + entry + for entry in entries + if entry.version >= CURRENT_CONF_VERSION + and (ignore_entries is None or entry.unique_id not in ignore_entries) + and (match_entries is None or entry.unique_id in match_entries) + ] + + +def get_entry_matching_mac(hass: HomeAssistant, target_mac: str) -> ConfigEntry | None: + """Get a config entry that has the target_mac as a configured device.""" + entries: list[ConfigEntry] = get_config_entries(hass) + + matches: list[ConfigEntry] = [ + e for e in entries if target_mac in e.data.get(CONF_DEVICES, {}) + ] + + if len(matches) > 1: + _LOGGER.error("A device must exist in only one entry") + elif len(matches) == 1: + return matches[0] + + return None + + +def get_configured_macs_in_entries( + hass: HomeAssistant, + ignore_entries: list[str] | None = None, + match_entries: list[str] | None = None, +) -> Mapping[str, ConfigEntry]: + """Get configured device macs and their respective config entry.""" + entries: list[ConfigEntry] = get_config_entries( + hass, ignore_entries=ignore_entries, match_entries=match_entries + ) + + configured_macs: dict[str, ConfigEntry] = {} + + for e in entries: + conf_devices: dict[str, Any] = e.data.get(CONF_DEVICES, {}) + for mac in conf_devices: + configured_macs[mac] = e + + return configured_macs + + +def get_subdevices_mac_matching_controller( + hass: HomeAssistant, controller_mac: str +) -> tuple[ConfigEntry, set[str]] | None: + """Get sub-device macs that match a given controller and their respective config entry.""" + matched_entry: ConfigEntry | None = None + matched_devices: set[str] = set() + + for entry in get_config_entries(hass): + conf_devices: dict[str, Any] = entry.data.get(CONF_DEVICES, {}) + + for mac, device in conf_devices.items(): + controller_local = ( + device.get(CONF_DEVICE_CONNECTION, {}) + .get(CONF_DEVICE_CONNECTION_LOCAL, {}) + .get(CONF_MAC_CONTROLLER_LOCAL) + ) + + if controller_mac != controller_local: + continue + + if matched_entry is not None and matched_entry is not entry: + _LOGGER.error("A device must exist in only one entry") + return None + + matched_entry = entry + matched_devices.add(mac) + + if matched_entry is None: + return None + + return matched_entry, matched_devices diff --git a/custom_components/gree_custom/manifest.json b/custom_components/gree_custom/manifest.json index 396c81f..c51a875 100755 --- a/custom_components/gree_custom/manifest.json +++ b/custom_components/gree_custom/manifest.json @@ -1,13 +1,36 @@ { - "domain": "gree_custom", - "name": "Gree A/C", - "codeowners": ["@robhofmann"], - "config_flow": true, - "dependencies": ["network"], - "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", - "integration_type": "hub", - "iot_class": "local_polling", - "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues", - "requirements": ["pycryptodome", "asyncio_dgram"], - "version": "4.0.0-alpha.103" + "domain": "gree_custom", + "name": "Gree A/C", + "codeowners": ["@robhofmann"], + "config_flow": true, + "dependencies": ["network"], + "dhcp": [ + { + "registered_devices": true + }, + { + "macaddress": "0447CA*" + }, + { + "macaddress": "502CC6*" + }, + { + "macaddress": "580D0D*" + }, + { + "macaddress": "7CB8E6*" + }, + { + "macaddress": "9424B8*" + }, + { + "macaddress": "C03937*" + } + ], + "documentation": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent", + "integration_type": "hub", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/RobHofmann/HomeAssistant-GreeClimateComponent/issues", + "requirements": ["asyncio_dgram", "aiomqtt", "pydantic"], + "version": "4.0.0-alpha.105" } diff --git a/custom_components/gree_custom/number.py b/custom_components/gree_custom/number.py old mode 100644 new mode 100755 index 59a5d3d..533950a --- a/custom_components/gree_custom/number.py +++ b/custom_components/gree_custom/number.py @@ -2,11 +2,13 @@ from collections.abc import Callable import logging +from typing import override from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, NumberEntityDescription, + NumberMode, ) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant @@ -19,7 +21,7 @@ from .const import GATTR_FEAT_HUMIDITY, GATTR_FEAT_HUMIDITY_TARGET from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import iter_platform_context, supported_descriptions +from .platform_helpers import supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -42,7 +44,7 @@ class GreeNumberDescription( key=GATTR_FEAT_HUMIDITY_TARGET, translation_key=GATTR_FEAT_HUMIDITY_TARGET, device_class=NumberDeviceClass.HUMIDITY, - mode="auto", + mode=NumberMode.AUTO, native_step=5, native_unit_of_measurement=PERCENTAGE, value_func=lambda device: device.feature_humidity_control_target, @@ -79,22 +81,25 @@ async def async_setup_entry( entities: list[GreeNumber] = [] - for ctx in iter_platform_context(entry): + for coordinator in entry.runtime_data.values(): descriptions = supported_descriptions( NUMBER_TYPES, - ctx.coordinator.device, - ctx.device_config, + coordinator.device, + coordinator.device_config, ) _LOGGER.debug( "Adding Number Entities for device '%s': %s", - ctx.coordinator.device.mac_address, + coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( GreeNumber( - description, ctx.coordinator, ctx.restore_state, ctx.check_availability + description, + coordinator, + coordinator.restore_states, + coordinator.check_availability, ) for description in descriptions ) @@ -125,33 +130,37 @@ def __init__( ) @property + @override def native_min_value(self) -> float: """Return the minimum allowed value.""" if self.entity_description.min_func is not None: return self.entity_description.min_func(self.device) - return self.entity_description.native_min_value + return super().native_min_value @property + @override def native_max_value(self) -> float: """Return the maximum allowed value.""" if self.entity_description.max_func is not None: return self.entity_description.max_func(self.device) - return self.entity_description.native_max_value + return super().native_max_value @property - def native_value(self) -> int: # pyright: ignore[reportIncompatibleVariableOverride] + @override + def native_value(self) -> float: """Return the state of the sensor.""" return self.entity_description.value_func(self.device) - async def async_set_native_value(self, value: int) -> None: + @override + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" if not self.available: raise HomeAssistantError("Entity unavailable") try: - self.entity_description.set_func(self.device, value) + self.entity_description.set_func(self.device, int(value)) if self.entity_description.updates_device: await self.coordinator.push_device_status() diff --git a/custom_components/gree_custom/platform_helpers.py b/custom_components/gree_custom/platform_helpers.py old mode 100644 new mode 100755 index 771c9c0..fe4617e --- a/custom_components/gree_custom/platform_helpers.py +++ b/custom_components/gree_custom/platform_helpers.py @@ -1,26 +1,17 @@ """Helpers for the Gree integration.""" -from collections.abc import Iterator, Sequence -from dataclasses import dataclass +from collections.abc import Sequence import logging -from typing import TypeVar - -from homeassistant.const import CONF_MAC -from homeassistant.helpers.entity_platform import Any +from typing import Any, TypeVar +from .aiogree.api import GreeProp from .aiogree.device import GreeDevice from .const import ( - CONF_ADVANCED, - CONF_DEVICES, - CONF_DISABLE_AVAILABLE_CHECK, + CONF_DEVICE_OPTIONS, CONF_FEATURES, - CONF_RESTORE_STATES, CONF_TO_PROP_FEATURE_MAP, - DEFAULT_DISABLE_AVAILABLE_CHECK, - DEFAULT_RESTORE_STATES, DEFAULT_SUPPORTED_FEATURES, ) -from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntityDescription _LOGGER = logging.getLogger(__name__) @@ -29,48 +20,6 @@ T = TypeVar("T", bound=GreeEntityDescription) -@dataclass(slots=True) -class GreePlatformContext: - """Provides the context for platform entity creation.""" - - device_config: dict[str, Any] - coordinator: GreeCoordinator - restore_state: bool - check_availability: bool - - -def iter_platform_context( - entry: GreeConfigEntry, -) -> Iterator[GreePlatformContext]: - """Yield context for every configured device.""" - - check_availability = not entry.data[CONF_ADVANCED].get( - CONF_DISABLE_AVAILABLE_CHECK, - DEFAULT_DISABLE_AVAILABLE_CHECK, - ) - - for device_config in entry.data.get(CONF_DEVICES, []): - mac = device_config.get(CONF_MAC, "") - - coordinator = entry.runtime_data.get(mac) - if coordinator is None: - _LOGGER.error( - "No coordinator found for device '%s'", - mac, - ) - continue - - yield GreePlatformContext( - device_config=device_config, - coordinator=coordinator, - restore_state=device_config.get( - CONF_RESTORE_STATES, - DEFAULT_RESTORE_STATES, - ), - check_availability=check_availability, - ) - - def supported_descriptions( descriptions: Sequence[T], device: GreeDevice, @@ -82,9 +31,12 @@ def supported_descriptions( descriptions: `GreeEntityDescription` list of entity descriptions. device: The device to check for property support, device_config: Device configuration. If omitted, all ``descriptions`` are used. + """ - configured_features: list[str] = ( - set(device_config.get(CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES)) + configured_features: list[str] | None = ( + device_config.get(CONF_DEVICE_OPTIONS, {}).get( + CONF_FEATURES, DEFAULT_SUPPORTED_FEATURES + ) if device_config is not None else None ) @@ -101,14 +53,14 @@ def supported_descriptions( ): continue - prop = CONF_TO_PROP_FEATURE_MAP.get(feature) - if prop and device.supports_property(prop): + props: list[GreeProp] = CONF_TO_PROP_FEATURE_MAP.get(feature, []) + if props and any(device.supports_property(p) for p in props): supported.append(description) return supported -def entity_feature_key(entity_description: T) -> str: - """Returns the correct feature key for an entity description.""" +def entity_feature_key(entity_description: GreeEntityDescription) -> str: + """Return the correct feature key for an entity description.""" # This is needed because the description dataclasses don't allow methods/properties return entity_description.feature_key_override or entity_description.key diff --git a/custom_components/gree_custom/select.py b/custom_components/gree_custom/select.py old mode 100644 new mode 100755 index df57553..3fea4c7 --- a/custom_components/gree_custom/select.py +++ b/custom_components/gree_custom/select.py @@ -2,6 +2,7 @@ from collections.abc import Callable import logging +from typing import override from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory @@ -20,7 +21,7 @@ from .const import DOMAIN, GATTR_FEAT_HUMIDITY, GATTR_TEMP_UNITS from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import iter_platform_context, supported_descriptions +from .platform_helpers import supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -73,7 +74,7 @@ def _set_humidity_control_mode(device: GreeDevice, mode: str) -> None: key=GATTR_FEAT_HUMIDITY, translation_key=GATTR_FEAT_HUMIDITY, options=[member.name for member in HumidityControlMode], - value_func=lambda device: device.feature_humidity_control, + value_func=lambda device: str(device.feature_humidity_control), set_func=_set_humidity_control_mode, additional_available_func=lambda device: ( device.operation_mode in (OperationMode.cool, OperationMode.dry) @@ -94,21 +95,21 @@ async def async_setup_entry( entities: list[GreeSelect] = [] - for ctx in iter_platform_context(entry): + for coordinator in entry.runtime_data.values(): descriptions = supported_descriptions( SELECT_TYPES, - ctx.coordinator.device, - ctx.device_config, + coordinator.device, + coordinator.device_config, ) _LOGGER.debug( "Adding Select Entities for device '%s': %s", - ctx.coordinator.device.mac_address, + coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( - GreeSelect(description, ctx.coordinator, False, ctx.check_availability) + GreeSelect(description, coordinator, False, coordinator.check_availability) for description in descriptions ) @@ -147,10 +148,12 @@ def __init__( ) @property - def current_option(self) -> str | None: # pyright: ignore[reportIncompatibleVariableOverride] + @override + def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self.entity_description.value_func(self.device) + @override async def async_select_option(self, option: str) -> None: """Change the selected option.""" _LOGGER.debug( @@ -185,7 +188,8 @@ async def async_select_option(self, option: str) -> None: self.async_write_ha_state() - async def async_added_to_hass(self): + @override + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() diff --git a/custom_components/gree_custom/sensor.py b/custom_components/gree_custom/sensor.py old mode 100644 new mode 100755 index e5ecd12..bb69898 --- a/custom_components/gree_custom/sensor.py +++ b/custom_components/gree_custom/sensor.py @@ -1,7 +1,10 @@ """Gree Sensor Entity for Home Assistant.""" from collections.abc import Callable +from datetime import date, datetime +from decimal import Decimal import logging +from typing import override from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,12 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from .aiogree.device import GreeDevice from .const import GATTR_HUMIDITY, GATTR_INDOOR_TEMPERATURE, GATTR_OUTDOOR_TEMPERATURE from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import iter_platform_context, supported_descriptions +from .platform_helpers import supported_descriptions _LOGGER = logging.getLogger(__name__) @@ -76,20 +80,18 @@ async def async_setup_entry( entities: list[GreeSensor] = [] - for ctx in iter_platform_context(entry): + for coordinator in entry.runtime_data.values(): # Sensors are checked directly, not on the entry config - descriptions = supported_descriptions( - SENSOR_TYPES, ctx.coordinator.device, None - ) + descriptions = supported_descriptions(SENSOR_TYPES, coordinator.device, None) _LOGGER.debug( "Adding Sensor Entities for device '%s': %s", - ctx.coordinator.device.mac_address, + coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( - GreeSensor(description, ctx.coordinator, False, ctx.check_availability) + GreeSensor(description, coordinator, False, coordinator.check_availability) for description in descriptions ) @@ -119,11 +121,13 @@ def __init__( ) @property - def native_value(self): # pyright: ignore[reportIncompatibleVariableOverride] + @override + def native_value(self) -> StateType | date | datetime | Decimal: """Return the state of the sensor.""" return self.entity_description.value_func(self.device) - async def async_added_to_hass(self): + @override + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() # Restore last HA state to device if applicable diff --git a/custom_components/gree_custom/services.py b/custom_components/gree_custom/services.py old mode 100644 new mode 100755 diff --git a/custom_components/gree_custom/services.yaml b/custom_components/gree_custom/services.yaml old mode 100644 new mode 100755 diff --git a/custom_components/gree_custom/switch.py b/custom_components/gree_custom/switch.py old mode 100644 new mode 100755 index f9db1e2..052a966 --- a/custom_components/gree_custom/switch.py +++ b/custom_components/gree_custom/switch.py @@ -2,7 +2,7 @@ from collections.abc import Callable import logging -from typing import Any +from typing import Any, override from homeassistant.components.switch import ( SwitchDeviceClass, @@ -15,11 +15,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .aiogree.api import OperationMode, SleepMode +from .aiogree.api import HumidityControlMode, OperationMode, SleepMode from .aiogree.device import GreeDevice +from .aiogree.errors import GreeContinuousDryUnavailable from .const import ( ATTR_AUTO_LIGHT, ATTR_AUTO_XFAN, + DOMAIN, GATTR_ANTI_DIRECT_BLOW, GATTR_BEEPER, GATTR_FEAT_ENERGY_SAVING, @@ -33,11 +35,27 @@ ) from .coordinator import GreeConfigEntry, GreeCoordinator from .entity import GreeEntity, GreeEntityDescription -from .platform_helpers import iter_platform_context, supported_descriptions +from .platform_helpers import supported_descriptions _LOGGER = logging.getLogger(__name__) +def _set_humidity_control_continuous( + device: GreeDevice, coordinator: GreeCoordinator, state: bool +) -> None: + try: + device.set_feature_humidity_control( + HumidityControlMode.continuous_dry + if state + else HumidityControlMode.disabled + ) + + except GreeContinuousDryUnavailable as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="continuous_dry_unavailable" + ) from err + + class GreeSwitchDescription( GreeEntityDescription, SwitchEntityDescription, frozen_or_thawed=True ): @@ -165,25 +183,25 @@ async def async_setup_entry( entities: list[GreeSwitch] = [] - for ctx in iter_platform_context(entry): + for coordinator in entry.runtime_data.values(): descriptions = supported_descriptions( SWITCH_TYPES, - ctx.coordinator.device, - ctx.device_config, + coordinator.device, + coordinator.device_config, ) _LOGGER.debug( "Adding Switch Entities for device '%s': %s", - ctx.coordinator.device.mac_address, + coordinator.device.mac_address, [d.key for d in descriptions], ) entities.extend( GreeSwitch( description, - ctx.coordinator, + coordinator, restore_state=( - ctx.restore_state + coordinator.restore_states if description.key not in ( GATTR_BEEPER, @@ -193,7 +211,7 @@ async def async_setup_entry( else True ), check_availability=( - ctx.check_availability + coordinator.check_availability if description.key != GATTR_BEEPER # Beeper is always available else False ), @@ -227,11 +245,13 @@ def __init__( ) @property - def is_on(self) -> bool | None: # pyright: ignore[reportIncompatibleVariableOverride] + @override + def is_on(self) -> bool | None: """Return true if the switch is on.""" return self.entity_description.value_func(self.device, self.coordinator) - async def async_added_to_hass(self): + @override + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() # Restore last HA state to device if applicable @@ -259,6 +279,7 @@ async def async_added_to_hass(self): repr(err), ) + @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if not self.available: @@ -282,6 +303,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.async_write_ha_state() + @override async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch on.""" if not self.available: diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json old mode 100755 new mode 100644 index 590fb21..d791d87 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -1,376 +1,389 @@ { - "config": { - "error": { - "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", - "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", - "cannot_bind": "Unable to bind the device. It was not possible to find the device encryption version or key. If the issue persists, please check the logs.", - "no_devices_found": "Couldn't find any Gree device in the network. Please add your device manually.", - "invalid_network": "Invalid CIDR. Example: 192.168.30.0/24", - "invalid_host": "Invalid IP address. Example: 192.168.30.50", - "network_too_large": "Network exceeds the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts.", - "too_many_targets": "The specified networks and hosts exceed the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts." - }, - "abort": { - "already_configured": "A device with this MAC address is already configured." - }, - "step": { - "user": { - "title": "Gree Climate Setup", - "description": "Choose how to add your Gree air conditioner", - "data": { - "discovery": "Setup Method" - } - }, - "manual_discovery": { - "title": "Discovered Devices", - "description": "Found {devices_found} Gree device(s). Select one to add or choose manual setup.", - "data": { - "device": "Device" - } - }, - "discovery_options": { - "title": "Extended Discovery", - "description": "Devices on a different subnet or VLAN? Enter one or more networks and/or specific IP addresses to probe via unicast. Inter-VLAN routing and firewall rules must allow UDP port 7000 from Home Assistant to the target subnet.", - "data": { - "extra_scan_networks": "Networks (comma-separated CIDRs, e.g. 192.168.20.0/24,192.168.30.0/24)", - "extra_scan_hosts": "Hosts (comma-separated IPs, e.g. 192.168.30.50,192.168.30.51)" - } - }, - "manual_add": { - "title": "Device configuration", - "data": { - "host": "IP Address", - "mac": "MAC Address" + "config": { + "error": { + "unknown": "Something went wrong, please try again. If the issue persists, please check the logs.", + "cannot_connect": "Unable to connect to the device. Please check the device configuration and network connection and try again. If the issue persists, please check the logs.", + "cannot_bind": "Unable to bind the device. It was not possible to find the device encryption version or key. If the issue persists, please check the logs.", + "invalid_network": "Invalid CIDR. Example: 192.168.30.0/24", + "invalid_host": "Invalid IP address. Example: 192.168.30.50", + "network_too_large": "Network exceeds the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts.", + "too_many_targets": "The specified networks and hosts exceed the maximum of 65536 hosts (a /16). Split into multiple CIDRs or list specific hosts.", + "cloud_bad_login": "Login failed. Ensure the details are correct and try again.", + "cloud_unknown": "Error during login. Please check the logs and contact the developers", + "no_devices_selected": "Please select at least one device to continue", + "no_methods_selected": "Please select at least one discovery method" + }, + "abort": { + "reconfigure_successful": "Devices reconfigured with success.", + "already_configured": "A device with this MAC address is already configured.", + "unique_id_mismatch": "The entry being configured does not match the configuration intent.", + "no_devices_to_add": "No new devices discovered. Adjust your discovery options and try again." }, - "sections": { - "advanced": { - "name": "Advanced Settings", - "description": "Configure advanced setting of the device", - "data": { - "port": "Port", - "encryption_key": "Encryption Key", - "encryption_version": "Encryption Version", - "uid": "UID", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Connection Attempts", - "timeout": "Connection Timeout" + "step": { + "user": { + "title": "Gree Climate Setup", + "description": "Choose how to discover your Gree device", + "data": { + "discovery": "Discovery Method" + } + }, + "cloud_add": { + "title": "Gree Account", + "description": "Enter the details to connect to your Gree account", + "data": { + "email": "Email", + "password": "Password", + "region": "Region" + } + }, + "local_add": { + "title": "Local Discovery", + "description": "Devices will be automatically discovered by scanning the available networks.\n\nIf you have devices on a different subnet or VLAN, enter one or more networks and/or specific IP addresses to probe via unicast. Inter-VLAN routing and firewall rules must allow UDP port 7000 from Home Assistant to the target subnet.", + "data": { + "extra_scan_hosts": "Extra Hosts", + "extra_scan_networks": "Extra Networks" + }, + "data_description": { + "extra_scan_hosts": "(IPs, e.g. 192.168.30.50, 192.168.30.51)", + "extra_scan_networks": "(CIDRs, e.g. 192.168.20.0/24, 192.168.30.0/24)" + } + }, + "device_picker": { + "title": "Discovered Devices", + "description": "Found {devices_found} Gree device(s). Select the devices you want to configure.\n\nCaution: If you are reconfiguring devices, unselected devices will be removed.", + "data": { + "devices": "Devices" + } + }, + "connection_options": { + "title": "Connection Options {device_idx} of {device_cnt}", + "description": "Configure the connection options that will use for the device:\n\n{device_name}", + "data": { + "disable_available_check": "Disable Available Check", + "encryption_key": "Encryption Key", + "scan_interval": "Scan Interval", + "uid": "User ID" + }, + "data_description": { + "encryption_key": "If left empty will be automatically discovered", + "scan_interval": "Frequency of the device data polling (in seconds)", + "uid": "The user ID number that owns the device. Can be 0 if not known" + }, + "sections": { + "local": { + "name": "Local Connection Settings", + "data": { + "encryption_version": "Encryption Version", + "host": "IP Address (discovered: {discovered_ip})", + "mac_controller_local": "MAC of the local controller device (discovered: {discovered_mac_local})", + "max_online_attempts": "Max Connection Attempts", + "port": "Port", + "timeout": "Connection Timeout" + }, + "data_description": { + "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", + "timeout": "The timeout for each of the connection attempts" + } + }, + "cloud": { + "name": "Cloud Connection Settings", + "data": { + "mac_controller_cloud": "MAC of the cloud controller device (discovered: {discovered_mac_cloud})", + "prefer_cloud": "Prefer Cloud Connection" + }, + "data_description": { + "prefer_cloud": "Devices prefer local connections. Check if you prefer this device to connect through the cloud" + } + } + } + }, + "device_options": { + "title": "Device Features {device_idx} of {device_cnt}", + "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device:\n\n {device_name}", + "data": { + "name": "Device Name", + "hvac_modes": "HVAC Modes", + "fan_modes": "Fan Speeds", + "swing_modes": "Vertical Swing Modes", + "swing_horizontal_modes": "Horizontal Swing Modes", + "features": "Device Features and Modes", + "target_temp_step": "Temperature Step", + "external_temperature_sensor": "External Temperature Sensor", + "external_humidity_sensor": "External Humidity Sensor", + "restore_states": "Restore Entities" + }, + "data_description": { + "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor for the Climate entity data", + "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor for the Climate entity data", + "restore_states": "If checked, when the integration is started the device will be overridden by previous states of the integration instead of using the current device state.", + "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer." + } }, - "data_description": { - "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", - "timeout": "The timeout for each of the connection attempts" + "reconfigure": { + "title": "Reconfigure Devices", + "description": "You are reconfiguring a entry with a cloud account. Do you want to look for local devices that match your cloud devices?", + "data": { + "include_local": "Include local devices" + } } - } } - }, - "device_options": { - "title": "Device features", - "description": "The Gree API doesn't have a reliable method of getting the supported features of a device. Please use the options bellow to the best of your knowledge about your device.", - "data": { - "device_name": "Device Name", - "hvac_modes": "HVAC Modes", - "fan_modes": "Fan Speeds", - "swing_modes": "Vertical Swing Modes", - "swing_horizontal_modes": "Horizontal Swing Modes", - "features": "Device Features and Modes", - "external_temperature_sensor": "External Temperature Sensor", - "external_humidity_sensor": "External Humidity Sensor", - "restore_states": "Restore Entities", - "target_temp_step": "Temperature Step", - "scan_interval": "Scan Interval" + }, + "selector": { + "discovery": { + "options": { + "cloud": "Gree Cloud account", + "local": "Local network" + } }, - "data_description": { - "external_temperature_sensor": "If set will replace the built-in HVAC temperature sensor", - "external_humidity_sensor": "If set will replace the built-in HVAC humidity sensor", - "restore_states": "If set to true, when the integration is started the device will be overriden by previous states of the integration instead of using the current device state.", - "target_temp_step": "Sets the increment step for adjusting the target temperature. Fahrenheit degrees are clamped to the nearest integer.", - "scan_interval": "Frequency of the device data polling (in seconds)" - } - }, - "reconfigure": { - "title": "Device configuration", - "data": { - "name": "Name", - "host": "IP Address", - "mac": "MAC Address" + "encryption_version": { + "options": { + "0": "Auto-Detect", + "1": "V1", + "2": "V2" + } }, - "sections": { - "advanced": { - "name": "Advanced Settings", - "description": "Configure advanced setting of the device", - "data": { - "port": "Port", - "encryption_key": "Encryption Key", - "encryption_version": "Encryption Version", - "uid": "UID", - "disable_available_check": "Disable Available Check", - "max_online_attempts": "Max Connection Attempts", - "timeout": "Connection Timeout" - }, - "data_description": { - "max_online_attempts": "The number of attempts to communicate with the device before it is marked as unavailable", - "timeout": "The timeout for each of the connection attempts" + "hvac_modes": { + "options": { + "auto": "Auto", + "cool": "Cool", + "dry": "Dry", + "fan_only": "Fan only", + "heat": "Heat", + "off": "Off" } - } - } - } - } - }, - "selector": { - "discovery_method": { - "options": { - "discover": "Discover devices on the local network", - "discover_extended": "Discover devices on the local network and other VLANs/subnets", - "manual": "Add device manually" - } - }, - "hvac_modes": { - "options": { - "auto": "Auto", - "cool": "Cool", - "dry": "Dry", - "fan_only": "Fan only", - "heat": "Heat", - "off": "Off" - } - }, - "fan_modes": { - "options": { - "auto": "Auto", - "low": "Low", - "medium_low": "Medium-Low", - "medium": "Medium", - "medium_high": "Medium-High", - "high": "High", - "turbo": "Turbo", - "quiet": "Quiet" - } - }, - "swing_modes": { - "options": { - "default": "Default", - "full_swing": "Swing in full range", - "fixed_upper": "Fixed in the upmost position", - "fixed_upper_middle": "Fixed in the middle-up position", - "fixed_middle": "Fixed in the middle position", - "fixed_lower_middle": "Fixed in the middle-low position", - "fixed_lower": "Fixed in the lowest position", - "swing_lower": "Swing in the downmost region", - "swing_lower_middle": "Swing in the middle-low region", - "swing_middle": "Swing in the middle region", - "swing_upper_middle": "Swing in the middle-up region", - "swing_upper": "Swing in the upmost region" - } - }, - "swing_horizontal_modes": { - "options": { - "default": "Default", - "full_swing": "Swing in full range", - "left": "Fixed in the leftmost position", - "left_center": "Fixed in the middle-left position", - "center": "Fixed in the middle position", - "right_center": "Fixed in the middle-right position", - "right": "Fixed in the rightmost position" - } - }, - "features": { - "options": { - "beeper": "Beeper", - "air": "Fresh Air", - "xfan": "X-Fan", - "sleep": "Sleep", - "eightdegheat": "8ºC Smart Heat", - "lights": "Display Light", - "health": "Health", - "anti_direct_blow": "Anti Direct Blow", - "powersave": "Energy Saving", - "light_sensor": "Display Auto Brightness", - "faults": "Fault Detection", - "humidity_control": "Humidity Control" - } - } - }, - "entity": { - "sensor": { - "indoor_temperature": { - "name": "Indoor Temperature" - }, - "outdoor_temperature": { - "name": "Outdoor Temperature" - }, - "room_humidity": { - "name": "Indoor Humidity" - } - }, - "binary_sensor": { - "faults": { - "name": "Fault Detection" - } - }, - "climate": { - "hvac": { - "state_attributes": { - "fan_mode": { - "state": { - "auto": "Auto", - "low": "Low", - "medium_low": "Medium-Low", - "medium": "Medium", - "medium_high": "Medium-High", - "high": "High", - "turbo": "Turbo", - "quiet": "Quiet" + }, + "fan_modes": { + "options": { + "auto": "Auto", + "low": "Low", + "medium_low": "Medium-Low", + "medium": "Medium", + "medium_high": "Medium-High", + "high": "High", + "turbo": "Turbo", + "quiet": "Quiet" } - }, - "swing_mode": { - "state": { - "default": "Default", - "full_swing": "Swing in full range", - "fixed_upper": "Fixed in the upmost position", - "fixed_upper_middle": "Fixed in the middle-up position", - "fixed_middle": "Fixed in the middle position", - "fixed_lower_middle": "Fixed in the middle-low position", - "fixed_lower": "Fixed in the lowest position", - "swing_lower": "Swing in the downmost region", - "swing_lower_middle": "Swing in the middle-low region", - "swing_middle": "Swing in the middle region", - "swing_upper_middle": "Swing in the middle-up region", - "swing_upper": "Swing in the upmost region" + }, + "swing_modes": { + "options": { + "default": "Default", + "full_swing": "Swing in full range", + "fixed_upper": "Fixed in the highest position", + "fixed_upper_middle": "Fixed in the middle-up position", + "fixed_middle": "Fixed in the middle position", + "fixed_lower_middle": "Fixed in the middle-low position", + "fixed_lower": "Fixed in the lowest position", + "swing_lower": "Swing in the lowest region", + "swing_lower_middle": "Swing in the middle-low region", + "swing_middle": "Swing in the middle region", + "swing_upper_middle": "Swing in the middle-up region", + "swing_upper": "Swing in the highest region" } - }, - "swing_horizontal_mode": { - "state": { - "default": "Default", - "full_swing": "Swing in full range", - "left": "Fixed in the leftmost position", - "left_center": "Fixed in the middle-left position", - "center": "Fixed in the middle position", - "right_center": "Fixed in the middle-right position", - "right": "Fixed in the rightmost position" + }, + "swing_horizontal_modes": { + "options": { + "default": "Default", + "full_swing": "Swing in full range", + "left": "Fixed in the leftmost position", + "left_center": "Fixed in the middle-left position", + "center": "Fixed in the middle position", + "right_center": "Fixed in the middle-right position", + "right": "Fixed in the rightmost position" + } + }, + "features": { + "options": { + "beeper": "Beeper", + "air": "Fresh Air", + "xfan": "X-Fan", + "sleep": "Sleep", + "eightdegheat": "8ºC Smart Heat", + "lights": "Display Light", + "health": "Health", + "anti_direct_blow": "Anti Direct Blow", + "powersave": "Energy Saving", + "light_sensor": "Display Auto Brightness", + "faults": "Fault Detection", + "humidity_control": "Humidity Control" } - } } - } }, - "number": { - "target_temp_step": { - "name": "Temperature Step" - }, - "humidity_control_target": { - "name": "Humidity Control Target" - } - }, - "select": { - "temperature_units": { - "name": "Temperature Units" - }, - "humidity_control": { - "name": "Humidity Control", - "state": { - "disabled": "Disabled", - "target_dry": "Normal Dry", - "smart_dry": "Smart Dry", - "continuous_dry": "Continuous Dry" + "entity": { + "sensor": { + "indoor_temperature": { + "name": "Indoor Temperature" + }, + "outdoor_temperature": { + "name": "Outdoor Temperature" + }, + "room_humidity": { + "name": "Indoor Humidity" + } + }, + "binary_sensor": { + "faults": { + "name": "Fault Detection" + } + }, + "climate": { + "hvac": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Auto", + "low": "Low", + "medium_low": "Medium-Low", + "medium": "Medium", + "medium_high": "Medium-High", + "high": "High", + "turbo": "Turbo", + "quiet": "Quiet" + } + }, + "swing_mode": { + "state": { + "default": "Default", + "full_swing": "Swing in full range", + "fixed_upper": "Fixed in the highest position", + "fixed_upper_middle": "Fixed in the middle-up position", + "fixed_middle": "Fixed in the middle position", + "fixed_lower_middle": "Fixed in the middle-low position", + "fixed_lower": "Fixed in the lowest position", + "swing_lower": "Swing in the lowest region", + "swing_lower_middle": "Swing in the middle-low region", + "swing_middle": "Swing in the middle region", + "swing_upper_middle": "Swing in the middle-up region", + "swing_upper": "Swing in the highest region" + } + }, + "swing_horizontal_mode": { + "state": { + "default": "Default", + "full_swing": "Swing in full range", + "left": "Fixed in the leftmost position", + "left_center": "Fixed in the middle-left position", + "center": "Fixed in the middle position", + "right_center": "Fixed in the middle-right position", + "right": "Fixed in the rightmost position" + } + } + } + } + }, + "number": { + "target_temp_step": { + "name": "Temperature Step" + }, + "humidity_control_target": { + "name": "Humidity Control Target" + } + }, + "select": { + "temperature_units": { + "name": "Temperature Units" + }, + "humidity_control": { + "name": "Humidity Control", + "state": { + "disabled": "Disabled", + "target_dry": "Normal Dry", + "smart_dry": "Smart Dry", + "continuous_dry": "Continuous Dry" + } + } + }, + "switch": { + "auto_light": { + "name": "Auto Display Light" + }, + "auto_xfan": { + "name": "Auto X-Fan" + }, + "lights": { + "name": "Display Light" + }, + "xfan": { + "name": "X-Fan" + }, + "health": { + "name": "Health" + }, + "powersave": { + "name": "Power Save" + }, + "eightdegheat": { + "name": "Smart Heat 8ºC" + }, + "sleep": { + "name": "Sleep" + }, + "air": { + "name": "Fresh Air" + }, + "anti_direct_blow": { + "name": "Anti Direct Blow" + }, + "light_sensor": { + "name": "Display Auto Brightness" + }, + "beeper": { + "name": "Beeper" + } } - } - }, - "switch": { - "auto_light": { - "name": "Auto Display Light" - }, - "auto_xfan": { - "name": "Auto X-Fan" - }, - "lights": { - "name": "Display Light" - }, - "xfan": { - "name": "X-Fan" - }, - "health": { - "name": "Health" - }, - "powersave": { - "name": "Power Save" - }, - "eightdegheat": { - "name": "Smart Heat 8ºC" - }, - "sleep": { - "name": "Sleep" - }, - "air": { - "name": "Fresh Air" - }, - "anti_direct_blow": { - "name": "Anti Direct Blow" - }, - "light_sensor": { - "name": "Display Auto Brightness" - }, - "beeper": { - "name": "Beeper" - } - } - }, - "exceptions": { - "turbo_availability": { - "message": "Turbo mode is only available in Cool and Heat modes." - }, - "turbo_ignored": { - "message": "Turbo mode is ignored when Energy Saving or Smart Heat are enabled." - }, - "quiet_ignored": { - "message": "Quiet mode is ignored when Energy Saving or Smart Heat are enabled." }, - "entity_unavailable": { - "message": "The entity is unavailable." - }, - "generic": { - "message": "There was a problem performing the requested change, please consult the integration log." - }, - "invalid_device_id": { - "message": "There was a problem performing the action. An invalid device was selected." - }, - "entry_not_loaded": { - "message": "There was a problem performing the action. The configuration entry for the device is not loaded." - }, - "config_entry_not_found": { - "message": "There was a problem performing the action. The configuration entry for the device was not found." - }, - "invalid_config_data": { - "message": "There was a problem performing the action. The configuration entry has invalid data." - }, - "humidity_mode_unavailable": { - "message": "Humidity Control is only available in Cool and Dry modes." - }, - "continuous_dry_unavailable": { - "message": "Continuous Dry is only available in Dry mode." - }, - "smart_dry_unavailable": { - "message": "Smart Dry is only available in Cool mode." - } - }, - "services": { - "get_prop_values_all": { - "name": "Query all properties", - "description": "Query all properties of a Gree device", - "fields": { - "device_id": { - "name": "Device ID" + "exceptions": { + "turbo_availability": { + "message": "Turbo mode is only available in Cool and Heat modes." + }, + "entity_unavailable": { + "message": "The entity is unavailable." + }, + "generic": { + "message": "There was a problem performing the requested change, please consult the integration log." + }, + "invalid_device_id": { + "message": "There was a problem performing the action. An invalid device was selected." + }, + "entry_not_loaded": { + "message": "There was a problem performing the action. The configuration entry for the device is not loaded." + }, + "config_entry_not_found": { + "message": "There was a problem performing the action. The configuration entry for the device was not found." + }, + "invalid_config_data": { + "message": "There was a problem performing the action. The configuration entry has invalid data." + }, + "humidity_mode_unavailable": { + "message": "Humidity Control is only available in Cool and Dry modes." + }, + "continuous_dry_unavailable": { + "message": "Continuous Dry is only available in Dry mode." + }, + "smart_dry_unavailable": { + "message": "Smart Dry is only available in Cool mode." } - } }, - "get_prop_values": { - "name": "Query properties", - "description": "Query properties of a Gree device", - "fields": { - "device_id": { - "name": "Device ID" + "services": { + "get_prop_values_all": { + "name": "Query all properties", + "description": "Query all properties of a Gree device", + "fields": { + "device_id": { + "name": "Device ID" + } + } }, - "prop_list": { - "name": "List of properties to query" + "get_prop_values": { + "name": "Query properties", + "description": "Query properties of a Gree device", + "fields": { + "device_id": { + "name": "Device ID" + }, + "prop_list": { + "name": "List of properties to query" + } + } } - } } - } -} \ No newline at end of file +} diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json old mode 100755 new mode 100644 index bcf657e..a75b13d --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -1,384 +1,397 @@ { - "config": { - "error": { - "unknown": "Ocorreu algo de errado, tente de novo. Se o problema persistir, verifique os registos.", - "cannot_connect": "Não foi possível ligar ao dispositivo. Verifique as configurações do dispositivo e de rede e tente de novo.ain. Se o problema persistir, verifique os registos.", - "cannot_bind": "Não foi possível encontrar uma combinação da versão de encriptação e chave do dispositivo válida. Se o problema persistir, verifique os registos.", - "no_devices_found": "Não foram encontrados novos dispositivos Gree compatíveis na rede. Por favor, adicione o dispositivo manualmente.", - "invalid_network": "CIDR Inválido. Exemplo: 192.168.30.0/24", - "invalid_host": "Endereço IP Inválido. Exemplo: 192.168.30.50", - "network_too_large": "Uma das redes introduzidas excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos.", - "too_many_targets": "O conjunto de redes e dispositivos introduzidos excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos." - }, - "abort": { - "already_configured": "Um dispositivo com este endereço MAC já foi configurado previamente." - }, - "step": { - "user": { - "title": "Configuração Gree", - "description": "Escolha como adicionar o seu dispositivo Gree", - "data": { - "discovery": "Método" - } - }, - "manual_discovery": { - "title": "Dispositivos Encontrados", - "description": "Foram encontrado(s) {devices_found} dispositivo(s) Gree. Selecione um para adicionar ou escolha a configuração manual.", - "data": { - "device": "Dispositivo" - } - }, - "discovery_options": { - "title": "Procura Expandida", - "description": "Tem dispositivos numa VLAN? Introduza uma ou mais redes e/ou IP de dispositivos para expandir a procura automática. Rotas Inter-VLAN e regras de firewall devem existir para permitir tráfego UDP na porta 7000 desde o Home Assistant até às redes e dispositivos especificados.", - "data": { - "extra_scan_networks": "Redes (CIDRs separados por vírgula, e.g. 192.168.20.0/24,192.168.30.0/24)", - "extra_scan_hosts": "Dispositivos (IPs separados por vírgula, e.g. 192.168.30.50,192.168.30.51)" - } - }, - "manual_add": { - "title": "Configuração do dispositivo", - "data": { - "host": "Endereço IP", - "mac": "Endereço MAC" + "config": { + "error": { + "unknown": "Ocorreu algo de errado, tente novamente. \nSe o problema persistir, verifique os registos.", + "cannot_connect": "Não foi possível ligar ao dispositivo. \nVerifique a configuração do dispositivo e a conexão de rede e tente novamente. \nSe o problema persistir, verifique os registos.", + "cannot_bind": "Não foi possível encontrar uma combinação da versão de encriptação e chave do dispositivo válida. Se o problema persistir, verifique os registos.", + "invalid_network": "CIDR inválido. Exemplo: 192.168.30.0/24", + "invalid_host": "Endereço IP inválido. Exemplo: 192.168.30.50", + "network_too_large": "Uma das redes introduzidas excede o número máximo de dispositivos (65536, rede /16). Divida a rede em múltiplos CIDRs ou especifique os dispositivos.", + "too_many_targets": "As redes e hosts especificados excedem o máximo de 65536 hosts (rede /16). Divida em vários CIDRs ou especifique os dispositivos.", + "cloud_bad_login": "Falha no login. \nCertifique-se de que os detalhes estejam corretos e tente novamente.", + "cloud_unknown": "Erro durante o login. \nVerifique os registos e entre em contato com os desenvolvedores", + "no_devices_selected": "Selecione pelo menos um dispositivo para continuar", + "no_methods_selected": "Selecione pelo menos um método de procura" }, - "sections": { - "advanced": { - "name": "Definições Avançadas", - "description": "Configure as definições avançadas do dispositivo", - "data": { - "port": "Porta", - "encryption_key": "Chave de Encriptação", - "encryption_version": "Versão de Encriptação", - "uid": "UID", - "disable_available_check": "Desativar Verificação de Disponibilidade", - "max_online_attempts": "Máximo de Tentativas de Ligação", - "timeout": "Tempo Limite de Ligação" + "abort": { + "reconfigure_successful": "Dispositivos reconfigurados com sucesso", + "already_configured": "Um dispositivo com o este endere;o MAC j]a est]a configurado.", + "unique_id_mismatch": "A entrada a ser configurada não corresponde o tipo de configuração.", + "no_devices_to_add": "Não foram encontrados novos dispositivos. Ajuste as opções de procura e tente de novo." + }, + "step": { + "user": { + "title": "Configuração Gree", + "description": "Escolha onde procurar os seus dispositivos Gree", + "data": { + "discovery": "Método de procura" + } + }, + "cloud_add": { + "title": "Conta Gree", + "description": "Introduza os detalhes para se conectar à sua conta Gree", + "data": { + "email": "Email", + "password": "Palavra-passe", + "region": "Região" + } + }, + "local_add": { + "title": "Procura Local", + "description": "Os dispositivos serão procurados automaticamente ao nas redes disponíveis.\n\nSe tiver dispositivos numa sub-rede ou VLAN diferente, insira uma ou mais redes e/ou endereços IP específicos para procura via unicast. O encaminhamento entre VLANs e as regras de firewall devem permitir a porta UDP 7000 do Home Assistant para a sub-rede de destino.", + "data": { + "extra_scan_hosts": "Dispositivos Extra", + "extra_scan_networks": "Redes Extra" + }, + "data_description": { + "extra_scan_hosts": "(IPs, por exemplo, 192.168.30.50, 192.168.30.51)", + "extra_scan_networks": "(CIDRs, por exemplo, 192.168.20.0/24, 192.168.30.0/24)" + } + }, + "device_picker": { + "title": "Dispositivos encontrados", + "description": "Foram encontrado(s) {devices_found} dispositivo(s) Gree.\nSelecione os dispositivos que deseja configurar.\n\nAtenção: Se estiver a reconfigurar dispositivos, os dispositivos não selecionados serão removidos.", + "data": { + "devices": "Dispositivos" + } + }, + "connection_options": { + "title": "Opções de Ligação {device_idx} de {device_cnt}", + "description": "Configure as opções de ligação que serão utilizadas para o dispositivo:\n\n{device_name}", + "data": { + "disable_available_check": "Desativar Verificação de Disponibilidade", + "encryption_key": "Chave de Encriptação", + "scan_interval": "Taxa de Atualização", + "uid": "ID de Utilizador" + }, + "data_description": { + "encryption_key": "Se deixado em branco será detetada automaticamente", + "scan_interval": "Frequência de atualização dos dados do dispositivo (em segundos)", + "uid": "O número de ID do utilizador que possui o dispositivo. Pode ser 0 se não for conhecido" + }, + "sections": { + "local": { + "name": "Configurações de conexão local", + "data": { + "encryption_version": "Versão de Encriptação", + "host": "Endereço IP (encontrado: {discovered_ip})", + "mac_controller_local": "MAC do dispositivo controlador local (encontrado: {discovered_mac_local})", + "max_online_attempts": "Máximo de Tentativas de Ligação", + "port": "Porta", + "timeout": "Tempo Limite de Ligação" + }, + "data_description": { + "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível", + "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo" + } + }, + "cloud": { + "name": "Configurações de conexão cloud", + "data": { + "mac_controller_cloud": "MAC do dispositivo controlador da cloud (encontrado: {discovered_mac_cloud})", + "prefer_cloud": "Preferir ligação por cloud" + }, + "data_description": { + "prefer_cloud": "Os dispositivos preferem ligações locais. Ativar se prefere que este dispositivo se ligue através da cloud" + } + } + } }, - "data_description": { - "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível", - "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo" + "device_options": { + "title": "Funcionalidades do Dispositivo {device_idx} de {device_cnt}", + "description": "A API da Gree não fornece um método robusto para obter as funcionalidades de um dispositivo. Por favor, use as opções abaixo com base nos seus conhecimentos sobre o dispositivo:\n\n{device_name}", + "data": { + "name": "Nome do Dispositivo", + "hvac_modes": "Modos de Climatização", + "fan_modes": "Velocidades da Ventoinha", + "swing_modes": "Modos de Oscilamento Vertical", + "swing_horizontal_modes": "Modos de Oscilamento Horizontal", + "features": "Outras Funcionalidades e Modos", + "target_temp_step": "Incremento de Temperatura", + "external_temperature_sensor": "Sensor de Temperatura", + "external_humidity_sensor": "Sensor de Humidade", + "restore_states": "Restaurar Entidades" + }, + "data_description": { + "external_temperature_sensor": "Se definido, substitui o sensor integrado de temperatura interior do dispositivo", + "external_humidity_sensor": "Se definido, substitui o sensor integrado de humidade interior do dispositivo", + "restore_states": "Se ativo, quando a integração é iniciada, o estado do dispositivo será reposto para o último estado observado na integração.", + "target_temp_step": "Define o incremento da temperatura quando esta é ajustada. Graus Fahrenheit são arredondados para às unidades." + } + }, + "reconfigure": { + "title": "Reconfigurar Dispositivos", + "description": "Está a reconfigurar uma entrada com uma conta Gree. \nDeseja procurar dispositivos locais que correspondam aos seus dispositivos na conta?", + "data": { + "include_local": "Incluir dispositivos locais" + } } - } } - }, - "device_options": { - "title": "Funcionalidades do dispositivo", - "description": "A API da Gree não fornece um método robusto para obter as funcionalidades de um dispositivo. Por favor, use as opções abaixo com base nos seus conhecimentos sobre o dispositivo.", - "data": { - "device_name": "Nome do Dispositivo", - "hvac_modes": "Modos de Climatização", - "fan_modes": "Velocidades da Ventoinha", - "swing_modes": "Modos de Oscilamento Vertical", - "swing_horizontal_modes": "Modos de Oscilamento Horizontal", - "features": "Outas Funcionalidades e Modos", - "external_temperature_sensor": "Sensor de Temperatura", - "external_humidity_sensor": "Sensor de Humidade", - "restore_states": "Restaurar Entidades", - "target_temp_step": "Incremento de Temperatura", - "scan_interval": "Taxa de Atualização" + }, + "entity": { + "binary_sensor": { + "faults": { + "name": "Falha de Operação" + } }, - "data_description": { - "external_temperature_sensor": "Se definido, substitui o sensor integrado de temperatura interior do dispositivo", - "external_humidity_sensor": "Se definido, substitui o sensor integrado de humidade interior do dispositivo", - "restore_states": "Se ativo, quando a integração é iniciada, o estado do dispositivo será reposto para o último estado observado na integração.", - "target_temp_step": "Define o incremento da temperatura quando esta é ajustada. Graus Fahrenheit são arredondados para às unidades.", - "scan_interval": "Frequência de atualização dos dados do dispositivo (em segundos)" - } - }, - "reconfigure": { - "title": "Configuração do dispositivo", - "data": { - "name": "Nomw", - "host": "Endereço IP", - "mac": "Endereço MAC" + "climate": { + "hvac": { + "state": { + "auto": "Automático", + "cool": "Arrefecer", + "dry": "Secar", + "fan_only": "Ventilação", + "heat": "Aquecer", + "off": "Desligado" + }, + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Automática", + "low": "Baixa", + "medium_low": "Média-Baixa", + "medium": "Média", + "medium_high": "Média-Alta", + "high": "Alta", + "turbo": "Turbo", + "quiet": "Silenciosa" + } + }, + "swing_horizontal_mode": { + "state": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "left": "Fixo à esquerda", + "left_center": "Fixo entre o meio e a esquerda", + "center": "Fixo no meio", + "right_center": "Fixo entre o meio e a direita", + "right": "Fixo à direita" + } + }, + "swing_mode": { + "state": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "fixed_upper": "Fixo no topo", + "fixed_upper_middle": "Fixo entre o meio e topo", + "fixed_middle": "Fixo no meio", + "fixed_lower_middle": "Fixo entre o meio e baixo", + "fixed_lower": "Fixo em baixo", + "swing_lower": "Oscilação na região inferior", + "swing_lower_middle": "Oscilação na região média-inferior", + "swing_middle": "Oscilação na região intermédia", + "swing_upper_middle": "Oscilação na região média-superior", + "swing_upper": "Oscilação na região superior" + } + } + } + } }, - "sections": { - "advanced": { - "name": "Definições Avançadas", - "description": "Configure as definições avançadas do dispositivo", - "data": { - "port": "Porta", - "encryption_key": "Chave de Encriptação", - "encryption_version": "Versão de Encriptação", - "uid": "UID", - "disable_available_check": "Desativar Verificação de Disponibilidade", - "max_online_attempts": "Máximo de Tentativas de Ligação", - "timeout": "Tempo Limite de Ligação" + "number": { + "humidity_control_target": { + "name": "Alvo do Controle de Humidade" }, - "data_description": { - "max_online_attempts": "Número máximo de tentativas de comunicação com o dispositivo antes de ele ser marcado como indisponível", - "timeout": "Tempo limite de espera das respostas de cada ligação ao dispositivo" + "target_temp_step": { + "name": "Incremento de Temperatura" } - } - } - } - } - }, - "selector": { - "discovery_method": { - "options": { - "discover": "Procura automática de dispositivos", - "discover_extended": "Procura automática de dispositivos expandida", - "manual": "Adicionar manualmente" - } - }, - "hvac_modes": { - "options": { - "auto": "Automático", - "cool": "Arrefecer", - "dry": "Secar", - "fan_only": "Ventilação", - "heat": "Aquecer", - "off": "Desligado" - } - }, - "fan_modes": { - "options": { - "auto": "Automática", - "low": "Baixa", - "medium_low": "Média-Baixa", - "medium": "Média", - "medium_high": "Média-Alta", - "high": "Alta", - "turbo": "Turbo", - "quiet": "Silenciosa" - } - }, - "swing_modes": { - "options": { - "default": "Por defeito", - "full_swing": "Oscilação completa", - "fixed_upper": "Fixo no topo", - "fixed_upper_middle": "Fixo entre o meio e topo", - "fixed_middle": "Fixo no meio", - "fixed_lower_middle": "Fixo entre o meio e baixo", - "fixed_lower": "Fixo em baixo", - "swing_lower": "Oscilação na região inferior", - "swing_lower_middle": "Oscilação na região média-inferior", - "swing_middle": "Oscilação na região intermédia", - "swing_upper_middle": "Oscilação na região média-superior", - "swing_upper": "Oscilação na região superior" - } - }, - "swing_horizontal_modes": { - "options": { - "default": "Por defeito", - "full_swing": "Oscilação completa", - "left": "Fixo à esquerda", - "left_center": "Fixo entre o meio e a esquerda", - "center": "Fixo no meio", - "right_center": "Fixo entre o meio e a direita", - "right": "Fixo à direita" - } - }, - "features": { - "options": { - "beeper": "Aviso Sonoro", - "air": "Ar Fresco", - "xfan": "X-Fan", - "sleep": "Dormir", - "eightdegheat": "Fora de Casa", - "lights": "Visor", - "health": "Saúde", - "anti_direct_blow": "Anti Sopro Direto", - "powersave": "Poupança de Energia", - "light_sensor": "Brilho Automático do Visor", - "faults": "Falha de Operação", - "humidity_control": "Controlo de Humidade" - } - } - }, - "entity": { - "sensor": { - "indoor_temperature": { - "name": "Temperatura Interior" - }, - "outdoor_temperature": { - "name": "Temperatura Exterior" - }, - "room_humidity": { - "name": "Humidade Interior" - } - }, - "binary_sensor": { - "faults": { - "name": "Falha de Operação" - } - }, - "climate": { - "hvac": { - "state": { - "auto": "Automático", - "cool": "Arrefecer", - "dry": "Secar", - "fan_only": "Ventilação", - "heat": "Aquecer", - "off": "Desligado" }, - "state_attributes": { - "fan_mode": { - "state": { - "auto": "Automática", - "low": "Baixa", - "medium_low": "Média-Baixa", - "medium": "Média", - "medium_high": "Média-Alta", - "high": "Alta", - "turbo": "Turbo", - "quiet": "Silenciosa" + "select": { + "humidity_control": { + "name": "Controle de Humidade", + "state": { + "disabled": "Desativado", + "target_dry": "Secar", + "smart_dry": "Secagem Inteligente", + "continuous_dry": "Secagem Contínua" + } + }, + "temperature_units": { + "name": "Unidades de Temperatura" } - }, - "swing_mode": { - "state": { - "default": "Por defeito", - "full_swing": "Oscilação completa", - "fixed_upper": "Fixo no topo", - "fixed_upper_middle": "Fixo entre o meio e topo", - "fixed_middle": "Fixo no meio", - "fixed_lower_middle": "Fixo entre o meio e baixo", - "fixed_lower": "Fixo em baixo", - "swing_lower": "Oscilação na região inferior", - "swing_lower_middle": "Oscilação na região média-inferior", - "swing_middle": "Oscilação na região intermédia", - "swing_upper_middle": "Oscilação na região média-superior", - "swing_upper": "Oscilação na região superior" + }, + "sensor": { + "indoor_temperature": { + "name": "Temperatura Interior" + }, + "outdoor_temperature": { + "name": "Temperatura Exterior" + }, + "room_humidity": { + "name": "Humidade Interior" } - }, - "swing_horizontal_mode": { - "state": { - "default": "Por defeito", - "full_swing": "Oscilação completa", - "left": "Fixo à esquerda", - "left_center": "Fixo entre o meio e a esquerda", - "center": "Fixo no meio", - "right_center": "Fixo entre o meio e a direita", - "right": "Fixo à direita" + }, + "switch": { + "air": { + "name": "Ar Fresco" + }, + "anti_direct_blow": { + "name": "Anti Sopro Direto" + }, + "auto_light": { + "name": "Visor Automático" + }, + "auto_xfan": { + "name": "X-Fan Automática" + }, + "beeper": { + "name": "Aviso Sonoro" + }, + "eightdegheat": { + "name": "Fora de Casa" + }, + "health": { + "name": "Saúde" + }, + "light_sensor": { + "name": "Brilho Automático do Visor" + }, + "lights": { + "name": "Visor" + }, + "powersave": { + "name": "Poupança de Energia" + }, + "sleep": { + "name": "Dormir" + }, + "xfan": { + "name": "X-Fan" } - } } - } - }, - "number": { - "target_temp_step": { - "name": "Incremento de Temperatura" - }, - "humidity_control_target": { - "name": "Valor de Controlo de Humidade" - } }, - "select": { - "temperature_units": { - "name": "Unidade de Temperatura" - }, - "humidity_control": { - "name": "Controlo de Humidade", - "state": { - "disabled": "Desativado", - "target_dry": "Secar", - "smart_dry": "Secagem Inteligente", - "continuous_dry": "Secagem Contínua" + "exceptions": { + "config_entry_not_found": { + "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo não foi encontrada." + }, + "continuous_dry_unavailable": { + "message": "A Secagem Contínua só está disponível no modo de Secar." + }, + "entity_unavailable": { + "message": "A entidade não está disponível." + }, + "entry_not_loaded": { + "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo não está ativa." + }, + "generic": { + "message": "Ocorreu um erro a realizar a ação pretendida, consulto os registos da integração." + }, + "humidity_mode_unavailable": { + "message": "O Controlo de Humidade só está disponível nos modos de Arrefecer e Secar." + }, + "invalid_config_data": { + "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo contém dados inválidos." + }, + "invalid_device_id": { + "message": "Ocorreu um erro a executar a ação. O dispositivo selecionado é inválido." + }, + "smart_dry_unavailable": { + "message": "A Secagem Inteligente só está disponível no modo de Arrefecer." + }, + "turbo_availability": { + "message": "A função Turbo só está disponível nos modos de Arrefecer ou Aquecer" } - } - }, - "switch": { - "auto_light": { - "name": "Visor Automático" - }, - "auto_xfan": { - "name": "X-Fan Automática" - }, - "lights": { - "name": "Visor" - }, - "xfan": { - "name": "X-Fan" - }, - "health": { - "name": "Saúde" - }, - "powersave": { - "name": "Poupança de Energia" - }, - "eightdegheat": { - "name": "Fora de Casa" - }, - "sleep": { - "name": "Dormir" - }, - "air": { - "name": "Ar Fresco" - }, - "anti_direct_blow": { - "name": "Anti Sopro Direto" - }, - "light_sensor": { - "name": "Brilho Automático do Visor" - }, - "beeper": { - "name": "Aviso Sonoro" - } - } - }, - "exceptions": { - "turbo_availability": { - "message": "A função Turbo só está disponível nos modos de Arrefecer ou Aquecer" - }, - "turbo_ignored": { - "message": "A função Turbo é ignorada quando os modos de Poupança de Energia ou Fora de Casa estão ativos." - }, - "quiet_ignored": { - "message": "A função Silenciosa é ignorada quando os modos de Poupança de Energia ou Fora de Casa estão ativos." - }, - "entity_unavailable": { - "message": "A entidade não está disponível." - }, - "generic": { - "message": "Ocorreu um erro a realizar a ação pretendida, consulto os registos da integração." - }, - "invalid_device_id": { - "message": "Ocorreu um erro a executar a ação. O dispositivo selecionado é inválido." - }, - "entry_not_loaded": { - "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo não está ativa." - }, - "config_entry_not_found": { - "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo não foi encontrada." }, - "invalid_config_data": { - "message": "Ocorreu um erro a executar a ação. A configuração para o dispositivo contém dados inválidos." - }, - "humidity_mode_unavailable": { - "message": "O Controlo de Humidade só está disponível nos modos de Arrefecer e Secar." - }, - "continuous_dry_unavailable": { - "message": "A Secagem Contínua só está disponível no modo de Secar." - }, - "smart_dry_unavailable": { - "message": "A Secagem Inteligente só está disponível no modo de Arrefecer." - } - }, - "services": { - "get_prop_values_all": { - "name": "Consultar todas as propriedades", - "description": "Obtém todas as propriedades de um dispositivo Gree", - "fields": { - "device_id": { - "name": "ID do Dispositivo" + "selector": { + "discovery": { + "options": { + "cloud": "Conta Gree Cloud", + "local": "Rede local" + } + }, + "encryption_version": { + "options": { + "0": "Detetar Automaticamente", + "1": "V1", + "2": "V2" + } + }, + "fan_modes": { + "options": { + "auto": "Automática", + "low": "Baixa", + "medium_low": "Média-Baixa", + "medium": "Média", + "medium_high": "Média-Alta", + "high": "Alta", + "turbo": "Turbo", + "quiet": "Silenciosa" + } + }, + "features": { + "options": { + "beeper": "Aviso Sonoro", + "air": "Ar Fresco", + "xfan": "X-Fan", + "sleep": "Dormir", + "eightdegheat": "Fora de Casa", + "lights": "Visor", + "health": "Saúde", + "anti_direct_blow": "Anti Sopro Direto", + "powersave": "Poupança de Energia", + "light_sensor": "Brilho Automático do Visor", + "faults": "Falha de Operação", + "humidity_control": "Controlo de Humidade" + } + }, + "hvac_modes": { + "options": { + "auto": "Automático", + "cool": "Arrefecer", + "dry": "Secar", + "fan_only": "Ventilação", + "heat": "Aquecer", + "off": "Desligado" + } + }, + "swing_horizontal_modes": { + "options": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "left": "Fixo à esquerda", + "left_center": "Fixo entre o meio e a esquerda", + "center": "Fixo no meio", + "right_center": "Fixo entre o meio e a direita", + "right": "Fixo à direita" + } + }, + "swing_modes": { + "options": { + "default": "Por defeito", + "full_swing": "Oscilação completa", + "fixed_upper": "Fixo no topo", + "fixed_upper_middle": "Fixo entre o meio e topo", + "fixed_middle": "Fixo no meio", + "fixed_lower_middle": "Fixo entre o meio e baixo", + "fixed_lower": "Fixo em baixo", + "swing_lower": "Oscilação na região inferior", + "swing_lower_middle": "Oscilação na região média-inferior", + "swing_middle": "Oscilação na região intermédia", + "swing_upper_middle": "Oscilação na região média-superior", + "swing_upper": "Oscilação na região superior" + } } - } }, - "get_prop_values": { - "name": "Consultar propriedades", - "description": "Obtém o valor das propriedades de um dispositivo Gree", - "fields": { - "device_id": { - "name": "ID do Dispositivo" + "services": { + "get_prop_values": { + "name": "Consultar propriedades", + "description": "Consultar propriedades de um dispositivo Gree", + "fields": { + "device_id": { + "name": "ID do dispositivo" + }, + "prop_list": { + "name": "Lista de propriedades a consultar" + } + } }, - "prop_list": { - "name": "Lista de propriedades a obter" + "get_prop_values_all": { + "name": "Consultar todas as propriedades", + "description": "Consultar todas as propriedades de um dispositivo Gree", + "fields": { + "device_id": { + "name": "ID do dispositivo" + } + } } - } } - } -} \ No newline at end of file +} diff --git a/mypy.ini b/mypy.ini new file mode 100755 index 0000000..01dc1a4 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6402 @@ +# Imported from HA Core + +[mypy] +python_version = 3.14 +platform = linux +plugins = pydantic.mypy, .ha-core/mypy_plugins/enum_identity_compare.py +show_error_codes = true +follow_imports = normal +native_parser = true +num_workers = 2 +local_partial_types = true +strict_equality = true +strict_bytes = true +no_implicit_optional = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_unused_ignores = true +enable_error_code = deprecated, explicit-override, ignore-without-code, redundant-self, truthy-iterable +disable_error_code = annotation-unchecked, import-not-found, import-untyped +extra_checks = false +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true +warn_untyped_fields = true + +[mypy-homeassistant.*] +no_implicit_reexport = true + +[mypy-homeassistant.auth.auth_store] +disallow_any_generics = true + +[mypy-homeassistant.auth.providers.*] +disallow_any_generics = true + +[mypy-homeassistant.core] +disallow_any_generics = true + +[mypy-homeassistant.exceptions] +disallow_any_generics = true + +[mypy-homeassistant.helpers.area_registry] +disallow_any_generics = true + +[mypy-homeassistant.helpers.condition] +disallow_any_generics = true + +[mypy-homeassistant.helpers.debounce] +disallow_any_generics = true + +[mypy-homeassistant.helpers.deprecation] +disallow_any_generics = true + +[mypy-homeassistant.helpers.device_registry] +disallow_any_generics = true + +[mypy-homeassistant.helpers.discovery] +disallow_any_generics = true + +[mypy-homeassistant.helpers.dispatcher] +disallow_any_generics = true + +[mypy-homeassistant.helpers.entity] +disallow_any_generics = true + +[mypy-homeassistant.helpers.entity_platform] +disallow_any_generics = true + +[mypy-homeassistant.helpers.entity_values] +disallow_any_generics = true + +[mypy-homeassistant.helpers.event] +disallow_any_generics = true + +[mypy-homeassistant.helpers.reload] +disallow_any_generics = true + +[mypy-homeassistant.helpers.script] +disallow_any_generics = true + +[mypy-homeassistant.helpers.script_variables] +disallow_any_generics = true + +[mypy-homeassistant.helpers.singleton] +disallow_any_generics = true + +[mypy-homeassistant.helpers.sun] +disallow_any_generics = true + +[mypy-homeassistant.helpers.translation] +disallow_any_generics = true + +[mypy-homeassistant.loader] +disallow_any_generics = true + +[mypy-homeassistant.requirements] +disallow_any_generics = true + +[mypy-homeassistant.runner] +disallow_any_generics = true + +[mypy-homeassistant.setup] +disallow_any_generics = true + +[mypy-homeassistant.util.async_] +disallow_any_generics = true + +[mypy-homeassistant.util.color] +disallow_any_generics = true + +[mypy-homeassistant.util.decorator] +disallow_any_generics = true + +[mypy-homeassistant.util.location] +disallow_any_generics = true + +[mypy-homeassistant.util.logging] +disallow_any_generics = true + +[mypy-homeassistant.util.process] +disallow_any_generics = true + +[mypy-homeassistant.util.unit_system] +disallow_any_generics = true + +[mypy-homeassistant.components.*] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_subclassing_any = false +disallow_untyped_calls = false +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false +warn_unreachable = false +no_implicit_reexport = false + +[mypy-homeassistant.components] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true +no_implicit_reexport = true + +[mypy-homeassistant.components.abode.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.acaia.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.accuweather.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.acmeda.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.actiontec.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.actron_air.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.adax.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.adguard.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aftership.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ai_task.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.air_quality.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airgradient.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airly.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airnow.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airobot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airq.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airthings.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airthings_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airtouch5.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airvisual.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airvisual_pro.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airzone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.airzone_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aladdin_connect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.alarm_control_panel.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.alert.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.alexa.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.alexa_devices.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.alpha_vantage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.altruist.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.amazon_polly.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.amberelectric.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ambient_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ambient_station.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.amcrest.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.analytics.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.analytics_insights.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.android_ip_webcam.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.androidtv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.androidtv_remote.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.anel_pwrctrl.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.anova.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.anthemav.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.anthropic.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.apache_kafka.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.apcupsd.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.api.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.apple_tv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.apprise.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aprs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.apsystems.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aqualogic.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aquostv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aqvify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aranet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.arcam_fmj.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.arris_tg2492lg.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aruba.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.arwn.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.aseko_pool_live.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.assist_pipeline.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.assist_satellite.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.asuswrt.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.autarco.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.auth.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.automation.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.awair.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.axis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.azure_storage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.backblaze_b2.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.backup.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.baf.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bang_olufsen.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bayesian.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.binary_sensor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bitcoin.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.blockchain.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.blue_current.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.blueprint.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bluesound.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bluetooth.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bluetooth_adapters.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bond.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bosch_alarm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.braviatv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.brother.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.browser.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bryant_evolution.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bsblan.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.bthome.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.button.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.calendar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.cambridge_audio.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.camera.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.canary.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.casper_glow.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.centriconnect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.cert_expiry.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.chef_iq.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.clickatell.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.clicksend.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.climate.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.co2signal.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.comelit.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.command_line.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.compit.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.config.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.configurator.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.cookidoo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.counter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.cover.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.cpuspeed.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.crownstone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.data_grand_lyon.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.date.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.datetime.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.deako.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.deconz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.default_config.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.demo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.derivative.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.device_automation.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.device_tracker.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.devolo_home_control.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.devolo_home_network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dhcp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.diagnostics.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true +no_implicit_reexport = true + +[mypy-homeassistant.components.discovergy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dlna_dmr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dlna_dms.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dnsip.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.doorbird.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dormakaba_dkey.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.downloader.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dropbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.droplet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dsmr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.duckdns.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.duco.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.dunehd.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.duotecno.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.easyenergy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ecovacs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ecowitt.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.efergy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.eheimdigital.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.electrasmart.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.electric_kiwi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.elgato.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.elkm1.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.emulated_hue.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.energenie_power_sockets.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.energieleser.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.energy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.energyid.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.energyzero.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.enigma2.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.enphase_envoy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.eq3btsmart.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.esphome.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.event.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.evil_genius_labs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.evohome.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.faa_delays.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fan.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fastdotcom.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.feedreader.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.file_upload.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.filesize.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.filter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.firefly_iii.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fitbit.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.flexit_bacnet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.flow_it.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.flux_led.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.folder_watcher.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.forecast_solar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.freshr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fritz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fritzbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fritzbox_callmonitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fronius.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.frontend.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fujitsu_fglair.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fully_kiosk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fumis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fyta.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.gatus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.generic_hygrostat.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.generic_thermostat.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.geo_location.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.geocaching.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ghost.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.gios.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.github.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.glances.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.go2rtc.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.goalzero.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_assistant_sdk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_drive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_health.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_photos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_sheets.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.google_weather.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.govee_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.gpsd.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.greeneye_monitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.group.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.guardian.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.guntamatic.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.habitica.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hardkernel.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hardware.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.harman_luxury.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hdfury.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.heos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.here_travel_time.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.history.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.history_stats.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.holiday.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.home_connect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeassistant.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeassistant_alerts.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeassistant_green.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeassistant_hardware.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeassistant_sky_connect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeassistant_yellow.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.alarm_control_panel] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.button] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.config_flow] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.const] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.lock] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.select] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.storage] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homekit_controller.utils] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homewizard.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.homeworks.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hortimax.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hotspring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hr_energy_qube.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.http.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.huawei_lte.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.humidifier.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.husqvarna_automower.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.huum.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hvv_departures.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hydrawise.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hyperion.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.hypontech.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ibeacon.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.idasen_desk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.image.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.image_processing.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.image_upload.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.imap.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.imgw_pib.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.immich.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.incomfort.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.indevolt.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.inels.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.input_button.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.input_select.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.input_text.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.integration.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.intelliclima.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.intent.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.intent_script.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ios.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.iotty.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ipp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.iqvia.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.iron_os.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.isal.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.islamic_prayer_times.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.isy994.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.jellyfin.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.jewish_calendar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.jvc_projector.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.kaleidescape.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.knocki.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.knx.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.kraken.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.kulersky.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.labs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lacrosse.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lacrosse_view.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lamarzocco.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lametric.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.laundrify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lawn_mower.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lcn.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ld2410_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.led_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.led_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lektrico.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.letpot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lg_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lg_tv_rs232.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.libre_hardware_monitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lidarr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.liebherr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lifx.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.light.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.linkplay.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.litejet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.litellm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.litterrobot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.llama_cpp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.llm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.local_ip.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.local_todo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lock.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.logbook.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.logger.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.london_underground.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lookin.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lovelace.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.luftdaten.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lunatone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.lutron.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.madvr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.manual.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.marantz_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mastodon.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.matrix.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.matter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mcp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mcp_server.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mealie.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.media_extractor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.media_player.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.media_source.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.met_eireann.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.metoffice.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.miele.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mikrotik.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.min_max.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.minecraft_server.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mjpeg.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.modbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.modem_callerid.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mold_indicator.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.monzo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.moon.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mopeka.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.motionmount.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mqtt.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.music_assistant.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.my.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.mysensors.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.myuplink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nam.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.namecheapdns.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nasweb.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.neato.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.neopool.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nest.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.netatmo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nextdns.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nfandroidtv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nightscout.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nissan_leaf.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.no_ip.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nobo_hub.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nordpool.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.notify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.notion.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nrgkick.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ntfy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.number.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.nut.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ohme.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.onboarding.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.oncue.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.onedrive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.onedrive_for_business.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.onewire.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.onkyo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.open_meteo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.open_router.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.openai_conversation.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.openevse.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.openexchangerates.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.opensky.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.openuv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.opnsense.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.opower.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.oralb.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.otbr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.otp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ouman_eh_800.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.overkiz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.overseerr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ovhcloud_ai_endpoints.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.p1_monitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.paj_gps.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.panel_custom.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.paperless_ngx.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.peblar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.peco.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pegel_online.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.persistent_notification.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.person.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pi_hole.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ping.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.plugwise.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pooldose.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.portainer.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.powerfox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.powerfox_local.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.powerwall.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.private_ble_device.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.prometheus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.proximity.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.prusalink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ptdevices.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pure_energie.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.purpleair.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pushbullet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pvoutput.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.pyload.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.python_script.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.qbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.qnap_qsw.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rabbitair.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.radarr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.radio_browser.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rainforest_raven.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rainmachine.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.random.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.raspberry_pi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rdw.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.recollect_waste.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.recorder.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.recovery_mode.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.redgtech.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.remember_the_milk.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.remote.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.remote_calendar.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.renault.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.reolink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.repairs.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rest.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rest_command.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rfxtrx.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rhasspy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ridwell.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ring.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rituals_perfume_genie.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.roborock.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.roku.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.romy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.route_b_smart_meter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rpi_power.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rss_feed_template.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.russound_rio.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ruuvi_gateway.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ruuvitag_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.samsung_infrared.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.samsungtv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.saunum.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.scene.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.schedule.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.schlage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.scrape.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.script.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.search.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.season.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.select.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sensibo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sensirion_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sensor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sensorpush_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sensoterra.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.senz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sfr_box.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sftp_storage.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.shell_command.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.shelly.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.shopping_list.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.simplepush.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.simplisafe.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.siren.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.skybell.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.slack.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sleep_as_android.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sleepiq.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sma.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.smhi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.smlight.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.smtp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.snooz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.solarlog.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sonarr.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.spaceapi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.speedtestdotnet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.spotify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true +no_implicit_reexport = true + +[mypy-homeassistant.components.sql.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.squeezebox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.ssdp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.starlink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.statistics.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.steamist.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.stookwijzer.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.stream.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true +no_implicit_reexport = true + +[mypy-homeassistant.components.streamlabswater.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.stt.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.suez_water.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.sun.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.surepetcare.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.switch.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.switch_as_x.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.switchbee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.switchbot_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.switcher_kis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.synology_dsm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.system_health.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.system_log.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.systemmonitor.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.systemnexa2.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tag.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tailscale.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tailwind.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tami4.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tankerkoenig.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tautulli.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tcp.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.technove.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tedee.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.telegram_bot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.teleinfo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.teltonika.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.teslemetry.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.text.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.thethingsnetwork.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.threshold.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tibber.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tile.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tilt_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.time.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.time_date.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.timer.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tod.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.todo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tolo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tplink.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tplink_omada.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trace.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tractive.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tradfri.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trafikverket_camera.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trafikverket_ferry.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trafikverket_train.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trafikverket_weatherstation.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.transmission.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trend.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.trmnl.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.tts.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.twentemilieu.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.unifi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.unifi_access.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.unifiprotect.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.upcloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.update.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true +no_implicit_reexport = true + +[mypy-homeassistant.components.uptime.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.uptime_kuma.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.uptimerobot.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.usage_prediction.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.usb.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.uvc.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vacuum.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vallox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.valve.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.velbus.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.velux.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.victron_gx.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vistapool.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vivotek.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vizio.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vlc_telnet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.vodafone_station.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.volvo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wake_on_lan.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wake_word.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wallbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.waqi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.water_heater.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.watts.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.watttime.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wattwaechter.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.weather.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.web_rtc.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.webhook.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.webostv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.websocket_api.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wemo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.whois.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.withings.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wiz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.wled.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.workday.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.worldclock.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.xbox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.xiaomi_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.yale_smart_alarm.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.yalexs_ble.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.yoto.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.youtube.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.zeroconf.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.zinvolt.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.zodiac.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.zone.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.zwave_js.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.application_credentials.*] +no_implicit_reexport = true + +[mypy-tests.*] +check_untyped_defs = false +disallow_incomplete_defs = false +disallow_subclassing_any = false +disallow_untyped_calls = false +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false +warn_unreachable = false diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100755 index 0000000..1cdb01e --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,11 @@ +# Linting / formatting (same tool HA core uses) +ruff>=0.16.3 + +# Additional static analysis - HA core's CI also runs these +pylint>=4.0.7 +mypy>=2.3.1 + +# Integration dependencies (from manifest) +asyncio-dgram +aiomqtt +pydantic \ No newline at end of file