From 838fff399b094a64d71599e09226c8e486ea574b Mon Sep 17 00:00:00 2001 From: topsworld Date: Tue, 10 Dec 2024 17:36:32 +0800 Subject: [PATCH] feat: first commit --- .github/ISSUE_TEMPLATE/bug_report.yaml | 77 + .github/ISSUE_TEMPLATE/config.yml | 9 + .github/workflows/validate.yaml | 79 + .gitignore | 5 + .pylintrc | 398 ++++ LICENSE.md | 33 + LegalNotice.md | 13 + README.md | 394 ++++ custom_components/xiaomi_home/__init__.py | 309 +++ .../xiaomi_home/binary_sensor.py | 91 + custom_components/xiaomi_home/button.py | 88 + custom_components/xiaomi_home/climate.py | 470 +++++ custom_components/xiaomi_home/config_flow.py | 1280 ++++++++++++ custom_components/xiaomi_home/cover.py | 239 +++ custom_components/xiaomi_home/event.py | 89 + custom_components/xiaomi_home/fan.py | 264 +++ custom_components/xiaomi_home/humidifier.py | 202 ++ custom_components/xiaomi_home/light.py | 300 +++ custom_components/xiaomi_home/manifest.json | 32 + custom_components/xiaomi_home/miot/common.py | 89 + custom_components/xiaomi_home/miot/const.py | 149 ++ .../xiaomi_home/miot/i18n/de.json | 95 + .../xiaomi_home/miot/i18n/en.json | 95 + .../xiaomi_home/miot/i18n/es.json | 95 + .../xiaomi_home/miot/i18n/fr.json | 95 + .../xiaomi_home/miot/i18n/ja.json | 95 + .../xiaomi_home/miot/i18n/ru.json | 95 + .../xiaomi_home/miot/i18n/zh-Hans.json | 95 + .../xiaomi_home/miot/i18n/zh-Hant.json | 97 + .../xiaomi_home/miot/miot_client.py | 1811 +++++++++++++++++ .../xiaomi_home/miot/miot_cloud.py | 809 ++++++++ .../xiaomi_home/miot/miot_device.py | 1289 ++++++++++++ .../xiaomi_home/miot/miot_error.py | 142 ++ custom_components/xiaomi_home/miot/miot_ev.py | 320 +++ .../xiaomi_home/miot/miot_i18n.py | 109 + .../xiaomi_home/miot/miot_lan.py | 1331 ++++++++++++ .../xiaomi_home/miot/miot_mdns.py | 283 +++ .../xiaomi_home/miot/miot_mips.py | 1806 ++++++++++++++++ .../xiaomi_home/miot/miot_network.py | 295 +++ .../xiaomi_home/miot/miot_spec.py | 1029 ++++++++++ .../xiaomi_home/miot/miot_storage.py | 1034 ++++++++++ .../xiaomi_home/miot/specs/bool_trans.json | 235 +++ .../xiaomi_home/miot/specs/multi_lang.json | 158 ++ .../xiaomi_home/miot/specs/spec_filter.json | 63 + .../xiaomi_home/miot/specs/specv2entity.py | 392 ++++ .../xiaomi_home/miot/web_pages.py | 281 +++ custom_components/xiaomi_home/notify.py | 129 ++ custom_components/xiaomi_home/number.py | 106 + custom_components/xiaomi_home/select.py | 95 + custom_components/xiaomi_home/sensor.py | 124 ++ custom_components/xiaomi_home/switch.py | 105 + .../xiaomi_home/test/json_format.py | 41 + .../xiaomi_home/test/rule_format.py | 149 ++ .../xiaomi_home/test/test_all.sh | 35 + custom_components/xiaomi_home/text.py | 154 ++ .../xiaomi_home/translations/de.json | 144 ++ .../xiaomi_home/translations/en.json | 144 ++ .../xiaomi_home/translations/es.json | 144 ++ .../xiaomi_home/translations/fr.json | 144 ++ .../xiaomi_home/translations/ja.json | 146 ++ .../xiaomi_home/translations/ru.json | 144 ++ .../xiaomi_home/translations/zh-Hans.json | 144 ++ .../xiaomi_home/translations/zh-Hant.json | 144 ++ custom_components/xiaomi_home/vacuum.py | 219 ++ custom_components/xiaomi_home/water_heater.py | 207 ++ doc/CHANGELOG.md | 7 + doc/CONTRIBUTING.md | 105 + doc/CONTRIBUTING_zh.md | 105 + doc/README_zh.md | 396 ++++ doc/images/cloud_control.jpg | Bin 0 -> 47360 bytes doc/images/cloud_control_zh.jpg | Bin 0 -> 44130 bytes doc/images/local_control.jpg | Bin 0 -> 32938 bytes doc/images/local_control_zh.jpg | Bin 0 -> 28423 bytes hacs.json | 5 + install.sh | 28 + 75 files changed, 19923 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/workflows/validate.yaml create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 LICENSE.md create mode 100644 LegalNotice.md create mode 100644 README.md create mode 100644 custom_components/xiaomi_home/__init__.py create mode 100644 custom_components/xiaomi_home/binary_sensor.py create mode 100644 custom_components/xiaomi_home/button.py create mode 100644 custom_components/xiaomi_home/climate.py create mode 100644 custom_components/xiaomi_home/config_flow.py create mode 100644 custom_components/xiaomi_home/cover.py create mode 100644 custom_components/xiaomi_home/event.py create mode 100644 custom_components/xiaomi_home/fan.py create mode 100644 custom_components/xiaomi_home/humidifier.py create mode 100644 custom_components/xiaomi_home/light.py create mode 100644 custom_components/xiaomi_home/manifest.json create mode 100644 custom_components/xiaomi_home/miot/common.py create mode 100644 custom_components/xiaomi_home/miot/const.py create mode 100644 custom_components/xiaomi_home/miot/i18n/de.json create mode 100644 custom_components/xiaomi_home/miot/i18n/en.json create mode 100644 custom_components/xiaomi_home/miot/i18n/es.json create mode 100644 custom_components/xiaomi_home/miot/i18n/fr.json create mode 100644 custom_components/xiaomi_home/miot/i18n/ja.json create mode 100644 custom_components/xiaomi_home/miot/i18n/ru.json create mode 100644 custom_components/xiaomi_home/miot/i18n/zh-Hans.json create mode 100644 custom_components/xiaomi_home/miot/i18n/zh-Hant.json create mode 100644 custom_components/xiaomi_home/miot/miot_client.py create mode 100644 custom_components/xiaomi_home/miot/miot_cloud.py create mode 100644 custom_components/xiaomi_home/miot/miot_device.py create mode 100644 custom_components/xiaomi_home/miot/miot_error.py create mode 100644 custom_components/xiaomi_home/miot/miot_ev.py create mode 100644 custom_components/xiaomi_home/miot/miot_i18n.py create mode 100644 custom_components/xiaomi_home/miot/miot_lan.py create mode 100644 custom_components/xiaomi_home/miot/miot_mdns.py create mode 100644 custom_components/xiaomi_home/miot/miot_mips.py create mode 100644 custom_components/xiaomi_home/miot/miot_network.py create mode 100644 custom_components/xiaomi_home/miot/miot_spec.py create mode 100644 custom_components/xiaomi_home/miot/miot_storage.py create mode 100644 custom_components/xiaomi_home/miot/specs/bool_trans.json create mode 100644 custom_components/xiaomi_home/miot/specs/multi_lang.json create mode 100644 custom_components/xiaomi_home/miot/specs/spec_filter.json create mode 100644 custom_components/xiaomi_home/miot/specs/specv2entity.py create mode 100644 custom_components/xiaomi_home/miot/web_pages.py create mode 100644 custom_components/xiaomi_home/notify.py create mode 100644 custom_components/xiaomi_home/number.py create mode 100644 custom_components/xiaomi_home/select.py create mode 100644 custom_components/xiaomi_home/sensor.py create mode 100644 custom_components/xiaomi_home/switch.py create mode 100644 custom_components/xiaomi_home/test/json_format.py create mode 100644 custom_components/xiaomi_home/test/rule_format.py create mode 100755 custom_components/xiaomi_home/test/test_all.sh create mode 100644 custom_components/xiaomi_home/text.py create mode 100644 custom_components/xiaomi_home/translations/de.json create mode 100644 custom_components/xiaomi_home/translations/en.json create mode 100644 custom_components/xiaomi_home/translations/es.json create mode 100644 custom_components/xiaomi_home/translations/fr.json create mode 100644 custom_components/xiaomi_home/translations/ja.json create mode 100644 custom_components/xiaomi_home/translations/ru.json create mode 100644 custom_components/xiaomi_home/translations/zh-Hans.json create mode 100644 custom_components/xiaomi_home/translations/zh-Hant.json create mode 100644 custom_components/xiaomi_home/vacuum.py create mode 100644 custom_components/xiaomi_home/water_heater.py create mode 100644 doc/CHANGELOG.md create mode 100644 doc/CONTRIBUTING.md create mode 100644 doc/CONTRIBUTING_zh.md create mode 100644 doc/README_zh.md create mode 100644 doc/images/cloud_control.jpg create mode 100644 doc/images/cloud_control_zh.jpg create mode 100644 doc/images/local_control.jpg create mode 100644 doc/images/local_control_zh.jpg create mode 100644 hacs.json create mode 100755 install.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..8dae876 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,77 @@ +name: Bug report / 报告问题 +description: Create a report to help us improve. / 报告问题以帮助我们改进 +title: "[Bug]: " +labels: ["bug"] +body: + - type: input + attributes: + label: Describe the bug / 描述问题 + description: | + > A clear and concise description of what the bug is. + > 清晰且简明地描述问题。 + validations: + required: true + + - type: textarea + attributes: + label: To Reproduce / 复现步骤 + description: | + > If applicable, add screenshots to help explain your problem. You can attach images by clicking this area to highlight it and then dragging files in. Steps to reproduce the behavior: + > 如有需要,可添加截图以帮助解释问题。点击此区域以高亮显示并拖动截图文件以上传。请详细描述复现步骤: + placeholder: | + 1. Go to ... + 2. Click on ... + 3. Scroll down to ... + 4. See error + validations: + required: true + + - type: input + attributes: + label: Expected behavior / 预期结果 + description: | + > A clear and concise description of what you expected to happen. + > 描述预期结果。 + validations: + required: true + + - type: textarea + attributes: + label: Home Assistant Logs / 系统日志 + description: | + > [Settings > System > Logs > DOWNLOAD FULL LOG](https://my.home-assistant.io/redirect/logs) > Filter `xiaomi_home` + > [设置 > 系统 > 日志 > 下载完整日志](https://my.home-assistant.io/redirect/logs) > 筛选 `xiaomi_home` + + - type: input + attributes: + label: Home Assistant Core version / Home Assistant Core 版本 + description: | + > [Settings > About](https://my.home-assistant.io/redirect/info) + > [设置 > 关于 Home Assistant](https://my.home-assistant.io/redirect/info) + placeholder: "2024.8.1" + validations: + required: true + + - type: input + attributes: + label: Home Assistant Operation System version / Home Assistant Operation System 版本 + description: | + > [Settings > About](https://my.home-assistant.io/redirect/info) + > [设置 > 关于 Home Assistant](https://my.home-assistant.io/redirect/info) + placeholder: "12.4" + validations: + required: true + + - type: input + attributes: + label: Xiaomi Home integration version / 米家集成版本 + description: | + > [Settings > Devices & services > Configured > `Xiaomi Home`](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) + > [设置 > 设备与服务 > 已配置 > `Xiaomi Home`](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) + placeholder: "v0.0.1" + validations: + required: true + + - type: textarea + attributes: + label: Additional context / 其他说明 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8dba3b4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: false +contact_links: + - name: Feature Suggestion / 功能建议 + url: https://github.com/XiaoMi/ha_xiaomi_home/discussions/new?category=ideas + about: Share ideas for enhancements or new features. / 建议改进或增加新功能 + + - name: Support and Help / 支持与帮助 + url: https://github.com/XiaoMi/ha_xiaomi_home/discussions/categories/q-a + about: Please ask and answer questions here. / 请在这里提问和答疑 diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..9fa283a --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,79 @@ +name: Validate + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + validate-hassfest: + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Hassfest validation + uses: home-assistant/actions/hassfest@master + + validate-hacs: + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: HACS validation + uses: hacs/action@main + with: + category: integration + ignore: brands + + validate-format: + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Check format + run: | + ./custom_components/xiaomi_home/test/test_all.sh + + validate-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + + - name: Analyse the code with pylint + run: | + pylint $(git ls-files '*.py') + + validate-setup: + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v4 + + - name: Install the integration + run: | + export config_path=./test_config + mkdir $config_path + ./install.sh $config_path + echo "default_config:" >> $config_path/configuration.yaml + echo "logger:" >> $config_path/configuration.yaml + echo " default: info" >> $config_path/configuration.yaml + echo " logs:" >> $config_path/configuration.yaml + echo " custom_components.xiaomi_home: debug" >> $config_path/configuration.yaml + + - name: Setup Home Assistant + id: homeassistant + uses: ludeeus/setup-homeassistant@main + with: + config-dir: ./test_config diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d4b8e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.pytest_cache +.vscode +.idea +requirements.txt diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e04983c --- /dev/null +++ b/.pylintrc @@ -0,0 +1,398 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MAIN] + +# Files or directories to be skipped. They should be base names, not paths. +ignore=third_party + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=R, + abstract-method, + apply-builtin, + arguments-differ, + attribute-defined-outside-init, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + consider-using-enumerate, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat, + import-error, + import-self, + import-star-module-level, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + misplaced-comparison-constant, + missing-function-docstring, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-init, # added + no-member, + no-name-in-module, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unnecessary-pass, + unpacking-in-except, + useless-else-on-loop, + useless-suppression, + using-cmp-argument, + wrong-import-order, + xrange-builtin, + zip-builtin-not-iterating, + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=12 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=80 + +# TODO(https://github.com/pylint-dev/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )??$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 4 spaces. +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.io.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1c4b201 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,33 @@ +# 许可证 + +版权声明 (C) 2024 小米公司。 + +在本许可证下提供的 Home Assistant 米家集成(Xiaomi Home Integration)和相关米家云服务 API 接口,包括源代码和目标代码(统称为“授权作品”)的所有权及知识产权归小米所有。小米在此授予您一项个人的、有限的、非排他的、不可转让的、不可转授权的、免费的权利,仅限于您为非商业性目的使用 Home Assistant 而复制、使用、修改、分发授权作品。为避免疑义,本许可证未授权您将授权作品用于任何其他用途,包括但不限于开发应用程序(APP)、Web 服务以及其他形式的软件等。 + +您在重新分发授权作品时,无论修改与否,无论以源码形式或目标代码形式,您均需保留本授权作品中的版权标识、免责声明及本许可证的副本。 + +授权作品是按“现状”分发的,小米不对授权作品承担任何明示或暗示的保证或担保,包括但不限于对授权作品没有错误或疏漏、持续性、可靠性、适用于某一特定用途或不侵权等的保证、声明或承诺。在任何情况下,对于因使用授权作品或无法使用授权作品而引起的任何直接、间接、特殊、偶然或后果性损害或损失,您需自行承担全部责任。 + +本许可证中未明确授予的所有权利均予保留,除本许可证明确授予您的权利外,小米未以任何形式授权您使用小米及小米关联公司的商标、著作权或其他任何形式的知识产权,例如在未获得小米另行书面许可的情况下,您不得使用“小米”、“米家”等与小米相关的字样或其他会使得公众联想到小米的字样对您使用授权作品的软件或搭载授权作品的硬件做任何形式的宣传或推广。 + +在下述情况下,小米有权立即终止对您依据本许可证获得的授权: +1. 您对小米或其关联公司的专利或其他知识产权提起专利无效、诉讼或其他主张;或, +2. 您生产、制造(含委托制造)、销售(含委托销售)模仿或复制小米产品(包含小米关联公司的产品)的山寨产品。 + +--- + +# License + +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant Integration and related Xiaomi cloud service API interface provided under this license, including source code and object code (collectively, "Licensed Work"), are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi hereby grants you a personal, limited, non-exclusive, non-transferable, non-sublicensable, and royalty-free license to reproduce, use, modify, and distribute the Licensed Work only for your use of Home Assistant for non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize you to use the Licensed Work for any other purpose, including but not limited to use Licensed Work to develop applications (APP), Web services, and other forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without modifications, whether in source or object form, provided that you must give any other recipients of the Licensed Work a copy of this License and retain all copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible for any direct, indirect, special, incidental, or consequential damages or losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. Except for the rights expressly granted by Xiaomi under this License, Xiaomi does not authorize you in any form to use the trademarks, copyrights, or other forms of intellectual property rights of Xiaomi and its affiliates, including, without limitation, without obtaining other written permission from Xiaomi, you shall not use "Xiaomi", "Xiaomi home", "Mijia" and other words related to Xiaomi or words that may make the public associate with Xiaomi in any form to publicize or promote the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this License in the event: +1. You assert patent invalidation, litigation, or other claims against patents or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock off Xiaomi or its affiliates' products. diff --git a/LegalNotice.md b/LegalNotice.md new file mode 100644 index 0000000..31d4ec8 --- /dev/null +++ b/LegalNotice.md @@ -0,0 +1,13 @@ +# 法律声明 + +版权声明 (C) 2024 小米。 +Home Assistant 米家集成(Xiaomi Home Integration)所使用的米家云服务 API 接口(以下简称小米云接口)的所有权及其知识产权为小米所有。您仅限于在[米家集成许可证](./LICENSE.md)规定的范围内使用,任何超出前述许可证规定范围外的行为,包括但不限于在非 Home Assistant 平台上使用小米云接口、以及基于商业目的在 Home Assistant 平台上使用小米云接口等行为均应被视为侵权行为,小米有权对您使用的小米云接口采取包括但不限于停止使用、删除、屏蔽、断开连接等措施,同时保留向您追究相关法律责任的权利。 +小米拥有本声明的最终解释权。 + +--- + +# Legal Notice + +Copyright (C) 2024 Xiaomi Corporation. +All rights, title, interest and intellectual property rights of the Xiaomi Cloud Service API interface (hereinafter referred to as Xiaomi Cloud Interface) provided to use the Home Assistant Xiaomi Home Integration shall be solely owned by Xiaomi. You are only permitted to use the Xiaomi Cloud Interface within the scope specified in the [Xiaomi Home Integration License](./LICENSE.md). Any behavior beyond the scope of the aforesaid license, including but not limited to using the Xiaomi Cloud Interface on non-Home Assistant platforms and using the Xiaomi Cloud Interface on the Home Assistant platform for any commercial purposes, shall be deemed as infringement. Xiaomi has the right to take measures, including but not limited to stopping usage, deleting, blocking and disconnecting the Xiaomi Cloud Interface used by You, and also reserves the right to pursue relevant legal responsibilities against You. +Xiaomi reserves the right of the final interpretation of this notice. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6808e76 --- /dev/null +++ b/README.md @@ -0,0 +1,394 @@ +# Xiaomi Home Integration for Home Assistant + +[English](./README.md) | [简体中文](./doc/README_zh.md) + +Xiaomi Home Integration is an integrated component of Home Assistant supported by Xiaomi official. It allows you to use Xiaomi IoT smart devices in Home Assistant. + +## Installation + +> Home Assistant version requirement: +> +> - Core $\geq$ 2024.12.1 +> - Operating System $\geq$ 14.0 + +### Method 1: Git clone from GitHub + +```bash +cd config +git clone https://github.com/XiaoMi/ha_xiaomi_home.git +cd ha_xiaomi_home +./install.sh /config +``` + +We recommend this installation method, for it is convenient to switch to a tag when updating `xiaomi_home` to a certain version. + +For example, update to version v1.0.0 + +```bash +cd config/ha_xiaomi_home +git checkout v1.0.0 +./install.sh /config +``` + +### Method 2: [HACS](https://hacs.xyz/) + +HACS > Overflow Menu > Custom repositories > Repository: https://github.com/XiaoMi/ha_xiaomi_home.git & Category: Integration > ADD + +> Xiaomi Home has not been added to the HACS store as a default yet. It's coming soon. + +### Method 3: Manually installation via [Samba](https://github.com/home-assistant/addons/tree/master/samba) / [FTPS](https://github.com/hassio-addons/addon-ftp) + +Download and copy `custom_components/xiaomi_home` folder to `config/custom_components` folder in your Home Assistant. + +## Configuration + +### Login + +[Settings > Devices & services > ADD INTEGRATION](https://my.home-assistant.io/redirect/brand/?brand=xiaomi_home) > Search `Xiaomi Home` > NEXT > Click here to login > Sign in with Xiaomi account + +[![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=xiaomi_home) + +### Add MIoT Devices + +After logging in successfully, a dialog box named "Select Home and Devices" pops up. You can select the home containing the device that you want to import in Home Assistant. + +### Multiple User Login + +After a Xiaomi account login and its user configuration are completed, you can continue to add other Xiaomi accounts in the configured Xiaomi Home Integration page. + +Method: [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > ADD HUB > NEXT > Click here to login > Sign in with Xiaomi account + +[![Open your Home Assistant instance and show an integration.](https://my.home-assistant.io/badges/integration.svg)](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) + +### Update Configurations + +You can change the configurations in the "Configuration Options" dialog box, in which you can update your user nickname and the list of the devices importing from Xiaomi Home APP, etc. + +Method: [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > CONFIGURE > Select the option to update + +### Debug Mode for Action + +You can manually send Action command message with parameters to the device when the debug mode for action is activated. The user interface for sending the Action command with parameters is shown as a Text entity. + +Method: [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > CONFIGURE > Debug mode for action + +## Security + +Xiaomi Home Integration and the affiliated cloud interface is provided by Xiaomi officially. You need to use your Xiaomi account to login to get your device list. Xiaomi Home Integration implements OAuth 2.0 login process, which does not keep your account password in the Home Assistant application. However, due to the limitation of the Home Assistant platform, the user information (including device information, certificates, tokens, etc.) of your Xiaomi account will be saved in the Home Assistant configuration file in clear text after successful login. You need to ensure that your Home Assistant configuration file is properly stored. The exposure of your configuration file may result in others logging in with your identity. + +## FAQ + +- Does Xiaomi Home Integration support all Xiaomi Home devices? + + Xiaomi Home Integration currently supports most categories of Home device. Only a few categories are not supported. They are Bluetooth device, infrared device and virtual device. + +- Does Xiaomi Home Integration support multiple Xiaomi accounts? + + Yes, it supports multiple Xiaomi accounts. Futhermore, Xiaomi Home Integration allows that devices belonging to different accounts can be added to a same area. + +- Does Xiaomi Home Integration support local control? + + Local control is implemented by [Xiaomi Central Hub Gateway](https://www.mi.com/shop/buy/detail?product_id=15755&cfrom=search) (firmware version 3.4.0_0000 above) or Xiaomi home devices with built-in central hub gateway (software version 0.8.0 above) inside. If you do not have a Xiaomi central hub gateway or other devices having central hub gateway function, all control commands are sent through Xiaomi Cloud. The firmware for Xiaomi central hub gateway including the built-in central hub gateway supporting Home Assistant local control feature has not been released yet. Please refer to MIoT team's notification for upgrade plans. + + Xiaomi central hub gateway is only available in mainland China. In other regions, it is not available. + + Xiaomi Home Integration can also implement partial local control by enabling Xiaomi LAN control function. Xiaomi LAN control function can only control IP devices (devices connected to the router via WiFi or ethernet cable) in the same local area network as Home Assistant. It cannot control BLE Mesh, ZigBee, etc. devices. This function may cause some abnormalities. We recommend not to use this function. Xiaomi LAN control function is enabled by [Settings > Devices & services > Configured > Xiaomi Home](https://my.home-assistant.io/redirect/integration/?domain=xiaomi_home) > CONFIGURE > Update LAN control configuration + + Xiaomi LAN control function is not restricted by region. It is available in all regions. However, if there is a central gateway in the local area network where Home Assistant is located, even Xiaomi LAN control function is enabled in the integration, it will not take effect. + +- In which regions is Xiaomi Home Integration available? + + Xiaomi Home Integration can be used in the mainland of China, Europe, India, Russia, Singapore, and USA. As user data in Xiaomi Cloud of different regions is isolated, you need to choose your region when importing MIoT devices in the configuration process. Xiaomi Home Integration allows you to import devices of different regions to a same area. + +## Principle of Messaging + +### Control through the Cloud + +
+ + +Image 1: Cloud control architecture + +
+ +Xiaomi Home Integration subscribes to the interested device messages on the MQTT Broker in MIoT Cloud. When a device property changes or a device event occurs, the device sends an upstream message to MIoT Cloud, and the MQTT Broker pushes the subscribed device message to Xiaomi Home Integration. Because Xiaomi Home Integration does not need to poll to obtain the current device property value in the cloud, it can immediately receive the notification message when the properties change or the events occur. Thanks to the message subscription mechanism, Xiaomi Home Integration only queries the properties of all devices from the cloud once when the integration configuration is completed, which puts little access pressure on the cloud. + +Xiaomi Home Integration sends command messages to the devices via the HTTP interface of MIoT Cloud to control devices. The device reacts and responds after receiving the downstream message sent forward by MIoT Cloud. + +### Control locally + +
+ + +Image 2: Local control architecture + +
+ +Xiaomi central hub gateway contains a standard MQTT Broker, which implements a complete subscribe-publish mechanism. Xiaomi Home Integration subscribes to the interested device messages through Xiaomi central hub gateway. When a device property changes or a device event occurs, the device sends an upstream message to Xiaomi central hub gateway, and the MQTT Broker pushes the subscribed device message to Xiaomi Home Integration. + +When Xiaomi Home Integration needs to control a device, it publishes a device command message to the MQTT Broker, which is then forwarded to the device by Xiaomi central hub gateway. The device reacts and responds after receiving the downstream message from the gateway. + +## Mapping Relationship between MIoT-Spec-V2 and Home Assistant Entity + +[MIoT-Spec-V2](https://iot.mi.com/v2/new/doc/introduction/knowledge/spec) is the abbreviation for MIoT Specification Version 2, which is an IoT protocol formulated by Xiaomi IoT platform to give a standard functional description of IoT devices. It includes function definition (referred to as data model by other IoT platforms), interaction model, message format, and encoding. + +In MIoT-Spec-V2 protocol, a product is defined as a device. A device contains several services. A service may have some properties, events and actions. Xiaomi Home Integration creates Home Assistant entities according to MIoT-Spec-V2. The conversion relationship is as follows. + +### General Conversion + +- Property + +| format | access | value-list | value-range | Entity in Home Assistant | +| ------------ | --------------------- | ------------ | ----------- | ------------------------ | +| writable | string | - | - | Text | +| writable | bool | - | - | Switch | +| writable | not string & not bool | existent | - | Select | +| writable | not string & not bool | non-existent | existent | Number | +| not writable | - | - | - | Sensor | + +- Event + +MIoT-Spec-V2 event is transformed to Event entity in Home Assistant. The event's parameters are also passed to entity's `_trigger_event`. + +- Action + +| in | Entity in Home Assistant | +| --------- | ------------------------ | +| empty | Button | +| not empty | Notify | + +If the debug mode for action is activated, the Text entity will be created when the "in" field in the action spec is not empty. + +The "Attribute" item in the entity details page displays the format of the input parameter which is an ordered list, enclosed in square brackets []. The string elements in the list are enclosed in double quotation marks "". + +For example, the "Attributes" item in the details page of the Notify entity converted by the "Intelligent Speaker Execute Text Directive" action of xiaomi.wifispeaker.s12 siid=5, aiid=5 instance shows the action params as `[Text Content(str), Silent Execution(bool)]`. A properly formatted input is `["Hello", true]`. + +### Specific Conversion + +MIoT-Spec-V2 uses URN for defining types. The format is `urn::::[::]`, in which `name` is a human-readable word or phrase describing the instance of device, service, property, event and action. Xiaomi Home Integration first determines whether to convert the MIoT-Spec-V2 instance into a specific Home Assistant entity based on the instance's name. For the instance that does not meet the specific conversion rules, general conversion rules are used for conversion. + +`namespace` is the namespace of MIoT-Spec-V2 instance. When its value is miot-spec-v2, it means that the specification is defined by Xiaomi. When its value is bluetooth-spec, it means that the specification is defined by Bluetooth Special Interest Group (SIG). When its value is not miot-spec-v2 or bluetooth-spec, it means that the specification is defined by other vendors. If MIoT-Spec-V2 `namespace` is not miot-spec-v2, a star mark `*` is added in front of the entity's name . + +- Device + +The conversion follows `SPEC_DEVICE_TRANS_MAP`. + +``` +{ + '':{ + 'required':{ + '':{ + 'required':{ + 'properties': { + '': set + }, + 'events': set, + 'actions': set + }, + 'optional':{ + 'properties': set, + 'events': set, + 'actions': set + } + } + }, + 'optional':{ + '':{ + 'required':{ + 'properties': { + '': set + }, + 'events': set, + 'actions': set + }, + 'optional':{ + 'properties': set, + 'events': set, + 'actions': set + } + } + }, + 'entity': str + } +} +``` + +The "required" field under "device instance name" indicates the required services of the device. The "optional" field under "device instance name" indicates the optional services of the device. The "entity" field indicates the Home Assistant entity to be created. The "required" and the "optional" field under "service instance name" are required and optional properties, events and actions of the service respectively. The value of "property instance name" under "required" "properties" field is the access mode of the property. The condition for a successful match is that the value of "property instance name" is a subset of the access mode of the corresponding MIoT-Spec-V2 property instance. + +Home Assistant entity will not be created if MIoT-Spec-V2 device instance does not contain all required services, properties, events or actions. + +- Service + +The conversion follows `SPEC_SERVICE_TRANS_MAP`. + +``` +{ + '':{ + 'required':{ + 'properties': { + '': set + }, + 'events': set, + 'actions': set + }, + 'optional':{ + 'properties': set, + 'events': set, + 'actions': set + }, + 'entity': str + } +} +``` + +The "required" field under "service instance name" indicates the required properties, events and actions of the service. The "optional" field indicates the optional properties, events and actions of the service. The "entity" field indicates the Home Assistant entity to be created. The value of "property instance name" under "required" "properties" field is the access mode of the property. The condition for a successful match is that the value of "property instance name" is a subset of the access mode of the corresponding MIoT-Spec-V2 property instance. + +Home Assistant entity will not be created if MIoT-Spec-V2 service instance does not contain all required properties, events or actions. + +- Property + +The conversion follows `SPEC_PROP_TRANS_MAP`. + +``` +{ + 'entities':{ + '':{ + 'format': set, + 'access': set + } + }, + 'properties': { + '':{ + 'device_class': str, + 'entity': str + } + } +} +``` + +The "format" field under "entity name" represents the data format of the property, and matching with one value indicates a successful match. The "access" field under "entity name" represents the access mode of the property, and matching with all values is considered a successful match. + +The "entity" field under "property instance name", of which value is one of entity name under "entities" field, indicates the Home Assistant entity to be created. The "device_class" field under "property instance name" indicates the Home Assistant entity's `_attr_device_class`. + +- Event + +The conversion follows `SPEC_EVENT_TRANS_MAP`. + +``` +{ + '': str +} +``` + +The value of the event instance name indicates `_attr_device_class` of the Home Assistant entity to be created. + +### MIoT-Spec-V2 Filter + +`spec_filter.json` is used to filter out the MIoT-Spec-V2 instance that will not be converted to Home Assistant entity. + +The format of `spec_filter.json` is as follows. + +``` +{ + "":{ + "services": list, + "properties": list, + "events": list, + "actions": list, + } +} +``` + +The key of `spec_filter.json` dictionary is the urn excluding the "version " field of the MIoT-Spec-V2 device instance. The firmware of different versions of the same product may be associated with the MIoT-Spec-V2 device instances of different versions. It is required that the MIoT-Spec-V2 instance of a higher version must contain all MIoT-Spec-V2 instances of the lower versions when a vendor defines the MIoT-Spec-V2 of its product on MIoT platform. Thus, the key of `spec_filter.json` does not need to specify the version number of MIoT-Spec-V2 device instance. + +The value of "services", "properties", "events" or "actions" fields under "device instance" is the instance id (iid) of the service, property, event or action that will be ignored in the conversion process. Wildcard matching is supported. + +Example: + +``` +{ + "urn:miot-spec-v2:device:television:0000A010:xiaomi-rmi1":{ + "services": ["*"] # Filter out all services. It is equivalent to completely ignoring the device with such MIoT-Spec-V2 device instance. + }, + "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { + "services": ["3"], # Filter out the service whose iid=3. + "properties": ["4.*"] # Filter out all properties in the service whose iid=4. + "events": ["4.1"], # Filter out the iid=1 event in the iid=4 service. + "actions": ["4.1"] # Filter out the iid=1 action in the iid=4 service. + } +} +``` + +Device information service (urn:miot-spec-v2:service:device-information:00007801) of all devices will never be converted to Home Assistant entity. + +## Multiple Language Support + +There are 8 languages available for selection in the config flow language option of Xiaomi Home, including Simplified Chinese, Traditional Chinese, English, Spanish, Russian, French, German, and Japanese. The config flow page in Simplified Chinese and English has been manually reviewed by the developer. Other languages are translated by machine translation. If you want to modify the words and sentences in the config flow page, you need to modify the json file of the certain language in `custom_components/xiaomi_home/translations/` directory. + +When displaying Home Assistant entity name, Xiaomi Home downloads the multiple language file configured by the device vendor from MIoT Cloud, which contains translations for MIoT-Spec-V2 instances of the device. `multi_lang.json` is a locally maintained multiple language dictionary, which has a higher priority than the multiple language file obtained from the cloud and can be used to supplement or modify the multiple language translation of devices. + +The format of `multi_lang.json` is as follows. + +``` +{ + "": { + "": { + "": + } + } +} +``` + +The key of `multi_lang.json` dictionary is the urn excluding the "version" field of the MIoT-Spec-V2 device instance. + +The language code is zh-Hans, zh-Hant, en, es, ru, fr, de, or ja, corresponding to the 8 selectable languages mentioned above. + +The instance code is the code of the MIoT-Spec-V2 instance, which is in the format of: + +``` +service: # service +service::property: # property +service::property::valuelist: # the value in value-list of a property +service::event: # event +service::action: # action +``` + +siid, piid, eiid, aiid and value are all decimal three-digit integers. + +Example: + +``` +{ + "urn:miot-spec-v2:device:health-pot:0000A051:chunmi-a1": { + "zh-Hant": { + "service:002": "養生壺", + "service:002:property:001": "工作狀態", + "service:002:property:001:valuelist:000": "待機中", + "service:002:action:002": "停止烹飪", + "service:005:event:001": "烹飪完成" + } + } +} +``` + +## Documents + +- [License](./LICENSE.md) +- Contribution Guidelines: [English](./doc/CONTRIBUTING.md) | [简体中文](./doc/CONTRIBUTING_zh.md) +- [ChangeLog](./doc/CHANGELOG.md) +- Development Documents: https://developers.home-assistant.io/docs/creating_component_index + +## Directory Structure + +- miot: core code. +- miot/miot_client: Adding a login user in the integration needs adding a miot_client instance. +- miot/miot_cloud: Contains functions related to the cloud service, including OAuth login process, HTTP interface functions (to get the user information, to send the device control command, etc.) +- miot/miot_device: Device entity, including device information, processing logic of property, event and action. +- miot/miot_mips: Message bus for subscribing and publishing method. +- miot/miot_spec: Parse MIoT-Spec-V2. +- miot/miot_lan: Device LAN control, including device discovery, device control, etc. +- miot/miot_mdns: Central hub gateway service LAN discovery. +- miot/miot_network: Obtain network status and network information. +- miot/miot_storage: Used for integrated file storage. +- miot/test: Test scripts. +- config_flow: Config flow. diff --git a/custom_components/xiaomi_home/__init__.py b/custom_components/xiaomi_home/__init__.py new file mode 100644 index 0000000..88a7965 --- /dev/null +++ b/custom_components/xiaomi_home/__init__.py @@ -0,0 +1,309 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +The Xiaomi Home integration Init File. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.components import persistent_notification +from homeassistant.helpers import device_registry, entity_registry + +from .miot.miot_storage import ( + DeviceManufacturer, MIoTStorage, MIoTCert) +from .miot.miot_spec import ( + MIoTSpecInstance, MIoTSpecParser, MIoTSpecService) +from .miot.const import ( + DEFAULT_INTEGRATION_LANGUAGE, DOMAIN, SUPPORTED_PLATFORMS) +from .miot.miot_error import MIoTOauthError +from .miot.miot_device import MIoTDevice +from .miot.miot_client import MIoTClient, get_miot_instance_async + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, hass_config: dict) -> bool: + hass.data.setdefault(DOMAIN, {}) + # {[entry_id:str]: MIoTClient}, miot client instance + hass.data[DOMAIN].setdefault('miot_clients', {}) + # {[entry_id:str]: list[MIoTDevice]} + hass.data[DOMAIN].setdefault('devices', {}) + # {[entry_id:str]: entities} + hass.data[DOMAIN].setdefault('entities', {}) + for platform in SUPPORTED_PLATFORMS: + hass.data[DOMAIN]['entities'][platform] = [] + return True + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up an entry.""" + def ha_persistent_notify( + notify_id: str, title: Optional[str] = None, + message: Optional[str] = None + ) -> None: + """Send messages in Notifications dialog box.""" + if title: + persistent_notification.async_create( + hass=hass, message=message, + title=title, notification_id=notify_id) + else: + persistent_notification.async_dismiss( + hass=hass, notification_id=notify_id) + + entry_id = config_entry.entry_id + entry_data = dict(config_entry.data) + + ha_persistent_notify( + notify_id=f'{entry_id}.oauth_error', title=None, message=None) + + try: + miot_client: MIoTClient = await get_miot_instance_async( + hass=hass, entry_id=entry_id, + entry_data=entry_data, + persistent_notify=ha_persistent_notify) + # Spec parser + spec_parser = MIoTSpecParser( + lang=entry_data.get( + 'integration_language', DEFAULT_INTEGRATION_LANGUAGE), + storage=miot_client.miot_storage, + loop=miot_client.main_loop + ) + await spec_parser.init_async() + # Manufacturer + manufacturer: DeviceManufacturer = DeviceManufacturer( + storage=miot_client.miot_storage, + loop=miot_client.main_loop) + await manufacturer.init_async() + miot_devices: list[MIoTDevice] = [] + er = entity_registry.async_get(hass=hass) + for did, info in miot_client.device_list.items(): + spec_instance: MIoTSpecInstance = await spec_parser.parse( + urn=info['urn']) + if spec_instance is None: + _LOGGER.error('spec content is None, %s, %s', did, info) + continue + device: MIoTDevice = MIoTDevice( + miot_client=miot_client, + device_info={ + **info, 'manufacturer': manufacturer.get_name( + info.get('manufacturer', ''))}, + spec_instance=spec_instance) + miot_devices.append(device) + device.spec_transform() + # Remove filter entities and non-standard entities + for platform in SUPPORTED_PLATFORMS: + # ONLY support filter spec service translate entity + if platform in device.entity_list: + filter_entities = list(filter( + lambda entity: ( + isinstance(entity.spec, MIoTSpecService) + and ( + entity.spec.need_filter + or ( + miot_client.hide_non_standard_entities + and entity.spec.proprietary)) + ), + device.entity_list[platform])) + for entity in filter_entities: + device.entity_list[platform].remove(entity) + entity_id = device.gen_service_entity_id( + ha_domain=platform, siid=entity.spec.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + if platform in device.prop_list: + filter_props = list(filter( + lambda prop: ( + prop.need_filter or ( + miot_client.hide_non_standard_entities + and prop.proprietary)), + device.prop_list[platform])) + for prop in filter_props: + device.prop_list[platform].remove(prop) + entity_id = device.gen_prop_entity_id( + ha_domain=platform, spec_name=prop.name, + siid=prop.service.iid, piid=prop.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + if platform in device.event_list: + filter_events = list(filter( + lambda event: ( + event.need_filter or ( + miot_client.hide_non_standard_entities + and event.proprietary)), + device.event_list[platform])) + for event in filter_events: + device.event_list[platform].remove(event) + entity_id = device.gen_event_entity_id( + ha_domain=platform, spec_name=event.name, + siid=event.service.iid, eiid=event.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + if platform in device.action_list: + filter_actions = list(filter( + lambda action: ( + action.need_filter or ( + miot_client.hide_non_standard_entities + and action.proprietary)), + device.action_list[platform])) + for action in filter_actions: + device.action_list[platform].remove(action) + entity_id = device.gen_action_entity_id( + ha_domain=platform, spec_name=action.name, + siid=action.service.iid, aiid=action.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + # Remove non-standard action debug entity + if platform == 'notify': + entity_id = device.gen_action_entity_id( + ha_domain='text', spec_name=action.name, + siid=action.service.iid, aiid=action.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + # Action debug + if miot_client.action_debug: + if 'notify' in device.action_list: + # Add text entity for debug action + device.action_list['action_text'] = ( + device.action_list['notify']) + else: + # Remove text entity for debug action + for action in device.action_list.get('notify', []): + entity_id = device.gen_action_entity_id( + ha_domain='text', spec_name=action.name, + siid=action.service.iid, aiid=action.iid) + if er.async_get(entity_id_or_uuid=entity_id): + er.async_remove(entity_id=entity_id) + + hass.data[DOMAIN]['devices'][config_entry.entry_id] = miot_devices + await hass.config_entries.async_forward_entry_setups( + config_entry, SUPPORTED_PLATFORMS) + + # Remove the deleted devices + devices_remove = (await miot_client.miot_storage.load_user_config_async( + uid=config_entry.data['uid'], + cloud_server=config_entry.data['cloud_server'], + keys=['devices_remove'])).get('devices_remove', []) + if isinstance(devices_remove, list) and devices_remove: + dr = device_registry.async_get(hass) + for did in devices_remove: + device_entry = dr.async_get_device( + identifiers={( + DOMAIN, + MIoTDevice.gen_did_tag( + cloud_server=config_entry.data['cloud_server'], + did=did))}, + connections=None) + if not device_entry: + _LOGGER.error('remove device not found, %s', did) + continue + dr.async_remove_device(device_id=device_entry.id) + _LOGGER.info( + 'delete device entry, %s, %s', did, device_entry.id) + await miot_client.miot_storage.update_user_config_async( + uid=config_entry.data['uid'], + cloud_server=config_entry.data['cloud_server'], + config={'devices_remove': []}) + + await spec_parser.deinit_async() + await manufacturer.deinit_async() + + except MIoTOauthError as oauth_error: + ha_persistent_notify( + notify_id=f'{entry_id}.oauth_error', + title='Xiaomi Home Oauth Error', + message=f'Please re-add.\r\nerror: {oauth_error}' + ) + except Exception as err: + raise err + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload the entry.""" + entry_id = config_entry.entry_id + # Unload the platform + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, SUPPORTED_PLATFORMS) + if unload_ok: + hass.data[DOMAIN]['entities'].pop(entry_id, None) + hass.data[DOMAIN]['devices'].pop(entry_id, None) + # Remove integration data + miot_client: MIoTClient = hass.data[DOMAIN]['miot_clients'].pop( + entry_id, None) + if miot_client: + await miot_client.deinit_async() + del miot_client + return True + + +async def async_remove_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Remove the entry.""" + entry_data = dict(config_entry.data) + uid: str = entry_data['uid'] + cloud_server: str = entry_data['cloud_server'] + miot_storage: MIoTStorage = hass.data[DOMAIN]['miot_storage'] + miot_cert: MIoTCert = MIoTCert( + storage=miot_storage, uid=uid, cloud_server=cloud_server) + + # Clean device list + await miot_storage.remove_async( + domain='miot_devices', name=f'{uid}_{cloud_server}', type_=dict) + # Clean user configuration + await miot_storage.update_user_config_async( + uid=uid, cloud_server=cloud_server, config=None) + # Clean cert file + await miot_cert.remove_user_cert_async() + await miot_cert.remove_user_key_async() + return True diff --git a/custom_components/xiaomi_home/binary_sensor.py b/custom_components/xiaomi_home/binary_sensor.py new file mode 100644 index 0000000..9ec6e83 --- /dev/null +++ b/custom_components/xiaomi_home/binary_sensor.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Binary sensor entities for Xiaomi Home. +""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .miot.miot_spec import MIoTSpecProperty +from .miot.miot_device import MIoTDevice, MIoTPropertyEntity +from .miot.const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for prop in miot_device.prop_list.get('binary_sensor', []): + new_entities.append(BinarySensor( + miot_device=miot_device, spec=prop)) + + if new_entities: + async_add_entities(new_entities) + + +class BinarySensor(MIoTPropertyEntity, BinarySensorEntity): + """Binary sensor entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + """Initialize the BinarySensor.""" + super().__init__(miot_device=miot_device, spec=spec) + # Set device_class + self._attr_device_class = spec.device_class + + @property + def is_on(self) -> bool: + """On/Off state. True if the binary sensor is on, False otherwise.""" + return self._value is True diff --git a/custom_components/xiaomi_home/button.py b/custom_components/xiaomi_home/button.py new file mode 100644 index 0000000..0280d89 --- /dev/null +++ b/custom_components/xiaomi_home/button.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Button entities for Xiaomi Home. +""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.button import ButtonEntity + +from .miot.miot_device import MIoTActionEntity, MIoTDevice +from .miot.miot_spec import MIoTSpecAction +from .miot.const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for action in miot_device.action_list.get('button', []): + new_entities.append(Button(miot_device=miot_device, spec=action)) + + if new_entities: + async_add_entities(new_entities) + + +class Button(MIoTActionEntity, ButtonEntity): + """Button entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecAction) -> None: + """Initialize the Button.""" + super().__init__(miot_device=miot_device, spec=spec) + # Use default device class + + async def async_press(self) -> None: + """Press the button.""" + return await self.action_async() diff --git a/custom_components/xiaomi_home/climate.py b/custom_components/xiaomi_home/climate.py new file mode 100644 index 0000000..860afac --- /dev/null +++ b/custom_components/xiaomi_home/climate.py @@ -0,0 +1,470 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Climate entities for Xiaomi Home. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.climate import ( + SWING_ON, + SWING_OFF, + SWING_BOTH, + SWING_VERTICAL, + SWING_HORIZONTAL, + ATTR_TEMPERATURE, + HVACMode, + ClimateEntity, + ClimateEntityFeature +) + +from .miot.const import DOMAIN +from .miot.miot_device import MIoTDevice, MIoTServiceEntity, MIoTEntityData +from .miot.miot_spec import MIoTSpecProperty + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('climate', []): + new_entities.append( + AirConditioner(miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class AirConditioner(MIoTServiceEntity, ClimateEntity): + """Air conditioner entities for Xiaomi Home.""" + # service: air-conditioner + _prop_on: Optional[MIoTSpecProperty] + _prop_mode: Optional[MIoTSpecProperty] + _prop_target_temp: Optional[MIoTSpecProperty] + _prop_target_humi: Optional[MIoTSpecProperty] + # service: fan-control + _prop_fan_on: Optional[MIoTSpecProperty] + _prop_fan_level: Optional[MIoTSpecProperty] + _prop_horizontal_swing: Optional[MIoTSpecProperty] + _prop_vertical_swing: Optional[MIoTSpecProperty] + # service: environment + _prop_env_temp: Optional[MIoTSpecProperty] + _prop_env_humi: Optional[MIoTSpecProperty] + # service: air-condition-outlet-matching + _prop_ac_state: Optional[MIoTSpecProperty] + _value_ac_state: Optional[dict[str, int]] + + _hvac_mode_map: Optional[dict[int, HVACMode]] + _fan_mode_map: Optional[dict[int, str]] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + """Initialize the Climate.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_icon = 'mdi:air-conditioner' + self._attr_supported_features = ClimateEntityFeature(0) + self._attr_swing_mode = None + self._attr_swing_modes = [] + + self._prop_on = None + self._prop_mode = None + self._prop_target_temp = None + self._prop_target_humi = None + self._prop_fan_on = None + self._prop_fan_level = None + self._prop_horizontal_swing = None + self._prop_vertical_swing = None + self._prop_env_temp = None + self._prop_env_humi = None + self._prop_ac_state = None + self._value_ac_state = None + self._hvac_mode_map = None + self._fan_mode_map = None + + # properties + for prop in entity_data.props: + if prop.name == 'on': + if prop.service.name == 'air-conditioner': + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_ON) + self._attr_supported_features |= ( + ClimateEntityFeature.TURN_OFF) + self._prop_on = prop + elif prop.service.name == 'fan-control': + self._attr_swing_modes.append(SWING_ON) + self._prop_fan_on = prop + else: + _LOGGER.error( + 'unknown on property, %s', self.entity_id) + elif prop.name == 'mode': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'invalid mode value_list, %s', self.entity_id) + continue + self._hvac_mode_map = {} + for item in prop.value_list: + if item['name'].lower() in {'off', 'idle'}: + self._hvac_mode_map[item['value']] = HVACMode.OFF + elif item['name'].lower() in {'auto'}: + self._hvac_mode_map[item['value']] = HVACMode.AUTO + elif item['name'].lower() in {'cool'}: + self._hvac_mode_map[item['value']] = HVACMode.COOL + elif item['name'].lower() in {'heat'}: + self._hvac_mode_map[item['value']] = HVACMode.HEAT + elif item['name'].lower() in {'dry'}: + self._hvac_mode_map[item['value']] = HVACMode.DRY + elif item['name'].lower() in {'fan'}: + self._hvac_mode_map[item['value']] = HVACMode.FAN_ONLY + self._attr_hvac_modes = list(self._hvac_mode_map.values()) + self._prop_mode = prop + elif prop.name == 'target-temperature': + if not isinstance(prop.value_range, dict): + _LOGGER.error( + 'invalid target-temperature value_range format, %s', + self.entity_id) + continue + self._attr_min_temp = prop.value_range['min'] + self._attr_max_temp = prop.value_range['max'] + self._attr_target_temperature_step = prop.value_range['step'] + self._attr_temperature_unit = prop.external_unit + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE) + self._prop_target_temp = prop + elif prop.name == 'target-humidity': + if not isinstance(prop.value_range, dict): + _LOGGER.error( + 'invalid target-humidity value_range format, %s', + self.entity_id) + continue + self._attr_min_humidity = prop.value_range['min'] + self._attr_max_humidity = prop.value_range['max'] + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_HUMIDITY) + self._prop_target_humi = prop + elif prop.name == 'fan-level': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'invalid fan-level value_list, %s', self.entity_id) + continue + self._fan_mode_map = { + item['value']: item['description'] + for item in prop.value_list} + self._attr_fan_modes = list(self._fan_mode_map.values()) + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + self._prop_fan_level = prop + elif prop.name == 'horizontal-swing': + self._attr_swing_modes.append(SWING_HORIZONTAL) + self._prop_horizontal_swing = prop + elif prop.name == 'vertical-swing': + self._attr_swing_modes.append(SWING_VERTICAL) + self._prop_vertical_swing = prop + elif prop.name == 'temperature': + self._prop_env_temp = prop + elif prop.name == 'relative-humidity': + self._prop_env_humi = prop + + elif prop.name == 'ac-state': + self._prop_ac_state = prop + self._value_ac_state = {} + self.sub_prop_changed( + prop=prop, handler=self.__ac_state_changed) + + # hvac modes + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes.append(HVACMode.OFF) + # swing modes + if ( + SWING_HORIZONTAL in self._attr_swing_modes + and SWING_VERTICAL in self._attr_swing_modes + ): + self._attr_swing_modes.append(SWING_BOTH) + if self._attr_swing_modes: + self._attr_swing_modes.insert(0, SWING_OFF) + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self.set_property_async(prop=self._prop_on, value=True) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.set_property_async(prop=self._prop_on, value=False) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF and self._prop_on: + if not await self.set_property_async( + prop=self._prop_on, value=False): + raise RuntimeError( + f'set climate prop.on failed, {hvac_mode}, ' + f'{self.entity_id}') + return + mode_value = self.get_map_value( + map_=self._hvac_mode_map, description=hvac_mode) + if ( + mode_value is None or + not await self.set_property_async( + prop=self._prop_mode, value=mode_value) + ): + raise RuntimeError( + f'set climate prop.mode failed, {hvac_mode}, {self.entity_id}') + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs: + temp = kwargs[ATTR_TEMPERATURE] + if temp > self.max_temp: + temp = self.max_temp + elif temp < self.min_temp: + temp = self.min_temp + + await self.set_property_async( + prop=self._prop_target_temp, value=temp) + + async def async_set_humidity(self, humidity): + """Set new target humidity.""" + if humidity > self.max_humidity: + humidity = self.max_humidity + elif humidity < self.min_humidity: + humidity = self.min_humidity + await self.set_property_async( + prop=self._prop_target_humi, value=humidity) + + async def async_set_swing_mode(self, swing_mode): + """Set new target swing operation.""" + if swing_mode == SWING_BOTH: + if await self.set_property_async( + prop=self._prop_horizontal_swing, value=True, update=False): + self.set_prop_value(self._prop_horizontal_swing, value=True) + if await self.set_property_async( + prop=self._prop_vertical_swing, value=True, update=False): + self.set_prop_value(self._prop_vertical_swing, value=True) + elif swing_mode == SWING_HORIZONTAL: + if await self.set_property_async( + prop=self._prop_horizontal_swing, value=True, update=False): + self.set_prop_value(self._prop_horizontal_swing, value=True) + elif swing_mode == SWING_VERTICAL: + if await self.set_property_async( + prop=self._prop_vertical_swing, value=True, update=False): + self.set_prop_value(self._prop_vertical_swing, value=True) + elif swing_mode == SWING_ON: + if await self.set_property_async( + prop=self._prop_fan_on, value=True, update=False): + self.set_prop_value(self._prop_fan_on, value=True) + elif swing_mode == SWING_OFF: + if self._prop_fan_on and await self.set_property_async( + prop=self._prop_fan_on, value=False, update=False): + self.set_prop_value(self._prop_fan_on, value=False) + if self._prop_horizontal_swing and await self.set_property_async( + prop=self._prop_horizontal_swing, value=False, + update=False): + self.set_prop_value(self._prop_horizontal_swing, value=False) + if self._prop_vertical_swing and await self.set_property_async( + prop=self._prop_vertical_swing, value=False, update=False): + self.set_prop_value(self._prop_vertical_swing, value=False) + else: + raise RuntimeError( + f'unknown swing_mode, {swing_mode}, {self.entity_id}') + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + mode_value = self.get_map_value( + map_=self._fan_mode_map, description=fan_mode) + if mode_value is None or not await self.set_property_async( + prop=self._prop_fan_level, value=mode_value): + raise RuntimeError( + f'set climate prop.fan_mode failed, {fan_mode}, ' + f'{self.entity_id}') + + @ property + def target_temperature(self) -> Optional[float]: + """Return the target temperature.""" + return self.get_prop_value( + prop=self._prop_target_temp) if self._prop_target_temp else None + + @ property + def target_humidity(self) -> Optional[int]: + """Return the target humidity.""" + return self.get_prop_value( + prop=self._prop_target_humi) if self._prop_target_humi else None + + @ property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self.get_prop_value( + prop=self._prop_env_temp) if self._prop_env_temp else None + + @ property + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return self.get_prop_value( + prop=self._prop_env_humi) if self._prop_env_humi else None + + @ property + def hvac_mode(self) -> Optional[HVACMode]: + """Return the hvac mode. e.g., heat, cool mode.""" + if self._prop_on and self.get_prop_value(prop=self._prop_on) is False: + return HVACMode.OFF + return self.get_map_description( + map_=self._hvac_mode_map, + key=self.get_prop_value(prop=self._prop_mode)) + + @ property + def fan_mode(self) -> Optional[str]: + """Return the fan mode. + + Requires ClimateEntityFeature.FAN_MODE. + """ + return self.get_map_description( + map_=self._fan_mode_map, + key=self.get_prop_value(prop=self._prop_fan_level)) + + @ property + def swing_mode(self) -> Optional[str]: + """Return the swing mode. + + Requires ClimateEntityFeature.SWING_MODE. + """ + horizontal: bool = ( + self.get_prop_value(prop=self._prop_horizontal_swing) + if self._prop_horizontal_swing else None) + vertical: bool = ( + self.get_prop_value(prop=self._prop_vertical_swing) + if self._prop_vertical_swing else None) + if horizontal and vertical: + return SWING_BOTH + if horizontal: + return SWING_HORIZONTAL + if vertical: + return SWING_VERTICAL + if self._prop_fan_on: + if self.get_prop_value(prop=self._prop_fan_on): + return SWING_ON + else: + return SWING_OFF + return None + + def __ac_state_changed(self, prop: MIoTSpecProperty, value: any) -> None: + del prop + if not isinstance(value, str): + _LOGGER.error( + 'ac_status value format error, %s', value) + return + v_ac_state = {} + v_split = value.split('_') + for item in v_split: + if len(item) < 2: + _LOGGER.error('ac_status value error, %s', item) + continue + try: + v_ac_state[item[0]] = int(item[1:]) + except ValueError: + _LOGGER.error('ac_status value error, %s', item) + # P: status. 0: on, 1: off + if 'P' in v_ac_state and self._prop_on: + self.set_prop_value(prop=self._prop_on, + value=v_ac_state['P'] == 0) + # M: model. 0: cool, 1: heat, 2: auto, 3: fan, 4: dry + if 'M' in v_ac_state and self._prop_mode: + mode: Optional[HVACMode] = { + 0: HVACMode.COOL, + 1: HVACMode.HEAT, + 2: HVACMode.AUTO, + 3: HVACMode.FAN_ONLY, + 4: HVACMode.DRY + }.get(v_ac_state['M'], None) + if mode: + self.set_prop_value( + prop=self._prop_mode, value=self.get_map_value( + map_=self._hvac_mode_map, description=mode)) + # T: target temperature + if 'T' in v_ac_state and self._prop_target_temp: + self.set_prop_value(prop=self._prop_target_temp, + value=v_ac_state['T']) + # S: fan level. 0: auto, 1: low, 2: media, 3: high + if 'S' in v_ac_state and self._prop_fan_level: + self.set_prop_value(prop=self._prop_fan_level, + value=v_ac_state['S']) + # D: swing mode. 0: on, 1: off + if 'D' in v_ac_state and len(self._attr_swing_modes) == 2: + if ( + SWING_HORIZONTAL in self._attr_swing_modes + and self._prop_horizontal_swing + ): + self.set_prop_value( + prop=self._prop_horizontal_swing, + value=v_ac_state['D'] == 0) + elif ( + SWING_VERTICAL in self._attr_swing_modes + and self._prop_vertical_swing + ): + self.set_prop_value( + prop=self._prop_vertical_swing, + value=v_ac_state['D'] == 0) + + self._value_ac_state.update(v_ac_state) + _LOGGER.debug( + 'ac_state update, %s', self._value_ac_state) diff --git a/custom_components/xiaomi_home/config_flow.py b/custom_components/xiaomi_home/config_flow.py new file mode 100644 index 0000000..7a2fdae --- /dev/null +++ b/custom_components/xiaomi_home/config_flow.py @@ -0,0 +1,1280 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Config flow for Xiaomi Home. +""" +import asyncio +import hashlib +import json +import secrets +import traceback +from typing import Optional +from aiohttp import web +from aiohttp.hdrs import METH_GET +import voluptuous as vol +import logging + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.zeroconf import HaAsyncZeroconf +from homeassistant.components.webhook import ( + async_register as webhook_async_register, + async_unregister as webhook_async_unregister, + async_generate_path as webhook_async_generate_path +) +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow +import homeassistant.helpers.config_validation as cv + +from .miot.const import ( + DEFAULT_CLOUD_SERVER, + DEFAULT_CTRL_MODE, + DEFAULT_INTEGRATION_LANGUAGE, + DEFAULT_NICK_NAME, + DOMAIN, + OAUTH2_CLIENT_ID, + CLOUD_SERVERS, + OAUTH_REDIRECT_URL, + INTEGRATION_LANGUAGES, + SUPPORT_CENTRAL_GATEWAY_CTRL, + NETWORK_REFRESH_INTERVAL, + MIHOME_CERT_EXPIRE_MARGIN +) +from .miot.miot_cloud import MIoTHttpClient, MIoTOauthClient +from .miot.miot_storage import MIoTStorage, MIoTCert +from .miot.miot_mdns import MipsService +from .miot.web_pages import oauth_redirect_page +from .miot.miot_error import MIoTConfigError, MIoTError, MIoTOauthError +from .miot.miot_i18n import MIoTI18n +from .miot.miot_network import MIoTNetwork +from .miot.miot_client import MIoTClient, get_miot_instance_async +from .miot.miot_spec import MIoTSpecParser +from .miot.miot_lan import MIoTLan + +_LOGGER = logging.getLogger(__name__) + + +class XiaomiMihomeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Xiaomi Home config flow.""" + # pylint: disable=unused-argument + VERSION = 1 + MINOR_VERSION = 1 + _main_loop: asyncio.AbstractEventLoop + _mips_service: Optional[MipsService] + _miot_storage: Optional[MIoTStorage] + _miot_network: Optional[MIoTNetwork] + _miot_i18n: Optional[MIoTI18n] + + _integration_language: Optional[str] + _storage_path: Optional[str] + _virtual_did: Optional[str] + _uid: Optional[str] + _uuid: Optional[str] + _ctrl_mode: Optional[str] + _area_name_rule: Optional[str] + _action_debug: bool + _hide_non_standard_entities: bool + _auth_info: Optional[dict] + _nick_name: Optional[str] + _home_selected: Optional[dict] + _home_info_buffer: Optional[dict[str, str | dict[str, dict]]] + _home_list: Optional[dict] + + _cloud_server: Optional[str] + _oauth_redirect_url: Optional[str] + _miot_oauth: Optional[MIoTOauthClient] + _miot_http: Optional[MIoTHttpClient] + _user_cert_state: bool + + _oauth_auth_url: Optional[str] + _task_oauth: Optional[asyncio.Task[None]] + _config_error_reason: Optional[str] + + _fut_oauth_code: Optional[asyncio.Future] + + def __init__(self) -> None: + self._main_loop = asyncio.get_running_loop() + self._mips_service = None + self._miot_storage = None + self._miot_network = None + self._miot_i18n = None + + self._integration_language = None + self._storage_path = None + self._virtual_did = None + self._uid = None + self._uuid = None # MQTT client id + self._ctrl_mode = None + self._area_name_rule = None + self._action_debug = False + self._hide_non_standard_entities = False + self._auth_info = None + self._nick_name = None + self._home_selected = {} + self._home_info_buffer = None + self._home_list = None + + self._cloud_server = None + self._oauth_redirect_url = None + self._miot_oauth = None + self._miot_http = None + self._user_cert_state = False + + self._oauth_auth_url = None + self._task_oauth = None + self._config_error_reason = None + self._fut_oauth_code = None + + async def async_step_user(self, user_input=None): + self.hass.data.setdefault(DOMAIN, {}) + loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + + if self._virtual_did is None: + self._virtual_did = str(secrets.randbits(64)) + self.hass.data[DOMAIN].setdefault(self._virtual_did, {}) + if self._storage_path is None: + self._storage_path = self.hass.config.path('.storage', DOMAIN) + # MIoT network + self._miot_network = self.hass.data[DOMAIN].get('miot_network', None) + if self._miot_network is None: + self._miot_network = MIoTNetwork(loop=loop) + self.hass.data[DOMAIN]['miot_network'] = self._miot_network + await self._miot_network.init_async( + refresh_interval=NETWORK_REFRESH_INTERVAL) + _LOGGER.info('async_step_user, create miot network') + # Mips server + self._mips_service = self.hass.data[DOMAIN].get('mips_service', None) + if self._mips_service is None: + aiozc: HaAsyncZeroconf = await zeroconf.async_get_async_instance( + self.hass) + self._mips_service = MipsService(aiozc=aiozc, loop=loop) + self.hass.data[DOMAIN]['mips_service'] = self._mips_service + await self._mips_service.init_async() + _LOGGER.info('async_step_user, create mips service') + # MIoT storage + self._miot_storage = self.hass.data[DOMAIN].get('miot_storage', None) + if self._miot_storage is None: + self._miot_storage = MIoTStorage( + root_path=self._storage_path, loop=loop) + self.hass.data[DOMAIN]['miot_storage'] = self._miot_storage + _LOGGER.info( + 'async_step_user, create miot storage, %s', self._storage_path) + + # Check network + if not await self._miot_network.get_network_status_async(timeout=5): + raise AbortFlow(reason='network_connect_error', + description_placeholders={}) + + return await self.async_step_eula(user_input) + + async def async_step_eula(self, user_input=None): + if user_input: + if user_input.get('eula', None) is True: + return await self.async_step_auth_config() + return await self.__display_eula('eula_not_agree') + return await self.__display_eula('') + + async def __display_eula(self, reason: str): + return self.async_show_form( + step_id='eula', + data_schema=vol.Schema({ + vol.Required('eula', default=False): bool, + }), + last_step=False, + errors={'base': reason}, + ) + + async def async_step_auth_config(self, user_input=None): + if user_input: + self._cloud_server = user_input.get( + 'cloud_server', DEFAULT_CLOUD_SERVER) + self._integration_language = user_input.get( + 'integration_language', DEFAULT_INTEGRATION_LANGUAGE) + self._miot_i18n = MIoTI18n( + lang=self._integration_language, loop=self._main_loop) + await self._miot_i18n.init_async() + webhook_path = webhook_async_generate_path( + webhook_id=self._virtual_did) + self._oauth_redirect_url = ( + f'{user_input.get("oauth_redirect_url")}{webhook_path}') + return await self.async_step_oauth(user_input) + # Generate default language from HomeAssistant config (not user config) + default_language: str = self.hass.config.language + if default_language not in INTEGRATION_LANGUAGES: + if default_language.split('-', 1)[0] not in INTEGRATION_LANGUAGES: + default_language = DEFAULT_INTEGRATION_LANGUAGE + else: + default_language = default_language.split('-', 1)[0] + return self.async_show_form( + step_id='auth_config', + data_schema=vol.Schema({ + vol.Required( + 'cloud_server', + default=DEFAULT_CLOUD_SERVER): vol.In(CLOUD_SERVERS), + vol.Required( + 'integration_language', + default=default_language): vol.In(INTEGRATION_LANGUAGES), + vol.Required( + 'oauth_redirect_url', + default=OAUTH_REDIRECT_URL): vol.In([OAUTH_REDIRECT_URL]), + }), + last_step=False, + ) + + async def async_step_oauth(self, user_input=None): + # 1: Init miot_oauth, generate auth url + try: + if self._miot_oauth is None: + _LOGGER.info( + 'async_step_oauth, redirect_url: %s', + self._oauth_redirect_url) + miot_oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=self._oauth_redirect_url, + cloud_server=self._cloud_server + ) + state = str(secrets.randbits(64)) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state + self._oauth_auth_url = miot_oauth.gen_auth_url( + redirect_url=self._oauth_redirect_url, state=state) + _LOGGER.info( + 'async_step_oauth, oauth_url: %s', self._oauth_auth_url) + webhook_async_unregister( + self.hass, webhook_id=self._virtual_did) + webhook_async_register( + self.hass, + domain=DOMAIN, + name='oauth redirect url webhook', + webhook_id=self._virtual_did, + handler=handle_oauth_webhook, + allowed_methods=(METH_GET,), + ) + self._fut_oauth_code = self.hass.data[DOMAIN][ + self._virtual_did].get('fut_oauth_code', None) + if self._fut_oauth_code is None: + self._fut_oauth_code = self._main_loop.create_future() + self.hass.data[DOMAIN][self._virtual_did][ + 'fut_oauth_code'] = self._fut_oauth_code + _LOGGER.info( + 'async_step_oauth, webhook.async_register: %s', + self._virtual_did) + self._miot_oauth = miot_oauth + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'async_step_oauth, %s, %s', err, traceback.format_exc()) + return self.async_show_progress_done(next_step_id='oauth_error') + + # 2: show OAuth2 loading page + if self._task_oauth is None: + self._task_oauth = self.hass.async_create_task( + self.__check_oauth_async()) + if self._task_oauth.done(): + if (error := self._task_oauth.exception()): + _LOGGER.error('task_oauth exception, %s', error) + self._config_error_reason = str(error) + return self.async_show_progress_done(next_step_id='oauth_error') + return self.async_show_progress_done(next_step_id='devices_filter') + return self.async_show_progress( + step_id='oauth', + progress_action='oauth', + description_placeholders={ + 'link_left': + f'', + 'link_right': '' + }, + progress_task=self._task_oauth, + ) + + async def __check_oauth_async(self) -> None: + # TASK 1: Get oauth code + oauth_code: Optional[str] = await self._fut_oauth_code + + # TASK 2: Get access_token and user_info from miot_oauth + if not self._auth_info: + try: + auth_info = await self._miot_oauth.get_access_token_async( + code=oauth_code) + self._miot_http = MIoTHttpClient( + cloud_server=self._cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=auth_info['access_token']) + self._auth_info = auth_info + # Gen uuid + self._uuid = hashlib.sha256( + f'{self._virtual_did}.{auth_info["access_token"]}'.encode( + 'utf-8') + ).hexdigest()[:32] + try: + self._nick_name = ( + await self._miot_http.get_user_info_async() or {} + ).get('miliaoNick', DEFAULT_NICK_NAME) + except (MIoTOauthError, json.JSONDecodeError): + self._nick_name = DEFAULT_NICK_NAME + _LOGGER.error('get nick name failed') + except Exception as err: + _LOGGER.error( + 'get_access_token, %s, %s', err, traceback.format_exc()) + raise MIoTConfigError('get_token_error') from err + + # TASK 3: Get home info + try: + self._home_info_buffer = ( + await self._miot_http.get_devices_async()) + _LOGGER.info('get_homeinfos response: %s', self._home_info_buffer) + self._uid = self._home_info_buffer['uid'] + if self._uid == self._nick_name: + self._nick_name = DEFAULT_NICK_NAME + except Exception as err: + _LOGGER.error( + 'get_homeinfos error, %s, %s', err, traceback.format_exc()) + raise MIoTConfigError('get_homeinfo_error') from err + + # TASK 4: Abort if unique_id configured + # Each MiHome account can only configure one instance + await self.async_set_unique_id(f'{self._cloud_server}{self._uid}') + self._abort_if_unique_id_configured() + + # TASK 5: Query mdns info + mips_list = None + if self._cloud_server in SUPPORT_CENTRAL_GATEWAY_CTRL: + try: + mips_list = self._mips_service.get_services() + except Exception as err: + _LOGGER.error( + 'async_update_services error, %s, %s', + err, traceback.format_exc()) + raise MIoTConfigError('mdns_discovery_error') from err + + # TASK 6: Generate devices filter + home_list = {} + tip_devices = self._miot_i18n.translate(key='config.other.devices') + # home list + for home_id, home_info in self._home_info_buffer[ + 'homes']['home_list'].items(): + # i18n + tip_central = '' + group_id = home_info.get('group_id', None) + dev_list = { + device['did']: device + for device in list(self._home_info_buffer['devices'].values()) + if device.get('home_id', None) == home_id} + if ( + mips_list + and group_id in mips_list + and mips_list[group_id].get('did', None) in dev_list + ): + # i18n + tip_central = self._miot_i18n.translate( + key='config.other.found_central_gateway') + home_info['central_did'] = mips_list[group_id].get('did', None) + home_list[home_id] = ( + f'{home_info["home_name"]} ' + f'[{len(dev_list)} {tip_devices}{tip_central}]') + + self._home_list = dict(sorted(home_list.items())) + + # TASK 7: Get user's MiHome certificate + if self._cloud_server in SUPPORT_CENTRAL_GATEWAY_CTRL: + miot_cert = MIoTCert( + storage=self._miot_storage, + uid=self._uid, cloud_server=self._cloud_server) + if not self._user_cert_state: + try: + if await miot_cert.user_cert_remaining_time_async( + did=self._virtual_did) < MIHOME_CERT_EXPIRE_MARGIN: + user_key = await miot_cert.load_user_key_async() + if user_key is None: + user_key = miot_cert.gen_user_key() + if not await miot_cert.update_user_key_async( + key=user_key): + raise MIoTError('update_user_key_async failed') + csr_str = miot_cert.gen_user_csr( + user_key=user_key, did=self._virtual_did) + crt_str = await self._miot_http.get_central_cert_async( + csr_str) + if not await miot_cert.update_user_cert_async( + cert=crt_str): + raise MIoTError('update_user_cert_async failed') + self._user_cert_state = True + _LOGGER.info( + 'get mihome cert success, %s, %s', + self._uid, self._virtual_did) + except Exception as err: + _LOGGER.error( + 'get user cert error, %s, %s', + err, traceback.format_exc()) + raise MIoTConfigError('get_cert_error') from err + + # Auth success, unregister oauth webhook + webhook_async_unregister(self.hass, webhook_id=self._virtual_did) + _LOGGER.info( + '__check_oauth_async, webhook.async_unregister: %s', + self._virtual_did) + + # Show setup error message + async def async_step_oauth_error(self, user_input=None): + if self._config_error_reason is None: + return await self.async_step_oauth() + if self._config_error_reason.startswith('Flow aborted: '): + raise AbortFlow( + reason=self._config_error_reason.replace('Flow aborted: ', '')) + error_reason = self._config_error_reason + self._config_error_reason = None + return self.async_show_form( + step_id='oauth_error', + data_schema=vol.Schema({}), + last_step=False, + errors={'base': error_reason}, + ) + + async def async_step_devices_filter(self, user_input=None): + _LOGGER.debug('async_step_devices_filter') + try: + if user_input is None: + return await self.display_device_filter_form('') + + home_selected: list = user_input.get('home_infos', []) + if not home_selected: + return await self.display_device_filter_form( + 'no_family_selected') + self._ctrl_mode = user_input.get('ctrl_mode') + for home_id, home_info in self._home_info_buffer[ + 'homes']['home_list'].items(): + if home_id in home_selected: + self._home_selected[home_id] = home_info + self._area_name_rule = user_input.get('area_name_rule') + self._action_debug = user_input.get( + 'action_debug', self._action_debug) + self._hide_non_standard_entities = user_input.get( + 'hide_non_standard_entities', self._hide_non_standard_entities) + # Storage device list + devices_list: dict[str, dict] = { + did: dev_info + for did, dev_info in self._home_info_buffer['devices'].items() + if dev_info['home_id'] in home_selected} + if not devices_list: + return await self.display_device_filter_form('no_devices') + devices_list_sort = dict(sorted( + devices_list.items(), key=lambda item: + item[1].get('home_id', '')+item[1].get('room_id', ''))) + if not await self._miot_storage.save_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + data=devices_list_sort): + _LOGGER.error( + 'save devices async failed, %s, %s', + self._uid, self._cloud_server) + return await self.display_device_filter_form( + 'devices_storage_failed') + if not (await self._miot_storage.update_user_config_async( + uid=self._uid, cloud_server=self._cloud_server, config={ + 'auth_info': self._auth_info + })): + raise MIoTError('miot_storage.update_user_config_async error') + return self.async_create_entry( + title=( + f'{self._nick_name}: {self._uid} ' + f'[{CLOUD_SERVERS[self._cloud_server]}]'), + data={ + 'virtual_did': self._virtual_did, + 'uuid': self._uuid, + 'integration_language': self._integration_language, + 'storage_path': self._storage_path, + 'uid': self._uid, + 'nick_name': self._nick_name, + 'cloud_server': self._cloud_server, + 'oauth_redirect_url': self._oauth_redirect_url, + 'ctrl_mode': self._ctrl_mode, + 'home_selected': self._home_selected, + 'area_name_rule': self._area_name_rule, + 'action_debug': self._action_debug, + 'hide_non_standard_entities': + self._hide_non_standard_entities, + }) + except Exception as err: + _LOGGER.error( + 'async_step_devices_filter, %s, %s', + err, traceback.format_exc()) + raise AbortFlow( + reason='config_flow_error', + description_placeholders={ + 'error': f'config_flow error, {err}'} + ) from err + + async def display_device_filter_form(self, reason: str): + return self.async_show_form( + step_id='devices_filter', + data_schema=vol.Schema({ + vol.Required('ctrl_mode', default=DEFAULT_CTRL_MODE): vol.In( + self._miot_i18n.translate(key='config.control_mode')), + vol.Required('home_infos'): cv.multi_select(self._home_list), + vol.Required('area_name_rule', default='room'): vol.In( + self._miot_i18n.translate(key='config.room_name_rule')), + vol.Required('action_debug', default=self._action_debug): bool, + vol.Required( + 'hide_non_standard_entities', + default=self._hide_non_standard_entities): bool, + }), + errors={'base': reason}, + description_placeholders={ + 'nick_name': self._nick_name, + }, + last_step=False, + ) + + @ staticmethod + @ callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Xiaomi MiHome options flow.""" + # pylint: disable=unused-argument + _config_entry: config_entries.ConfigEntry + _main_loop: asyncio.AbstractEventLoop + _miot_client: Optional[MIoTClient] + + _miot_network: Optional[MIoTNetwork] + _miot_storage: Optional[MIoTStorage] + _mips_service: Optional[MipsService] + _miot_oauth: Optional[MIoTOauthClient] + _miot_http: Optional[MIoTHttpClient] + _miot_i18n: Optional[MIoTI18n] + _miot_lan: Optional[MIoTLan] + + _entry_data: dict + _virtual_did: Optional[str] + _uid: Optional[str] + _storage_path: Optional[str] + _cloud_server: Optional[str] + _oauth_redirect_url: Optional[str] + _integration_language: Optional[str] + _ctrl_mode: Optional[str] + _nick_name: Optional[str] + _home_selected_list: Optional[list] + _action_debug: bool + _hide_non_standard_entities: bool + + _auth_info: Optional[dict] + _home_selected_dict: Optional[dict] + _home_info_buffer: Optional[dict[str, str | dict[str, dict]]] + _home_list: Optional[dict] + _device_list: Optional[dict[str, dict]] + _devices_add: list[str] + _devices_remove: list[str] + + _oauth_auth_url: Optional[str] + _task_oauth: Optional[asyncio.Task[None]] + _config_error_reason: Optional[str] + _fut_oauth_code: Optional[asyncio.Future] + # Config options + _lang_new: Optional[str] + _nick_name_new: Optional[str] + _action_debug_new: bool + _hide_non_standard_entities_new: bool + _update_user_info: bool + _update_devices: bool + _update_trans_rules: bool + _update_lan_ctrl_config: bool + _trans_rules_count: int + _trans_rules_count_success: int + + _need_reload: bool + + def __init__(self, config_entry: config_entries.ConfigEntry): + self._config_entry = config_entry + self._main_loop = None + self._miot_client = None + + self._miot_network = None + self._miot_storage = None + self._mips_service = None + self._miot_oauth = None + self._miot_http = None + self._miot_i18n = None + self._miot_lan = None + + self._entry_data = dict(config_entry.data) + self._virtual_did = self._entry_data['virtual_did'] + self._uid = self._entry_data['uid'] + self._storage_path = self._entry_data['storage_path'] + self._cloud_server = self._entry_data['cloud_server'] + self._oauth_redirect_url = self._entry_data['oauth_redirect_url'] + self._ctrl_mode = self._entry_data['ctrl_mode'] + self._integration_language = self._entry_data['integration_language'] + self._nick_name = self._entry_data['nick_name'] + self._action_debug = self._entry_data.get('action_debug', False) + self._hide_non_standard_entities = self._entry_data.get( + 'hide_non_standard_entities', False) + self._home_selected_list = list( + self._entry_data['home_selected'].keys()) + + self._auth_info = None + self._home_selected_dict = {} + self._home_info_buffer = None + self._home_list = None + self._device_list = None + self._devices_add = [] + self._devices_remove = [] + + self._oauth_auth_url = None + self._task_oauth = None + self._config_error_reason = None + self._fut_oauth_code = None + + self._lang_new = None + self._nick_name_new = None + self._action_debug_new = False + self._hide_non_standard_entities_new = False + self._update_user_info = False + self._update_devices = False + self._update_trans_rules = False + self._update_lan_ctrl_config = False + self._trans_rules_count = 0 + self._trans_rules_count_success = 0 + + self._need_reload = False + + _LOGGER.info( + 'options init, %s, %s, %s, %s', config_entry.entry_id, + config_entry.unique_id, config_entry.data, config_entry.options) + + async def async_step_init(self, user_input=None): + self.hass.data.setdefault(DOMAIN, {}) + self.hass.data[DOMAIN].setdefault(self._virtual_did, {}) + try: + # main loop + self._main_loop = asyncio.get_running_loop() + # MIoT client + self._miot_client: MIoTClient = await get_miot_instance_async( + hass=self.hass, entry_id=self._config_entry.entry_id) + if not self._miot_client: + raise MIoTConfigError('invalid miot client') + # MIoT network + self._miot_network = self._miot_client.miot_network + if not self._miot_network: + raise MIoTConfigError('invalid miot network') + # MIoT storage + self._miot_storage = self._miot_client.miot_storage + if not self._miot_storage: + raise MIoTConfigError('invalid miot storage') + # Mips service + self._mips_service = self._miot_client.mips_service + if not self._mips_service: + raise MIoTConfigError('invalid mips service') + # MIoT oauth + self._miot_oauth = self._miot_client.miot_oauth + if not self._miot_oauth: + raise MIoTConfigError('invalid miot oauth') + # MIoT http + self._miot_http = self._miot_client.miot_http + if not self._miot_http: + raise MIoTConfigError('invalid miot http') + self._miot_i18n = self._miot_client.miot_i18n + if not self._miot_i18n: + raise MIoTConfigError('invalid miot i18n') + self._miot_lan = self._miot_client.miot_lan + if not self._miot_lan: + raise MIoTConfigError('invalid miot lan') + # Check token + if not await self._miot_client.refresh_oauth_info_async(): + # Check network + if not await self._miot_network.get_network_status_async( + timeout=3): + raise AbortFlow( + reason='network_connect_error', + description_placeholders={}) + self._need_reload = True + return await self.async_step_auth_config() + return await self.async_step_config_options() + except MIoTConfigError as err: + raise AbortFlow( + reason='options_flow_error', + description_placeholders={'error': str(err)} + ) from err + except AbortFlow as err: + raise err + except Exception as err: + _LOGGER.error( + 'async_step_init error, %s, %s', + err, traceback.format_exc()) + raise AbortFlow( + reason='re_add', + description_placeholders={'error': str(err)}, + ) from err + + async def async_step_auth_config(self, user_input=None): + if user_input: + webhook_path = webhook_async_generate_path( + webhook_id=self._virtual_did) + self._oauth_redirect_url = ( + f'{user_input.get("oauth_redirect_url")}{webhook_path}') + return await self.async_step_oauth(user_input) + return self.async_show_form( + step_id='auth_config', + data_schema=vol.Schema({ + vol.Required( + 'oauth_redirect_url', + default=OAUTH_REDIRECT_URL): vol.In([OAUTH_REDIRECT_URL]), + }), + description_placeholders={ + 'cloud_server': CLOUD_SERVERS[self._cloud_server], + }, + last_step=False, + ) + + async def async_step_oauth(self, user_input=None): + try: + if self._task_oauth is None: + state = str(secrets.randbits(64)) + self.hass.data[DOMAIN][self._virtual_did]['oauth_state'] = state + self._miot_oauth.set_redirect_url( + redirect_url=self._oauth_redirect_url) + self._oauth_auth_url = self._miot_oauth.gen_auth_url( + redirect_url=self._oauth_redirect_url, state=state) + _LOGGER.info( + 'async_step_oauth, oauth_url: %s', + self._oauth_auth_url) + webhook_async_unregister( + self.hass, webhook_id=self._virtual_did) + webhook_async_register( + self.hass, + domain=DOMAIN, + name='oauth redirect url webhook', + webhook_id=self._virtual_did, + handler=handle_oauth_webhook, + allowed_methods=(METH_GET,), + ) + self._fut_oauth_code = self.hass.data[DOMAIN][ + self._virtual_did].get('fut_oauth_code', None) + if self._fut_oauth_code is None: + self._fut_oauth_code = self._main_loop.create_future() + self.hass.data[DOMAIN][self._virtual_did][ + 'fut_oauth_code'] = self._fut_oauth_code + self._task_oauth = self.hass.async_create_task( + self.__check_oauth_async()) + _LOGGER.info( + 'async_step_oauth, webhook.async_register: %s', + self._virtual_did) + + if self._task_oauth.done(): + if (error := self._task_oauth.exception()): + _LOGGER.error('task_oauth exception, %s', error) + self._config_error_reason = str(error) + self._task_oauth = None + return self.async_show_progress_done( + next_step_id='oauth_error') + return self.async_show_progress_done( + next_step_id='config_options') + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'async_step_oauth error, %s, %s', + err, traceback.format_exc()) + self._config_error_reason = str(err) + return self.async_show_progress_done(next_step_id='oauth_error') + + return self.async_show_progress( + step_id='oauth', + progress_action='oauth', + description_placeholders={ + 'link_left': + f'', + 'link_right': '' + }, + progress_task=self._task_oauth, + ) + + async def __check_oauth_async(self) -> None: + # Get oauth code + oauth_code: Optional[str] = await self._fut_oauth_code + _LOGGER.debug('options flow __check_oauth_async, %s', oauth_code) + # Get access_token and user_info from miot_oauth + if self._auth_info is None: + auth_info: dict = None + try: + auth_info = await self._miot_oauth.get_access_token_async( + code=oauth_code) + except Exception as err: + _LOGGER.error( + 'get_access_token, %s, %s', err, traceback.format_exc()) + raise MIoTConfigError('get_token_error') from err + # Check uid + m_http: MIoTHttpClient = MIoTHttpClient( + cloud_server=self._cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=auth_info['access_token'], + loop=self._main_loop) + if await m_http.get_uid_async() != self._uid: + raise AbortFlow('inconsistent_account') + del m_http + self._miot_http.update_http_header( + access_token=auth_info['access_token']) + if not await self._miot_storage.update_user_config_async( + uid=self._uid, + cloud_server=self._cloud_server, + config={'auth_info': auth_info}): + raise AbortFlow('storage_error') + self._auth_info = auth_info + + # Auth success, unregister oauth webhook + webhook_async_unregister(self.hass, webhook_id=self._virtual_did) + _LOGGER.info( + '__check_oauth_async, webhook.async_unregister: %s', + self._virtual_did) + + # Show setup error message + async def async_step_oauth_error(self, user_input=None): + if self._config_error_reason is None: + return await self.async_step_oauth() + if self._config_error_reason.startswith('Flow aborted: '): + raise AbortFlow( + reason=self._config_error_reason.replace('Flow aborted: ', '')) + error_reason = self._config_error_reason + self._config_error_reason = None + return self.async_show_form( + step_id='oauth_error', + data_schema=vol.Schema({}), + last_step=False, + errors={'base': error_reason}, + ) + + async def async_step_config_options(self, user_input=None): + if not user_input: + return self.async_show_form( + step_id='config_options', + data_schema=vol.Schema({ + vol.Required( + 'integration_language', + default=self._integration_language + ): vol.In(INTEGRATION_LANGUAGES), + vol.Required( + 'update_user_info', + default=self._update_user_info): bool, + vol.Required( + 'update_devices', default=self._update_devices): bool, + vol.Required( + 'action_debug', default=self._action_debug): bool, + vol.Required( + 'hide_non_standard_entities', + default=self._hide_non_standard_entities): bool, + vol.Required( + 'update_trans_rules', + default=self._update_trans_rules): bool, + vol.Required( + 'update_lan_ctrl_config', + default=self._update_lan_ctrl_config): bool + }), + errors={}, + description_placeholders={ + 'nick_name': self._nick_name, + 'uid': self._uid, + 'cloud_server': CLOUD_SERVERS[self._cloud_server] + }, + last_step=False, + ) + # Check network + if not await self._miot_network.get_network_status_async(timeout=3): + raise AbortFlow( + reason='network_connect_error', description_placeholders={}) + self._lang_new = user_input.get( + 'integration_language', self._integration_language) + self._update_user_info = user_input.get( + 'update_user_info', self._update_user_info) + self._update_devices = user_input.get( + 'update_devices', self._update_devices) + self._action_debug_new = user_input.get( + 'action_debug', self._action_debug) + self._hide_non_standard_entities_new = user_input.get( + 'hide_non_standard_entities', self._hide_non_standard_entities) + self._update_trans_rules = user_input.get( + 'update_trans_rules', self._update_trans_rules) + self._update_lan_ctrl_config = user_input.get( + 'update_lan_ctrl_config', self._update_lan_ctrl_config) + + return await self.async_step_update_user_info() + + async def async_step_update_user_info(self, user_input=None): + if not self._update_user_info: + return await self.async_step_devices_filter() + if not user_input: + nick_name_new = ( + await self._miot_http.get_user_info_async() or {}).get( + 'miliaoNick', DEFAULT_NICK_NAME) + return self.async_show_form( + step_id='update_user_info', + data_schema=vol.Schema({ + vol.Required('nick_name', default=nick_name_new): str + }), + description_placeholders={ + 'nick_name': self._nick_name + }, + last_step=False + ) + + self._nick_name_new = user_input.get('nick_name') + return await self.async_step_devices_filter() + + async def async_step_devices_filter(self, user_input=None): + if not self._update_devices: + return await self.async_step_update_trans_rules() + if not user_input: + # Query mdns info + try: + mips_list = self._mips_service.get_services() + except Exception as err: + _LOGGER.error( + 'async_update_services error, %s, %s', + err, traceback.format_exc()) + raise MIoTConfigError('mdns_discovery_error') from err + + # Get home info + try: + self._home_info_buffer = ( + await self._miot_http.get_devices_async()) + except Exception as err: + _LOGGER.error( + 'get_homeinfos error, %s, %s', err, traceback.format_exc()) + raise MIoTConfigError('get_homeinfo_error') from err + # Generate devices filter + home_list = {} + tip_devices = self._miot_i18n.translate(key='config.other.devices') + # home list + for home_id, home_info in self._home_info_buffer[ + 'homes']['home_list'].items(): + # i18n + tip_central = '' + group_id = home_info.get('group_id', None) + did_list = { + device['did']: device for device in list( + self._home_info_buffer['devices'].values()) + if device.get('home_id', None) == home_id} + if ( + group_id in mips_list + and mips_list[group_id].get('did', None) in did_list + ): + # i18n + tip_central = self._miot_i18n.translate( + key='config.other.found_central_gateway') + home_info['central_did'] = mips_list[group_id].get( + 'did', None) + home_list[home_id] = ( + f'{home_info["home_name"]} ' + f'[ {len(did_list)} {tip_devices}{tip_central}]') + # Remove deleted item + self._home_selected_list = [ + home_id for home_id in self._home_selected_list + if home_id in home_list] + + self._home_list = dict(sorted(home_list.items())) + return await self.display_device_filter_form('') + + self._home_selected_list = user_input.get('home_infos', []) + if not self._home_selected_list: + return await self.display_device_filter_form('no_family_selected') + self._ctrl_mode = user_input.get('ctrl_mode') + self._home_selected_dict = {} + for home_id, home_info in self._home_info_buffer[ + 'homes']['home_list'].items(): + if home_id in self._home_selected_list: + self._home_selected_dict[home_id] = home_info + # Get device list + self._device_list: dict[str, dict] = { + did: dev_info + for did, dev_info in self._home_info_buffer['devices'].items() + if dev_info['home_id'] in self._home_selected_list} + if not self._device_list: + return await self.display_device_filter_form('no_devices') + # Statistics devices changed + self._devices_add = [] + self._devices_remove = [] + local_devices = await self._miot_storage.load_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + type_=dict) or {} + + self._devices_add = [ + did for did in self._device_list.keys() if did not in local_devices] + self._devices_remove = [ + did for did in local_devices.keys() if did not in self._device_list] + _LOGGER.debug( + 'devices update, add->%s, remove->%s', + self._devices_add, self._devices_remove) + return await self.async_step_update_trans_rules() + + async def display_device_filter_form(self, reason: str): + return self.async_show_form( + step_id='devices_filter', + data_schema=vol.Schema({ + vol.Required( + 'ctrl_mode', default=self._ctrl_mode + ): vol.In(self._miot_i18n.translate(key='config.control_mode')), + vol.Required( + 'home_infos', + default=self._home_selected_list + ): cv.multi_select(self._home_list), + }), + errors={'base': reason}, + description_placeholders={ + 'nick_name': self._nick_name + }, + last_step=False + ) + + async def async_step_update_trans_rules(self, user_input=None): + if not self._update_trans_rules: + return await self.async_step_update_lan_ctrl_config() + urn_list: list[str] = list({ + info['urn'] + for info in list(self._miot_client.device_list.values()) + if 'urn' in info}) + self._trans_rules_count = len(urn_list) + if not user_input: + return self.async_show_form( + step_id='update_trans_rules', + data_schema=vol.Schema({ + vol.Required('confirm', default=False): bool + }), + description_placeholders={ + 'urn_count': self._trans_rules_count, + }, + last_step=False + ) + if user_input.get('confirm', False): + # Update trans rules + if urn_list: + spec_parser: MIoTSpecParser = MIoTSpecParser( + lang=self._lang_new, storage=self._miot_storage) + await spec_parser.init_async() + self._trans_rules_count_success = ( + await spec_parser.refresh_async(urn_list=urn_list)) + await spec_parser.deinit_async() + else: + # SKIP update trans rules + self._update_trans_rules = False + + return await self.async_step_update_lan_ctrl_config() + + async def async_step_update_lan_ctrl_config(self, user_input=None): + if not self._update_lan_ctrl_config: + return await self.async_step_config_confirm() + if not user_input: + notice_net_dup: str = '' + lan_ctrl_config = await self._miot_storage.load_user_config_async( + 'global_config', 'all', ['net_interfaces', 'enable_subscribe']) + selected_if = lan_ctrl_config.get('net_interfaces', []) + enable_subscribe = lan_ctrl_config.get('enable_subscribe', False) + net_unavailable = self._miot_i18n.translate( + key='config.lan_ctrl_config.net_unavailable') + net_if = { + if_name: f'{if_name}: {net_unavailable}' + for if_name in selected_if} + net_info = await self._miot_network.get_network_info_async() + net_segs = set() + for if_name, info in net_info.items(): + net_if[if_name] = ( + f'{if_name} ({info.ip}/{info.netmask})') + net_segs.add(info.net_seg) + if len(net_segs) != len(net_info): + notice_net_dup = self._miot_i18n.translate( + key='config.lan_ctrl_config.notice_net_dup') + return self.async_show_form( + step_id='update_lan_ctrl_config', + data_schema=vol.Schema({ + vol.Required( + 'net_interfaces', default=selected_if + ): cv.multi_select(net_if), + vol.Required( + 'enable_subscribe', default=enable_subscribe): bool + }), + description_placeholders={ + 'notice_net_dup': notice_net_dup, + }, + last_step=False + ) + + selected_if_new: list = user_input.get('net_interfaces', []) + enable_subscribe_new: bool = user_input.get('enable_subscribe', False) + lan_ctrl_config = await self._miot_storage.load_user_config_async( + 'global_config', 'all', ['net_interfaces', 'enable_subscribe']) + selected_if = lan_ctrl_config.get('net_interfaces', []) + enable_subscribe = lan_ctrl_config.get('enable_subscribe', False) + if ( + set(selected_if_new) != set(selected_if) + or enable_subscribe_new != enable_subscribe + ): + if not await self._miot_storage.update_user_config_async( + 'global_config', 'all', { + 'net_interfaces': selected_if_new, + 'enable_subscribe': enable_subscribe_new} + ): + raise AbortFlow( + reason='storage_error', + description_placeholders={ + 'error': 'Update net config error'}) + await self._miot_lan.update_net_ifs_async(net_ifs=selected_if_new) + await self._miot_lan.update_subscribe_option( + enable_subscribe=enable_subscribe_new) + + return await self.async_step_config_confirm() + + async def async_step_config_confirm(self, user_input=None): + if not user_input or not user_input.get('confirm', False): + enable_text = self._miot_i18n.translate( + key='config.option_status.enable') + disable_text = self._miot_i18n.translate( + key='config.option_status.disable') + return self.async_show_form( + step_id='config_confirm', + data_schema=vol.Schema({ + vol.Required('confirm', default=False): bool + }), + description_placeholders={ + 'nick_name': self._nick_name, + 'lang_new': INTEGRATION_LANGUAGES[self._lang_new], + 'nick_name_new': self._nick_name_new, + 'devices_add': len(self._devices_add), + 'devices_remove': len(self._devices_remove), + 'trans_rules_count': self._trans_rules_count, + 'trans_rules_count_success': + self._trans_rules_count_success, + 'action_debug': ( + enable_text if self._action_debug_new + else disable_text), + 'hide_non_standard_entities': ( + enable_text if self._hide_non_standard_entities_new + else disable_text), + }, + errors={'base': 'not_confirm'} if user_input else {}, + last_step=True + ) + + self._entry_data['oauth_redirect_url'] = self._oauth_redirect_url + if self._lang_new != self._integration_language: + self._entry_data['integration_language'] = self._lang_new + self._need_reload = True + if self._update_user_info: + self._entry_data['nick_name'] = self._nick_name_new + if self._update_devices: + self._entry_data['ctrl_mode'] = self._ctrl_mode + self._entry_data['home_selected'] = self._home_selected_dict + devices_list_sort = dict(sorted( + self._device_list.items(), key=lambda item: + item[1].get('home_id', '')+item[1].get('room_id', ''))) + if not await self._miot_storage.save_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + data=devices_list_sort): + _LOGGER.error( + 'save devices async failed, %s, %s', + self._uid, self._cloud_server) + raise AbortFlow( + reason='storage_error', description_placeholders={ + 'error': 'save user devices error'}) + self._need_reload = True + if self._update_trans_rules: + self._need_reload = True + if self._action_debug_new != self._action_debug: + self._entry_data['action_debug'] = self._action_debug_new + self._need_reload = True + if ( + self._hide_non_standard_entities_new != + self._hide_non_standard_entities + ): + self._entry_data['hide_non_standard_entities'] = ( + self._hide_non_standard_entities_new) + self._need_reload = True + if ( + self._devices_remove + and not await self._miot_storage.update_user_config_async( + uid=self._uid, + cloud_server=self._cloud_server, + config={'devices_remove': self._devices_remove}) + ): + raise AbortFlow( + reason='storage_error', + description_placeholders={'error': 'Update user config error'}) + entry_title = ( + f'{self._nick_name_new or self._nick_name}: ' + f'{self._uid} [{CLOUD_SERVERS[self._cloud_server]}]') + # Update entry config + self.hass.config_entries.async_update_entry( + self._config_entry, title=entry_title, data=self._entry_data) + # Reload later + if self._need_reload: + self._main_loop.call_later( + 0, lambda: self._main_loop.create_task( + self.hass.config_entries.async_reload( + entry_id=self._config_entry.entry_id))) + return self.async_create_entry(title='', data={}) + + +async def handle_oauth_webhook(hass, webhook_id, request): + try: + data = dict(request.query) + if data.get('code', None) is None or data.get('state', None) is None: + raise MIoTConfigError('invalid oauth code') + + if data['state'] != hass.data[DOMAIN][webhook_id]['oauth_state']: + raise MIoTConfigError( + f'invalid oauth state, ' + f'{hass.data[DOMAIN][webhook_id]["oauth_state"]}, ' + f'{data["state"]}') + + fut_oauth_code: asyncio.Future = hass.data[DOMAIN][webhook_id].pop( + 'fut_oauth_code', None) + fut_oauth_code.set_result(data['code']) + _LOGGER.info('webhook code: %s', data['code']) + + return web.Response( + body=oauth_redirect_page( + hass.config.language, 'success'), content_type='text/html') + + except MIoTConfigError: + return web.Response( + body=oauth_redirect_page(hass.config.language, 'fail'), + content_type='text/html') diff --git a/custom_components/xiaomi_home/cover.py b/custom_components/xiaomi_home/cover.py new file mode 100644 index 0000000..0e6da09 --- /dev/null +++ b/custom_components/xiaomi_home/cover.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Cover entities for Xiaomi Home. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverEntity, + CoverEntityFeature, + CoverDeviceClass +) + +from .miot.miot_spec import MIoTSpecProperty +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('cover', []): + if data.spec.name == 'curtain': + data.spec.device_class = CoverDeviceClass.CURTAIN + elif data.spec.name == 'window-opener': + data.spec.device_class = CoverDeviceClass.WINDOW + new_entities.append( + Cover(miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class Cover(MIoTServiceEntity, CoverEntity): + """Cover entities for Xiaomi Home.""" + # pylint: disable=unused-argument + _prop_motor_control: Optional[MIoTSpecProperty] + _prop_motor_value_open: Optional[int] + _prop_motor_value_close: Optional[int] + _prop_motor_value_pause: Optional[int] + _prop_status: Optional[MIoTSpecProperty] + _prop_status_opening: Optional[bool] + _prop_status_closing: Optional[bool] + _prop_status_stop: Optional[bool] + _prop_current_position: Optional[MIoTSpecProperty] + _prop_target_position: Optional[MIoTSpecProperty] + _prop_position_value_min: Optional[int] + _prop_position_value_max: Optional[int] + _prop_position_value_range: Optional[int] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + """Initialize the Cover.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_device_class = entity_data.spec.device_class + self._attr_supported_color_modes = set() + self._attr_supported_features = CoverEntityFeature(0) + + self._prop_motor_control = None + self._prop_motor_value_open = None + self._prop_motor_value_close = None + self._prop_motor_value_pause = None + self._prop_status = None + self._prop_current_position = None + self._prop_target_position = None + self._prop_position_value_min = None + self._prop_position_value_max = None + self._prop_position_value_range = None + + # properties + for prop in entity_data.props: + if prop.name == 'motor-control': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'motor-control value_list is None, %s', self.entity_id) + continue + for item in prop.value_list: + if item['name'].lower() in ['open']: + self._attr_supported_features |= ( + CoverEntityFeature.OPEN) + self._prop_motor_value_open = item['value'] + elif item['name'].lower() in ['close']: + self._attr_supported_features |= ( + CoverEntityFeature.CLOSE) + self._prop_motor_value_close = item['value'] + elif item['name'].lower() in ['pause']: + self._attr_supported_features |= ( + CoverEntityFeature.STOP) + self._prop_motor_value_pause = item['value'] + self._prop_motor_control = prop + elif prop.name == 'status': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'status value_list is None, %s', self.entity_id) + continue + for item in prop.value_list: + if item['name'].lower() in ['opening']: + self._prop_status_opening = item['value'] + elif item['name'].lower() in ['closing']: + self._prop_status_closing = item['value'] + elif item['name'].lower() in ['stop']: + self._prop_status_stop = item['value'] + self._prop_status = prop + elif prop.name == 'current-position': + self._prop_current_position = prop + elif prop.name == 'target-position': + if not isinstance(prop.value_range, dict): + _LOGGER.error( + 'invalid target-position value_range format, %s', + self.entity_id) + continue + self._prop_position_value_min = prop.value_range['min'] + self._prop_position_value_max = prop.value_range['max'] + self._prop_position_value_range = ( + self._prop_position_value_max - + self._prop_position_value_min) + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + self._prop_target_position = prop + + async def async_open_cover(self, **kwargs) -> None: + """Open the cover.""" + await self.set_property_async( + self._prop_motor_control, self._prop_motor_value_open) + + async def async_close_cover(self, **kwargs) -> None: + """Close the cover.""" + await self.set_property_async( + self._prop_motor_control, self._prop_motor_value_close) + + async def async_stop_cover(self, **kwargs) -> None: + """Stop the cover.""" + await self.set_property_async( + self._prop_motor_control, self._prop_motor_value_pause) + + async def async_set_cover_position(self, **kwargs) -> None: + """Set the position of the cover.""" + pos = kwargs.get(ATTR_POSITION, None) + if pos is None: + return None + pos = round(pos*self._prop_position_value_range/100) + return await self.set_property_async( + prop=self._prop_target_position, value=pos) + + @property + def current_cover_position(self) -> Optional[int]: + """Return the current position. + + 0: the cover is closed, 100: the cover is fully opened, None: unknown. + """ + pos = self.get_prop_value(prop=self._prop_current_position) + if pos is None: + return None + return round(pos*100/self._prop_position_value_range) + + @property + def is_opening(self) -> Optional[bool]: + """Return if the cover is opening.""" + if self._prop_status is None: + return None + return self.get_prop_value( + prop=self._prop_status) == self._prop_status_opening + + @property + def is_closing(self) -> Optional[bool]: + """Return if the cover is closing.""" + if self._prop_status is None: + return None + return self.get_prop_value( + prop=self._prop_status) == self._prop_status_closing + + @property + def is_closed(self) -> Optional[bool]: + """Return if the cover is closed.""" + return self.get_prop_value(prop=self._prop_current_position) == 0 diff --git a/custom_components/xiaomi_home/event.py b/custom_components/xiaomi_home/event.py new file mode 100644 index 0000000..9b12526 --- /dev/null +++ b/custom_components/xiaomi_home/event.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Event entities for Xiaomi Home. +""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.event import EventEntity + +from .miot.miot_spec import MIoTSpecEvent +from .miot.miot_device import MIoTDevice, MIoTEventEntity +from .miot.const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for event in miot_device.event_list.get('event', []): + new_entities.append(Event(miot_device=miot_device, spec=event)) + + if new_entities: + async_add_entities(new_entities) + + +class Event(MIoTEventEntity, EventEntity): + """Event entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecEvent) -> None: + """Initialize the Event.""" + super().__init__(miot_device=miot_device, spec=spec) + # Set device_class + self._attr_device_class = spec.device_class + + def on_event_occurred(self, name: str, arguments: list[dict[int, any]]): + """An event is occurred.""" + self._trigger_event(event_type=name, event_attributes=arguments) diff --git a/custom_components/xiaomi_home/fan.py b/custom_components/xiaomi_home/fan.py new file mode 100644 index 0000000..adae99a --- /dev/null +++ b/custom_components/xiaomi_home/fan.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Fan entities for Xiaomi Home. +""" +from __future__ import annotations +from typing import Any, Optional +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage +) + +from .miot.miot_spec import MIoTSpecProperty +from .miot.const import DOMAIN +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('fan', []): + new_entities.append(Fan(miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class Fan(MIoTServiceEntity, FanEntity): + """Fan entities for Xiaomi Home.""" + # pylint: disable=unused-argument + _prop_on: Optional[MIoTSpecProperty] + _prop_fan_level: Optional[MIoTSpecProperty] + _prop_mode: Optional[MIoTSpecProperty] + _prop_horizontal_swing: Optional[MIoTSpecProperty] + + _speed_min: Optional[int] + _speed_max: Optional[int] + _speed_step: Optional[int] + _mode_list: Optional[dict[any, any]] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + """Initialize the Fan.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_preset_modes = [] + self._attr_supported_features = FanEntityFeature(0) + + self._prop_on = None + self._prop_fan_level = None + self._prop_mode = None + self._prop_horizontal_swing = None + self._speed_min = 65535 + self._speed_max = 0 + self._speed_step = 1 + self._mode_list = None + + # properties + for prop in entity_data.props: + if prop.name == 'on': + self._attr_supported_features |= FanEntityFeature.TURN_ON + self._attr_supported_features |= FanEntityFeature.TURN_OFF + self._prop_on = prop + elif prop.name == 'fan-level': + if isinstance(prop.value_range, dict): + # Fan level with value-range + self._speed_min = prop.value_range['min'] + self._speed_max = prop.value_range['max'] + self._speed_step = prop.value_range['step'] + self._attr_speed_count = self._speed_max - self._speed_min+1 + self._attr_supported_features |= FanEntityFeature.SET_SPEED + self._prop_fan_level = prop + elif ( + self._prop_fan_level is None + and isinstance(prop.value_list, list) + and prop.value_list + ): + # Fan level with value-list + for item in prop.value_list: + self._speed_min = min(self._speed_min, item['value']) + self._speed_max = max(self._speed_max, item['value']) + self._attr_speed_count = self._speed_max - self._speed_min+1 + self._attr_supported_features |= FanEntityFeature.SET_SPEED + self._prop_fan_level = prop + elif prop.name == 'mode': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'mode value_list is None, %s', self.entity_id) + continue + self._mode_list = { + item['value']: item['description'] + for item in prop.value_list} + self._attr_preset_modes = list(self._mode_list.values()) + self._attr_supported_features |= FanEntityFeature.PRESET_MODE + self._prop_mode = prop + elif prop.name == 'horizontal-swing': + self._attr_supported_features |= FanEntityFeature.OSCILLATE + self._prop_horizontal_swing = prop + + def __get_mode_description(self, key: int) -> Optional[str]: + if self._mode_list is None: + return None + return self._mode_list.get(key, None) + + def __get_mode_value(self, description: str) -> Optional[int]: + if self._mode_list is None: + return None + for key, value in self._mode_list.items(): + if value == description: + return key + return None + + async def async_turn_on( + self, percentage: int = None, preset_mode: str = None, **kwargs: Any + ) -> None: + """Turn the fan on. + + Shall set the percentage or the preset_mode attr to complying + if applicable. + """ + # on + await self.set_property_async(prop=self._prop_on, value=True) + # percentage + if percentage: + await self.set_property_async( + prop=self._prop_fan_level, + value=int(percentage*self._attr_speed_count/100)) + # preset_mode + if preset_mode: + await self.set_property_async( + self._prop_mode, + value=self.__get_mode_value(description=preset_mode)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.set_property_async(prop=self._prop_on, value=False) + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the fan.""" + await self.set_property_async(prop=self._prop_on, value=not self.is_on) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the percentage of the fan speed.""" + if percentage > 0: + await self.set_property_async( + prop=self._prop_fan_level, + value=int(percentage_to_ranged_value( + low_high_range=(self._speed_min, self._speed_max), + percentage=percentage))) + if not self.is_on: + # If the fan is off, turn it on. + await self.set_property_async(prop=self._prop_on, value=True) + else: + await self.set_property_async(prop=self._prop_on, value=False) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + await self.set_property_async( + self._prop_mode, + value=self.__get_mode_value(description=preset_mode)) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self.set_property_async( + prop=self._prop_horizontal_swing, value=oscillating) + + @property + def is_on(self) -> Optional[bool]: + """Return if the fan is on. """ + return self.get_prop_value( + prop=self._prop_on) if self._prop_on else None + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, + e.g., auto, smart, eco, favorite.""" + return ( + self.__get_mode_description( + key=self.get_prop_value(prop=self._prop_mode)) + if self._prop_mode else None) + + @property + def percentage(self) -> Optional[int]: + """Return the current percentage of the fan speed.""" + fan_level = self.get_prop_value(prop=self._prop_fan_level) + return ranged_value_to_percentage( + low_high_range=(self._speed_min, self._speed_max), + value=fan_level) if fan_level else None + + @property + def oscillating(self) -> Optional[bool]: + """Return if the fan is oscillating.""" + return ( + self.get_prop_value( + prop=self._prop_horizontal_swing) + if self._prop_horizontal_swing else None) + + @property + def percentage_step(self) -> float: + """Return the step of the fan speed.""" + return self._speed_step diff --git a/custom_components/xiaomi_home/humidifier.py b/custom_components/xiaomi_home/humidifier.py new file mode 100644 index 0000000..62cd9cd --- /dev/null +++ b/custom_components/xiaomi_home/humidifier.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Humidifier entities for Xiaomi Home. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.humidifier import ( + HumidifierEntity, + HumidifierDeviceClass, + HumidifierEntityFeature +) + +from .miot.miot_spec import MIoTSpecProperty +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('humidifier', []): + data.device_class = HumidifierDeviceClass.HUMIDIFIER + new_entities.append( + Humidifier(miot_device=miot_device, entity_data=data)) + for data in miot_device.entity_list.get('dehumidifier', []): + data.device_class = HumidifierDeviceClass.DEHUMIDIFIER + new_entities.append(Humidifier( + miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class Humidifier(MIoTServiceEntity, HumidifierEntity): + """Humidifier entities for Xiaomi Home.""" + # pylint: disable=unused-argument + _prop_on: Optional[MIoTSpecProperty] + _prop_mode: Optional[MIoTSpecProperty] + _prop_target_humidity: Optional[MIoTSpecProperty] + _prop_humidity: Optional[MIoTSpecProperty] + + _mode_list: dict[any, any] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + """Initialize the Humidifier.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_device_class = entity_data.device_class + self._attr_supported_features = HumidifierEntityFeature(0) + self._prop_on = None + self._prop_mode = None + self._prop_target_humidity = None + self._prop_humidity = None + self._mode_list = None + + # properties + for prop in entity_data.props: + # on + if prop.name == 'on': + self._prop_on = prop + # target-humidity + elif prop.name == 'target-humidity': + if not isinstance(prop.value_range, dict): + _LOGGER.error( + 'invalid target-humidity value_range format, %s', + self.entity_id) + continue + self._attr_min_humidity = prop.value_range['min'] + self._attr_max_humidity = prop.value_range['max'] + self._prop_target_humidity = prop + # mode + elif prop.name == 'mode': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'mode value_list is None, %s', self.entity_id) + continue + self._mode_list = { + item['value']: item['description'] + for item in prop.value_list} + self._attr_available_modes = list( + self._mode_list.values()) + self._attr_supported_features |= HumidifierEntityFeature.MODES + self._prop_mode = prop + # relative-humidity + elif prop.name == 'relative-humidity': + self._prop_humidity = prop + + async def async_turn_on(self, **kwargs): + """Turn the humidifier on.""" + await self.set_property_async(prop=self._prop_on, value=True) + + async def async_turn_off(self, **kwargs): + """Turn the humidifier off.""" + await self.set_property_async(prop=self._prop_on, value=False) + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + await self.set_property_async( + prop=self._prop_target_humidity, value=humidity) + + async def async_set_mode(self, mode: str) -> None: + """Set new target preset mode.""" + await self.set_property_async( + prop=self._prop_mode, value=self.__get_mode_value(description=mode)) + + @property + def is_on(self) -> Optional[bool]: + """Return if the humidifier is on.""" + return self.get_prop_value(prop=self._prop_on) + + @property + def current_humidity(self) -> Optional[int]: + """Return the current humidity.""" + return self.get_prop_value(prop=self._prop_humidity) + + @property + def target_humidity(self) -> Optional[int]: + """Return the target humidity.""" + return self.get_prop_value(prop=self._prop_target_humidity) + + @property + def mode(self) -> Optional[str]: + """Return the current preset mode.""" + return self.__get_mode_description( + key=self.get_prop_value(prop=self._prop_mode)) + + def __get_mode_description(self, key: int) -> Optional[str]: + """Convert mode value to description.""" + if self._mode_list is None: + return None + return self._mode_list.get(key, None) + + def __get_mode_value(self, description: str) -> Optional[int]: + """Convert mode description to value.""" + if self._mode_list is None: + return None + for key, value in self._mode_list.items(): + if value == description: + return key + return None diff --git a/custom_components/xiaomi_home/light.py b/custom_components/xiaomi_home/light.py new file mode 100644 index 0000000..a11710b --- /dev/null +++ b/custom_components/xiaomi_home/light.py @@ -0,0 +1,300 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Light entities for Xiaomi Home. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_HS_COLOR, + ATTR_EFFECT, + LightEntity, + LightEntityFeature, + ColorMode +) +from homeassistant.util.color import ( + value_to_brightness, + brightness_to_value, + color_hs_to_RGB +) + +from .miot.miot_spec import MIoTSpecProperty +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('light', []): + new_entities.append( + Light(miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class Light(MIoTServiceEntity, LightEntity): + """Light entities for Xiaomi Home.""" + _prop_on: Optional[MIoTSpecProperty] + _prop_brightness: Optional[MIoTSpecProperty] + _prop_color_temp: Optional[MIoTSpecProperty] + _prop_color: Optional[MIoTSpecProperty] + _prop_mode: Optional[MIoTSpecProperty] + + _brightness_scale: Optional[tuple[int, int]] + _mode_list: Optional[dict[any, any]] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + """Initialize the Light.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_color_mode = None + self._attr_supported_color_modes = set() + self._attr_supported_features = LightEntityFeature(0) + if miot_device.did.startswith('group.'): + self._attr_icon = 'mdi:lightbulb-group' + + self._prop_on = None + self._prop_brightness = None + self._prop_color_temp = None + self._prop_color = None + self._prop_mode = None + self._brightness_scale = None + self._mode_list = None + + # properties + for prop in entity_data.props: + # on + if prop.name == 'on': + self._prop_on = prop + # brightness + if prop.name == 'brightness': + if isinstance(prop.value_range, dict): + self._brightness_scale = ( + prop.value_range['min'], prop.value_range['max']) + self._prop_brightness = prop + elif ( + self._mode_list is None + and isinstance(prop.value_list, list) + and prop.value_list + ): + # For value-list brightness + self._mode_list = { + item['value']: item['description'] + for item in prop.value_list} + self._attr_effect_list = list(self._mode_list.values()) + self._attr_supported_features |= LightEntityFeature.EFFECT + self._prop_mode = prop + else: + _LOGGER.error( + 'invalid brightness format, %s', self.entity_id) + continue + # color-temperature + if prop.name == 'color-temperature': + if not isinstance(prop.value_range, dict): + _LOGGER.error( + 'invalid color-temperature value_range format, %s', + self.entity_id) + continue + self._attr_min_color_temp_kelvin = prop.value_range['min'] + self._attr_max_color_temp_kelvin = prop.value_range['max'] + self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) + self._attr_color_mode = ColorMode.COLOR_TEMP + self._prop_color_temp = prop + # color + if prop.name == 'color': + self._attr_supported_color_modes.add(ColorMode.RGB) + self._attr_color_mode = ColorMode.RGB + self._prop_color = prop + # mode + if prop.name == 'mode': + mode_list = None + if ( + isinstance(prop.value_list, list) + and prop.value_list + ): + mode_list = { + item['value']: item['description'] + for item in prop.value_list} + elif isinstance(prop.value_range, dict): + mode_list = {} + for value in range( + prop.value_range['min'], prop.value_range['max']): + mode_list[value] = f'{value}' + if mode_list: + self._mode_list = mode_list + self._attr_effect_list = list(self._mode_list.values()) + self._attr_supported_features |= LightEntityFeature.EFFECT + self._prop_mode = prop + else: + _LOGGER.error('invalid mode format, %s', self.entity_id) + continue + + if not self._attr_supported_color_modes: + if self._prop_brightness: + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + self._attr_color_mode = ColorMode.BRIGHTNESS + elif self._prop_on: + self._attr_supported_color_modes.add(ColorMode.ONOFF) + self._attr_color_mode = ColorMode.ONOFF + + def __get_mode_description(self, key: int) -> Optional[str]: + """Convert mode value to description.""" + if self._mode_list is None: + return None + return self._mode_list.get(key, None) + + def __get_mode_value(self, description: str) -> Optional[int]: + """Convert mode description to value.""" + if self._mode_list is None: + return None + for key, value in self._mode_list.items(): + if value == description: + return key + return None + + @property + def is_on(self) -> Optional[bool]: + """Return if the light is on.""" + value_on = self.get_prop_value(prop=self._prop_on) + # Dirty logic for lumi.gateway.mgl03 indicator light + if isinstance(value_on, int): + value_on = value_on == 1 + return value_on + + @property + def brightness(self) -> Optional[int]: + """Return the brightness.""" + brightness_value = self.get_prop_value(prop=self._prop_brightness) + if brightness_value is None: + return None + return value_to_brightness(self._brightness_scale, brightness_value) + + @property + def color_temp_kelvin(self) -> Optional[int]: + """Return the color temperature.""" + return self.get_prop_value(prop=self._prop_color_temp) + + @ property + def rgb_color(self) -> Optional[tuple[int, int, int]]: + """Return the rgb color value.""" + rgb = self.get_prop_value(prop=self._prop_color) + if rgb is None: + return None + r = (rgb >> 16) & 0xFF + g = (rgb >> 8) & 0xFF + b = rgb & 0xFF + return r, g, b + + @ property + def effect(self) -> Optional[str]: + """Return the current mode.""" + return self.__get_mode_description( + key=self.get_prop_value(prop=self._prop_mode)) + + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on. + + Shall set attributes in kwargs if applicable. + """ + result: bool = False + # on + # Dirty logic for lumi.gateway.mgl03 indicator light + value_on = True if self._prop_on.format_ == 'bool' else 1 + result = await self.set_property_async( + prop=self._prop_on, value=value_on) + # brightness + if ATTR_BRIGHTNESS in kwargs: + brightness = brightness_to_value( + self._brightness_scale, kwargs[ATTR_BRIGHTNESS]) + result = await self.set_property_async( + prop=self._prop_brightness, value=brightness) + # color-temperature + if ATTR_COLOR_TEMP_KELVIN in kwargs: + result = await self.set_property_async( + prop=self._prop_color_temp, + value=kwargs[ATTR_COLOR_TEMP_KELVIN]) + self._attr_color_mode = ColorMode.COLOR_TEMP + # rgb color + if ATTR_RGB_COLOR in kwargs: + r = kwargs[ATTR_RGB_COLOR][0] + g = kwargs[ATTR_RGB_COLOR][1] + b = kwargs[ATTR_RGB_COLOR][2] + rgb = (r << 16) | (g << 8) | b + result = await self.set_property_async( + prop=self._prop_color, value=rgb) + self._attr_color_mode = ColorMode.RGB + # mode + if ATTR_EFFECT in kwargs: + result = await self.set_property_async( + prop=self._prop_mode, + value=self.__get_mode_value(description=kwargs[ATTR_EFFECT])) + return result + + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + # Dirty logic for lumi.gateway.mgl03 indicator light + value_on = False if self._prop_on.format_ == 'bool' else 0 + return await self.set_property_async(prop=self._prop_on, value=value_on) diff --git a/custom_components/xiaomi_home/manifest.json b/custom_components/xiaomi_home/manifest.json new file mode 100644 index 0000000..67ff027 --- /dev/null +++ b/custom_components/xiaomi_home/manifest.json @@ -0,0 +1,32 @@ +{ + "domain": "xiaomi_home", + "name": "Xiaomi Home", + "codeowners": [ + "@XiaoMi" + ], + "config_flow": true, + "dependencies": [ + "http", + "persistent_notification", + "ffmpeg", + "zeroconf" + ], + "documentation": "https://github.com/XiaoMi/ha_xiaomi_home/blob/main/README.md", + "integration_type": "hub", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/XiaoMi/ha_xiaomi_home/issues", + "loggers": [ + "Xiaomi Home" + ], + "requirements": [ + "construct>=2.10.56", + "paho-mqtt<=2.0.0", + "numpy", + "cryptography", + "psutil" + ], + "version": "v0.1.0", + "zeroconf": [ + "_miot-central._tcp.local." + ] +} diff --git a/custom_components/xiaomi_home/miot/common.py b/custom_components/xiaomi_home/miot/common.py new file mode 100644 index 0000000..5a5137d --- /dev/null +++ b/custom_components/xiaomi_home/miot/common.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Common utilities. +""" +import json +import random +from typing import Optional +import hashlib +from paho.mqtt.client import MQTTMatcher + + +def calc_group_id(uid: str, home_id: str) -> str: + """Calculate the group ID based on a user ID and a home ID.""" + return hashlib.sha1( + f'{uid}central_service{home_id}'.encode('utf-8')).hexdigest()[:16] + + +def load_json_file(json_file: str) -> dict: + """Load a JSON file.""" + with open(json_file, 'r', encoding='utf-8') as f: + return json.load(f) + + +def randomize_int(value: int, ratio: float) -> int: + """Randomize an integer value.""" + return int(value * (1 - ratio + random.random()*2*ratio)) + + +class MIoTMatcher(MQTTMatcher): + """MIoT Pub/Sub topic matcher.""" + + def iter_all_nodes(self) -> any: + """Return an iterator on all nodes with their paths and contents.""" + def rec(node, path): + # pylint: disable=protected-access + if node._content: + yield ('/'.join(path), node._content) + for part, child in node._children.items(): + yield from rec(child, path + [part]) + return rec(self._root, []) + + def get(self, topic: str) -> Optional[any]: + try: + return self[topic] + except KeyError: + return None diff --git a/custom_components/xiaomi_home/miot/const.py b/custom_components/xiaomi_home/miot/const.py new file mode 100644 index 0000000..7aec73d --- /dev/null +++ b/custom_components/xiaomi_home/miot/const.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Constants. +""" +DOMAIN: str = 'xiaomi_home' +DEFAULT_NAME: str = 'Xiaomi Home' + +DEFAULT_NICK_NAME: str = 'Xiaomi' + +MIHOME_HTTP_API_TIMEOUT: int = 30 +MIHOME_MQTT_KEEPALIVE: int = 60 +# seconds, 3 days +MIHOME_CERT_EXPIRE_MARGIN: int = 3600*24*3 + +NETWORK_REFRESH_INTERVAL: int = 30 + +OAUTH2_CLIENT_ID: str = '2882303761520251711' +OAUTH2_AUTH_URL: str = 'https://account.xiaomi.com/oauth2/authorize' +DEFAULT_OAUTH2_API_HOST: str = 'ha.api.io.mi.com' + +# seconds, 14 days +SPEC_STD_LIB_EFFECTIVE_TIME = 3600*24*14 +# seconds, 14 days +MANUFACTURER_EFFECTIVE_TIME = 3600*24*14 + +SUPPORTED_PLATFORMS: list = [ + # 'alarm_control_panel', + 'binary_sensor', + 'button', + 'climate', + # 'camera', + # 'conversation', + 'cover', + # 'device_tracker', + 'event', + 'fan', + 'humidifier', + 'light', + # 'lock', + # 'media_player', + 'notify', + 'number', + # 'remote', + # 'scene', + 'select', + 'sensor', + 'switch', + 'text', + 'vacuum', + 'water_heater', +] + +DEFAULT_CLOUD_SERVER: str = 'cn' +CLOUD_SERVERS: dict = { + 'cn': '中国大陆', + 'de': 'Europe', + 'i2': 'India', + 'ru': 'Russia', + 'sg': 'Singapore', + 'us': 'United States' +} + +SUPPORT_CENTRAL_GATEWAY_CTRL: list = ['cn'] + +DEFAULT_INTEGRATION_LANGUAGE: str = 'en' +INTEGRATION_LANGUAGES = { + 'zh-Hans': '简体中文', + 'zh-Hant': '繁體中文', + 'en': 'English', + 'es': 'Español', + 'ru': 'Русский', + 'fr': 'Français', + 'de': 'Deutsch', + 'ja': '日本語' +} + +DEFAULT_CTRL_MODE: str = 'auto' + +# Registered in Xiaomi OAuth 2.0 Service +# DO NOT CHANGE UNLESS YOU HAVE AN ADMINISTRATOR PERMISSION +OAUTH_REDIRECT_URL: str = 'http://homeassistant.local:8123' + +MIHOME_CA_CERT_STR: str = '-----BEGIN CERTIFICATE-----\n' \ + 'MIIBazCCAQ+gAwIBAgIEA/UKYDAMBggqhkjOPQQDAgUAMCIxEzARBgNVBAoTCk1p\n' \ + 'amlhIFJvb3QxCzAJBgNVBAYTAkNOMCAXDTE2MTEyMzAxMzk0NVoYDzIwNjYxMTEx\n' \ + 'MDEzOTQ1WjAiMRMwEQYDVQQKEwpNaWppYSBSb290MQswCQYDVQQGEwJDTjBZMBMG\n' \ + 'ByqGSM49AgEGCCqGSM49AwEHA0IABL71iwLa4//4VBqgRI+6xE23xpovqPCxtv96\n' \ + '2VHbZij61/Ag6jmi7oZ/3Xg/3C+whglcwoUEE6KALGJ9vccV9PmjLzAtMAwGA1Ud\n' \ + 'EwQFMAMBAf8wHQYDVR0OBBYEFJa3onw5sblmM6n40QmyAGDI5sURMAwGCCqGSM49\n' \ + 'BAMCBQADSAAwRQIgchciK9h6tZmfrP8Ka6KziQ4Lv3hKfrHtAZXMHPda4IYCIQCG\n' \ + 'az93ggFcbrG9u2wixjx1HKW4DUA5NXZG0wWQTpJTbQ==\n' \ + '-----END CERTIFICATE-----\n' \ + '-----BEGIN CERTIFICATE-----\n' \ + 'MIIBjzCCATWgAwIBAgIBATAKBggqhkjOPQQDAjAiMRMwEQYDVQQKEwpNaWppYSBS\n' \ + 'b290MQswCQYDVQQGEwJDTjAgFw0yMjA2MDkxNDE0MThaGA8yMDcyMDUyNzE0MTQx\n' \ + 'OFowLDELMAkGA1UEBhMCQ04xHTAbBgNVBAoMFE1JT1QgQ0VOVFJBTCBHQVRFV0FZ\n' \ + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEdYrzbnp/0x/cZLZnuEDXTFf8mhj4\n' \ + 'CVpZPwgj9e9Ve5r3K7zvu8Jjj7JF1JjQYvEC6yhp1SzBgglnK4L8xQzdiqNQME4w\n' \ + 'HQYDVR0OBBYEFCf9+YBU7pXDs6K6CAQPRhlGJ+cuMB8GA1UdIwQYMBaAFJa3onw5\n' \ + 'sblmM6n40QmyAGDI5sURMAwGA1UdEwQFMAMBAf8wCgYIKoZIzj0EAwIDSAAwRQIh\n' \ + 'AKUv+c8v98vypkGMTzMwckGjjVqTef8xodsy6PhcSCq+AiA/n9mDs62hAo5zXyJy\n' \ + 'Bs1s7mqXPf1XgieoxIvs1MqyiA==\n' \ + '-----END CERTIFICATE-----\n' + +MIHOME_CA_CERT_SHA256: str = \ + '8b7bf306be3632e08b0ead308249e5f2b2520dc921ad143872d5fcc7c68d6759' diff --git a/custom_components/xiaomi_home/miot/i18n/de.json b/custom_components/xiaomi_home/miot/i18n/de.json new file mode 100644 index 0000000..afcea1d --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/de.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "Geräte", + "found_central_gateway": ", lokales zentrales Gateway gefunden" + }, + "control_mode": { + "auto": "automatisch", + "cloud": "Cloud" + }, + "room_name_rule": { + "none": "nicht synchronisieren", + "home_room": "Hausname und Raumname (Xiaomi Home Schlafzimmer)", + "room": "Raumname (Schlafzimmer)", + "home": "Hausname (Xiaomi Home)" + }, + "option_status": { + "enable": "aktivieren", + "disable": "deaktivieren" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Hinweis]** Es wurden mehrere Netzwerkkarten erkannt, die möglicherweise mit demselben Netzwerk verbunden sind. Bitte achten Sie auf die Auswahl.", + "net_unavailable": "Schnittstelle nicht verfügbar" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "Ungültige Authentifizierungsinformationen, Cloud-Verbindung nicht verfügbar, bitte betreten Sie die Xiaomi Home-Integrationsseite und klicken Sie auf 'Optionen', um die Authentifizierung erneut durchzuführen", + "invalid_device_cache": "Ungültige Gerätecache-Informationen, bitte betreten Sie die Xiaomi Home-Integrationsseite und klicken Sie auf 'Optionen->Geräteliste aktualisieren', um den lokalen Gerätecache zu aktualisieren", + "invalid_cert_info": "Ungültiges Benutzerzertifikat, lokale zentrale Verbindung nicht verfügbar, bitte betreten Sie die Xiaomi Home-Integrationsseite und klicken Sie auf 'Optionen', um die Authentifizierung erneut durchzuführen", + "device_cloud_error": "Fehler beim Abrufen von Geräteinformationen aus der Cloud, bitte überprüfen Sie die lokale Netzwerkverbindung", + "xiaomi_home_error_title": "Xiaomi Home-Integrationsfehler", + "xiaomi_home_error": "Fehler **{nick_name}({uid}, {cloud_server})** festgestellt, bitte betreten Sie die Optionen-Seite, um die Konfiguration erneut durchzuführen.\n\n**Fehlermeldung**: \n{message}", + "device_list_changed_title": "Xiaomi Home-Geräteliste geändert", + "device_list_changed": "Änderung der Geräteinformationen **{nick_name}({uid}, {cloud_server})** festgestellt, bitte betreten Sie die Integrations-Optionen-Seite, klicken Sie auf 'Optionen->Geräteliste aktualisieren', um den lokalen Gerätecache zu aktualisieren.\n\nAktueller Netzwerkstatus: {network_status}\n{message}\n", + "device_list_add": "\n**{count} neue Geräte:** \n{message}", + "device_list_del": "\n**{count} Geräte nicht verfügbar:** \n{message}", + "device_list_offline": "\n**{count} Geräte offline:** \n{message}", + "network_status_online": "Online", + "network_status_offline": "Offline", + "device_exec_error": "Fehler bei der Ausführung" + } + }, + "error": { + "common": { + "-10000": "Unbekannter Fehler", + "-10001": "Dienst nicht verfügbar", + "-10002": "Ungültiger Parameter", + "-10003": "Unzureichende Ressourcen", + "-10004": "Interner Fehler", + "-10005": "Unzureichende Berechtigungen", + "-10006": "Ausführungszeitüberschreitung", + "-10007": "Gerät offline oder nicht vorhanden", + "-10020": "Nicht autorisiert (OAuth2)", + "-10030": "Ungültiges Token (HTTP)", + "-10040": "Ungültiges Nachrichtenformat", + "-10050": "Ungültiges Zertifikat", + "-704000000": "Unbekannter Fehler", + "-704010000": "Nicht autorisiert (Gerät wurde möglicherweise gelöscht)", + "-704014006": "Gerätebeschreibung nicht gefunden", + "-704030013": "Eigenschaft nicht lesbar", + "-704030023": "Eigenschaft nicht beschreibbar", + "-704030033": "Eigenschaft nicht abonnierbar", + "-704040002": "Dienst existiert nicht", + "-704040003": "Eigenschaft existiert nicht", + "-704040004": "Ereignis existiert nicht", + "-704040005": "Aktion existiert nicht", + "-704040999": "Funktion nicht online", + "-704042001": "Gerät existiert nicht", + "-704042011": "Gerät offline", + "-704053036": "Gerätebetrieb zeitüberschreitung", + "-704053100": "Gerät kann diese Operation im aktuellen Zustand nicht ausführen", + "-704083036": "Gerätebetrieb zeitüberschreitung", + "-704090001": "Gerät existiert nicht", + "-704220008": "Ungültige ID", + "-704220025": "Aktionsparameteranzahl stimmt nicht überein", + "-704220035": "Aktionsparameterfehler", + "-704220043": "Eigenschaftswertfehler", + "-704222034": "Aktionsrückgabewertfehler", + "-705004000": "Unbekannter Fehler", + "-705004501": "Unbekannter Fehler", + "-705201013": "Eigenschaft nicht lesbar", + "-705201015": "Aktionsausführungsfehler", + "-705201023": "Eigenschaft nicht beschreibbar", + "-705201033": "Eigenschaft nicht abonnierbar", + "-706012000": "Unbekannter Fehler", + "-706012013": "Eigenschaft nicht lesbar", + "-706012015": "Aktionsausführungsfehler", + "-706012023": "Eigenschaft nicht beschreibbar", + "-706012033": "Eigenschaft nicht abonnierbar", + "-706012043": "Eigenschaftswertfehler", + "-706014006": "Gerätebeschreibung nicht gefunden" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/en.json b/custom_components/xiaomi_home/miot/i18n/en.json new file mode 100644 index 0000000..49430b3 --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/en.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "Devices", + "found_central_gateway": ", Found Local Central Hub Gateway" + }, + "control_mode": { + "auto": "Auto", + "cloud": "Cloud" + }, + "room_name_rule": { + "none": "Do not synchronize", + "home_room": "Home Name and Room Name (Xiaomi Home Bedroom)", + "room": "Room Name (Bedroom)", + "home": "Home Name (Xiaomi Home)" + }, + "option_status": { + "enable": "Enable", + "disable": "Disable" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Notice]** Multiple network cards detected that may be connected to the same network. Please pay attention to the selection.", + "net_unavailable": "Interface unavailable" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "Authentication information is invalid, cloud link will be unavailable, please enter the Xiaomi Home integration page, click 'Options' to re-authenticate", + "invalid_device_cache": "Cache device information is abnormal, please enter the Xiaomi Home integration page, click 'Options->Update device list', update the local cache", + "invalid_cert_info": "Invalid user certificate, local central link will be unavailable, please enter the Xiaomi Home integration page, click 'Options' to re-authenticate", + "device_cloud_error": "An exception occurred when obtaining device information from the cloud, please check the local network connection", + "xiaomi_home_error_title": "Xiaomi Home Integration Error", + "xiaomi_home_error": "Detected **{nick_name}({uid}, {cloud_server})** error, please enter the options page to reconfigure.\n\n**Error message**: \n{message}", + "device_list_changed_title": "Xiaomi Home device list changes", + "device_list_changed": "Detected **{nick_name}({uid}, {cloud_server})** device information has changed, please enter the integration options page, click `Options->Update device list`, update local device information.\n\nCurrent network status: {network_status}\n{message}\n", + "device_list_add": "\n**{count} new devices:** \n{message}", + "device_list_del": "\n**{count} devices unavailable:** \n{message}", + "device_list_offline": "\n**{count} devices offline:** \n{message}", + "network_status_online": "Online", + "network_status_offline": "Offline", + "device_exec_error": "Execution error" + } + }, + "error": { + "common": { + "-10000": "Unknown error", + "-10001": "Service unavailable", + "-10002": "Invalid parameter", + "-10003": "Insufficient resources", + "-10004": "Internal error", + "-10005": "Insufficient permissions", + "-10006": "Execution timeout", + "-10007": "Device offline or does not exist", + "-10020": "Unauthorized (OAuth2)", + "-10030": "Invalid token (HTTP)", + "-10040": "Invalid message format", + "-10050": "Invalid certificate", + "-704000000": "Unknown error", + "-704010000": "Unauthorized (device may have been deleted)", + "-704014006": "Device description not found", + "-704030013": "Property not readable", + "-704030023": "Property not writable", + "-704030033": "Property not subscribable", + "-704040002": "Service does not exist", + "-704040003": "Property does not exist", + "-704040004": "Event does not exist", + "-704040005": "Action does not exist", + "-704040999": "Feature not online", + "-704042001": "Device does not exist", + "-704042011": "Device offline", + "-704053036": "Device operation timeout", + "-704053100": "Device cannot perform this operation in the current state", + "-704083036": "Device operation timeout", + "-704090001": "Device does not exist", + "-704220008": "Invalid ID", + "-704220025": "Action parameter count mismatch", + "-704220035": "Action parameter error", + "-704220043": "Property value error", + "-704222034": "Action return value error", + "-705004000": "Unknown error", + "-705004501": "Unknown error", + "-705201013": "Property not readable", + "-705201015": "Action execution error", + "-705201023": "Property not writable", + "-705201033": "Property not subscribable", + "-706012000": "Unknown error", + "-706012013": "Property not readable", + "-706012015": "Action execution error", + "-706012023": "Property not writable", + "-706012033": "Property not subscribable", + "-706012043": "Property value error", + "-706014006": "Device description not found" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/es.json b/custom_components/xiaomi_home/miot/i18n/es.json new file mode 100644 index 0000000..5ca9862 --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/es.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "dispositivos", + "found_central_gateway": ", se encontró la puerta de enlace central local" + }, + "control_mode": { + "auto": "automático", + "cloud": "nube" + }, + "room_name_rule": { + "none": "no sincronizar", + "home_room": "nombre de la casa y nombre de la habitación (Xiaomi Home Dormitorio)", + "room": "nombre de la habitación (Dormitorio)", + "home": "nombre de la casa (Xiaomi Home)" + }, + "option_status": { + "enable": "habilitar", + "disable": "deshabilitar" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Aviso]** Se detectaron varias tarjetas de red que pueden estar conectadas a la misma red. Por favor, preste atención a la selección.", + "net_unavailable": "Interfaz no disponible" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "La información de autenticación es inválida, la conexión en la nube no estará disponible, por favor, vaya a la página de integración de Xiaomi Home, haga clic en 'Opciones' para volver a autenticar", + "invalid_device_cache": "La información de caché del dispositivo es anormal, por favor, vaya a la página de integración de Xiaomi Home, haga clic en 'Opciones -> Actualizar lista de dispositivos' para actualizar la información del dispositivo local", + "invalid_cert_info": "Certificado de usuario inválido, la conexión del centro local no estará disponible, por favor, vaya a la página de integración de Xiaomi Home, haga clic en 'Opciones' para volver a autenticar", + "device_cloud_error": "Error al obtener la información del dispositivo desde la nube, por favor, compruebe la conexión de red local", + "xiaomi_home_error_title": "Error de integración de Xiaomi Home", + "xiaomi_home_error": "Se detectó un error en **{nick_name}({uid}, {cloud_server})**, por favor, vaya a la página de opciones para reconfigurar.\n\n**Mensaje de error**: \n{message}", + "device_list_changed_title": "Cambio en la lista de dispositivos de Xiaomi Home", + "device_list_changed": "Se detectó un cambio en la información del dispositivo **{nick_name}({uid}, {cloud_server})**, por favor, vaya a la página de integración, haga clic en 'Opciones -> Actualizar lista de dispositivos' para actualizar la información del dispositivo local.\n\nEstado actual de la red: {network_status}\n{message}\n", + "device_list_add": "\n**{count} nuevos dispositivos:** \n{message}", + "device_list_del": "\n**{count} dispositivos no disponibles:** \n{message}", + "device_list_offline": "\n**{count} dispositivos sin conexión:** \n{message}", + "network_status_online": "En línea", + "network_status_offline": "Desconectado", + "device_exec_error": "Error de ejecución" + } + }, + "error": { + "common": { + "-10000": "Error desconocido", + "-10001": "Servicio no disponible", + "-10002": "Parámetro inválido", + "-10003": "Recursos insuficientes", + "-10004": "Error interno", + "-10005": "Permisos insuficientes", + "-10006": "Tiempo de ejecución agotado", + "-10007": "Dispositivo fuera de línea o no existe", + "-10020": "No autorizado (OAuth2)", + "-10030": "Token inválido (HTTP)", + "-10040": "Formato de mensaje inválido", + "-10050": "Certificado inválido", + "-704000000": "Error desconocido", + "-704010000": "No autorizado (el dispositivo puede haber sido eliminado)", + "-704014006": "Descripción del dispositivo no encontrada", + "-704030013": "Propiedad no legible", + "-704030023": "Propiedad no escribible", + "-704030033": "Propiedad no suscribible", + "-704040002": "Servicio no existe", + "-704040003": "Propiedad no existe", + "-704040004": "Evento no existe", + "-704040005": "Acción no existe", + "-704040999": "Función no en línea", + "-704042001": "Dispositivo no existe", + "-704042011": "Dispositivo fuera de línea", + "-704053036": "Tiempo de operación del dispositivo agotado", + "-704053100": "El dispositivo no puede realizar esta operación en el estado actual", + "-704083036": "Tiempo de operación del dispositivo agotado", + "-704090001": "Dispositivo no existe", + "-704220008": "ID inválido", + "-704220025": "Número de parámetros de acción no coincide", + "-704220035": "Error de parámetro de acción", + "-704220043": "Error de valor de propiedad", + "-704222034": "Error de valor de retorno de acción", + "-705004000": "Error desconocido", + "-705004501": "Error desconocido", + "-705201013": "Propiedad no legible", + "-705201015": "Error de ejecución de acción", + "-705201023": "Propiedad no escribible", + "-705201033": "Propiedad no suscribible", + "-706012000": "Error desconocido", + "-706012013": "Propiedad no legible", + "-706012015": "Error de ejecución de acción", + "-706012023": "Propiedad no escribible", + "-706012033": "Propiedad no suscribible", + "-706012043": "Error de valor de propiedad", + "-706014006": "Descripción del dispositivo no encontrada" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/fr.json b/custom_components/xiaomi_home/miot/i18n/fr.json new file mode 100644 index 0000000..677bf27 --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/fr.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "appareils", + "found_central_gateway": ", passerelle centrale locale trouvée" + }, + "control_mode": { + "auto": "automatique", + "cloud": "cloud" + }, + "room_name_rule": { + "none": "ne pas synchroniser", + "home_room": "nom de la maison et nom de la pièce (Xiaomi Home Chambre)", + "room": "nom de la pièce (Chambre)", + "home": "nom de la maison (Xiaomi Home)" + }, + "option_status": { + "enable": "activer", + "disable": "désactiver" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Remarque]** Plusieurs cartes réseau détectées qui peuvent être connectées au même réseau. Veuillez faire attention à la sélection.", + "net_unavailable": "Interface non disponible" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "Informations d'authentification non valides, le lien cloud ne sera pas disponible, veuillez accéder à la page d'intégration Xiaomi Home, cliquez sur \"Options\" pour vous réauthentifier", + "invalid_device_cache": "Informations de cache de périphérique non valides, veuillez accéder à la page d'intégration Xiaomi Home, cliquez sur `Options-> Mettre à jour la liste des appareils`, pour mettre à jour les informations locales des appareils", + "invalid_cert_info": "Certificat utilisateur non valide, le lien central local ne sera pas disponible, veuillez accéder à la page d'intégration Xiaomi Home, cliquez sur \"Options\" pour vous réauthentifier", + "device_cloud_error": "Erreur lors de la récupération des informations de l'appareil à partir du cloud, veuillez vérifier la connexion réseau locale", + "xiaomi_home_error_title": "Erreur d'intégration Xiaomi Home", + "xiaomi_home_error": "Erreur détectée sur **{nick_name}({uid}, {cloud_server})**, veuillez accéder à la page d'options pour reconfigurer.\n\n**Message d'erreur**: \n{message}", + "device_list_changed_title": "Changements dans la liste des appareils Xiaomi Home", + "device_list_changed": "Changements détectés sur **{nick_name}({uid}, {cloud_server})**, veuillez accéder à la page d'intégration, cliquez sur `Options-> Mettre à jour la liste des appareils`, pour mettre à jour les informations locales des appareils.\n\nÉtat actuel du réseau : {network_status}\n{message}\n", + "device_list_add": "\n**{count} nouveaux appareils :** \n{message}", + "device_list_del": "\n**{count} appareils non disponibles :** \n{message}", + "device_list_offline": "\n**{count} appareils hors ligne :** \n{message}", + "network_status_online": "En ligne", + "network_status_offline": "Hors ligne", + "device_exec_error": "Erreur d'exécution" + } + }, + "error": { + "common": { + "-10000": "Erreur inconnue", + "-10001": "Service indisponible", + "-10002": "Paramètre invalide", + "-10003": "Ressources insuffisantes", + "-10004": "Erreur interne", + "-10005": "Permissions insuffisantes", + "-10006": "Délai d'exécution dépassé", + "-10007": "Appareil hors ligne ou n'existe pas", + "-10020": "Non autorisé (OAuth2)", + "-10030": "Jeton invalide (HTTP)", + "-10040": "Format de message invalide", + "-10050": "Certificat invalide", + "-704000000": "Erreur inconnue", + "-704010000": "Non autorisé (l'appareil peut avoir été supprimé)", + "-704014006": "Description de l'appareil introuvable", + "-704030013": "Propriété non lisible", + "-704030023": "Propriété non inscriptible", + "-704030033": "Propriété non abonnable", + "-704040002": "Service n'existe pas", + "-704040003": "Propriété n'existe pas", + "-704040004": "Événement n'existe pas", + "-704040005": "Action n'existe pas", + "-704040999": "Fonction non en ligne", + "-704042001": "Appareil n'existe pas", + "-704042011": "Appareil hors ligne", + "-704053036": "Délai d'opération de l'appareil dépassé", + "-704053100": "L'appareil ne peut pas effectuer cette opération dans l'état actuel", + "-704083036": "Délai d'opération de l'appareil dépassé", + "-704090001": "Appareil n'existe pas", + "-704220008": "ID invalide", + "-704220025": "Nombre de paramètres d'action ne correspond pas", + "-704220035": "Erreur de paramètre d'action", + "-704220043": "Erreur de valeur de propriété", + "-704222034": "Erreur de valeur de retour d'action", + "-705004000": "Erreur inconnue", + "-705004501": "Erreur inconnue", + "-705201013": "Propriété non lisible", + "-705201015": "Erreur d'exécution d'action", + "-705201023": "Propriété non inscriptible", + "-705201033": "Propriété non abonnable", + "-706012000": "Erreur inconnue", + "-706012013": "Propriété non lisible", + "-706012015": "Erreur d'exécution d'action", + "-706012023": "Propriété non inscriptible", + "-706012033": "Propriété non abonnable", + "-706012043": "Erreur de valeur de propriété", + "-706014006": "Description de l'appareil introuvable" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/ja.json b/custom_components/xiaomi_home/miot/i18n/ja.json new file mode 100644 index 0000000..3980c1e --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/ja.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "デバイス", + "found_central_gateway": "、ローカル中央ゲートウェイが見つかりました" + }, + "control_mode": { + "auto": "自動", + "cloud": "クラウド" + }, + "room_name_rule": { + "none": "同期しない", + "home_room": "家の名前と部屋の名前 (Xiaomi Home 寝室)", + "room": "部屋の名前(寝室)", + "home": "家の名前 (Xiaomi Home)" + }, + "option_status": { + "enable": "有効", + "disable": "無効" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[注意]** 複数のネットワークカードが同じネットワークに接続されている可能性があります。選択に注意してください。", + "net_unavailable": "インターフェースが利用できません" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "認証情報が無効です。クラウドリンクは利用できません。Xiaomi Home統合ページに入り、[オプション]をクリックして再認証してください", + "invalid_device_cache": "キャッシュデバイス情報が異常です。Xiaomi Home統合ページに入り、[オプション->デバイスリストの更新]をクリックして、ローカルキャッシュを更新してください", + "invalid_cert_info": "無効なユーザー証明書です。ローカルセントラルリンクは利用できません。Xiaomi Home統合ページに入り、[オプション]をクリックして再認証してください", + "device_cloud_error": "クラウドからデバイス情報を取得する際に例外が発生しました。ローカルネットワーク接続を確認してください", + "xiaomi_home_error_title": "Xiaomi Home統合エラー", + "xiaomi_home_error": "エラーが検出されました **{nick_name}({uid}, {cloud_server})** 、オプションページに入り再構成してください。\n\n**エラーメッセージ**: \n{message}", + "device_list_changed_title": "Xiaomi Homeデバイスリストの変更", + "device_list_changed": "変更が検出されました **{nick_name}({uid}, {cloud_server})** デバイス情報が変更されました。統合オプションページに入り、`オプション->デバイスリストの更新`をクリックして、ローカルデバイス情報を更新してください。\n\n現在のネットワーク状態:{network_status}\n{message}\n", + "device_list_add": "\n**{count} 新しいデバイス:** \n{message}", + "device_list_del": "\n**{count} デバイスが利用できません:** \n{message}", + "device_list_offline": "\n**{count} デバイスがオフライン:** \n{message}", + "network_status_online": "オンライン", + "network_status_offline": "オフライン", + "device_exec_error": "実行エラー" + } + }, + "error": { + "common": { + "-10000": "不明なエラー", + "-10001": "サービス利用不可", + "-10002": "無効なパラメータ", + "-10003": "リソース不足", + "-10004": "内部エラー", + "-10005": "権限不足", + "-10006": "実行タイムアウト", + "-10007": "デバイスがオフラインまたは存在しない", + "-10020": "未認証(OAuth2)", + "-10030": "無効なトークン(HTTP)", + "-10040": "無効なメッセージ形式", + "-10050": "無効な証明書", + "-704000000": "不明なエラー", + "-704010000": "未認証(デバイスが削除された可能性があります)", + "-704014006": "デバイスの説明が見つかりません", + "-704030013": "プロパティが読み取れません", + "-704030023": "プロパティが書き込めません", + "-704030033": "プロパティが購読できません", + "-704040002": "サービスが存在しません", + "-704040003": "プロパティが存在しません", + "-704040004": "イベントが存在しません", + "-704040005": "アクションが存在しません", + "-704040999": "機能がオンラインではありません", + "-704042001": "デバイスが存在しません", + "-704042011": "デバイスがオフラインです", + "-704053036": "デバイス操作タイムアウト", + "-704053100": "デバイスが現在の状態でこの操作を実行できません", + "-704083036": "デバイス操作タイムアウト", + "-704090001": "デバイスが存在しません", + "-704220008": "無効なID", + "-704220025": "アクションパラメータの数が一致しません", + "-704220035": "アクションパラメータエラー", + "-704220043": "プロパティ値エラー", + "-704222034": "アクションの戻り値エラー", + "-705004000": "不明なエラー", + "-705004501": "不明なエラー", + "-705201013": "プロパティが読み取れません", + "-705201015": "アクション実行エラー", + "-705201023": "プロパティが書き込めません", + "-705201033": "プロパティが購読できません", + "-706012000": "不明なエラー", + "-706012013": "プロパティが読み取れません", + "-706012015": "アクション実行エラー", + "-706012023": "プロパティが書き込めません", + "-706012033": "プロパティが購読できません", + "-706012043": "プロパティ値エラー", + "-706014006": "デバイスの説明が見つかりません" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/ru.json b/custom_components/xiaomi_home/miot/i18n/ru.json new file mode 100644 index 0000000..18f8490 --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/ru.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "устройства", + "found_central_gateway": ", найден локальный центральный шлюз" + }, + "control_mode": { + "auto": "автоматический", + "cloud": "облако" + }, + "room_name_rule": { + "none": "не синхронизировать", + "home_room": "название дома и название комнаты (Xiaomi Home Спальня)", + "room": "название комнаты (Спальня)", + "home": "название дома (Xiaomi Home)" + }, + "option_status": { + "enable": "Включить", + "disable": "Отключить" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[Уведомление]** Обнаружено несколько сетевых карт, которые могут быть подключены к одной и той же сети. Пожалуйста, обратите внимание на выбор.", + "net_unavailable": "Интерфейс недоступен" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "Информация об аутентификации недействительна, облако будет недоступно, пожалуйста, войдите на страницу интеграции Xiaomi Home, нажмите 'Опции' для повторной аутентификации", + "invalid_device_cache": "Кэш информации об устройстве ненормальный, пожалуйста, войдите на страницу интеграции Xiaomi Home, нажмите 'Опции->Обновить список устройств', обновите локальный кэш", + "invalid_cert_info": "Недействительный пользовательский сертификат, локальное центральное соединение будет недоступно, пожалуйста, войдите на страницу интеграции Xiaomi Home, нажмите 'Опции' для повторной аутентификации", + "device_cloud_error": "При получении информации об устройстве из облака произошло исключение, пожалуйста, проверьте локальное сетевое соединение", + "xiaomi_home_error_title": "Ошибка интеграции Xiaomi Home", + "xiaomi_home_error": "Обнаружена ошибка **{nick_name}({uid}, {cloud_server})**, пожалуйста, войдите на страницу опций для повторной настройки.\n\n**Сообщение об ошибке**: \n{message}", + "device_list_changed_title": "Изменения в списке устройств Xiaomi Home", + "device_list_changed": "Обнаружены изменения в информации об устройствах **{nick_name}({uid}, {cloud_server})**, пожалуйста, войдите на страницу интеграции, нажмите `Опции->Обновить список устройств`, обновите локальную информацию об устройствах.\n\nТекущий статус сети: {network_status}\n{message}\n", + "device_list_add": "\n**{count} новых устройств:** \n{message}", + "device_list_del": "\n**{count} устройств недоступно:** \n{message}", + "device_list_offline": "\n**{count} устройств недоступно:** \n{message}", + "network_status_online": "В сети", + "network_status_offline": "Не в сети", + "device_exec_error": "Ошибка выполнения" + } + }, + "error": { + "common": { + "-10000": "Неизвестная ошибка", + "-10001": "Сервис недоступен", + "-10002": "Недопустимый параметр", + "-10003": "Недостаточно ресурсов", + "-10004": "Внутренняя ошибка", + "-10005": "Недостаточно прав", + "-10006": "Тайм-аут выполнения", + "-10007": "Устройство не в сети или не существует", + "-10020": "Неавторизовано (OAuth2)", + "-10030": "Недействительный токен (HTTP)", + "-10040": "Недопустимый формат сообщения", + "-10050": "Недействительный сертификат", + "-704000000": "Неизвестная ошибка", + "-704010000": "Неавторизовано (устройство могло быть удалено)", + "-704014006": "Описание устройства не найдено", + "-704030013": "Свойство не читается", + "-704030023": "Свойство не записывается", + "-704030033": "Свойство не подписывается", + "-704040002": "Сервис не существует", + "-704040003": "Свойство не существует", + "-704040004": "Событие не существует", + "-704040005": "Действие не существует", + "-704040999": "Функция не в сети", + "-704042001": "Устройство не существует", + "-704042011": "Устройство не в сети", + "-704053036": "Тайм-аут операции устройства", + "-704053100": "Устройство не может выполнить эту операцию в текущем состоянии", + "-704083036": "Тайм-аут операции устройства", + "-704090001": "Устройство не существует", + "-704220008": "Недействительный ID", + "-704220025": "Несоответствие количества параметров действия", + "-704220035": "Ошибка параметра действия", + "-704220043": "Ошибка значения свойства", + "-704222034": "Ошибка возвращаемого значения действия", + "-705004000": "Неизвестная ошибка", + "-705004501": "Неизвестная ошибка", + "-705201013": "Свойство не читается", + "-705201015": "Ошибка выполнения действия", + "-705201023": "Свойство не записывается", + "-705201033": "Свойство не подписывается", + "-706012000": "Неизвестная ошибка", + "-706012013": "Свойство не читается", + "-706012015": "Ошибка выполнения действия", + "-706012023": "Свойство не записывается", + "-706012033": "Свойство не подписывается", + "-706012043": "Ошибка значения свойства", + "-706014006": "Описание устройства не найдено" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hans.json b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json new file mode 100644 index 0000000..1607a5e --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hans.json @@ -0,0 +1,95 @@ +{ + "config": { + "other": { + "devices": "个设备", + "found_central_gateway": ",发现本地中枢网关" + }, + "control_mode": { + "auto": "自动", + "cloud": "云端" + }, + "room_name_rule": { + "none": "不同步", + "home_room": "家庭名 和 房间名 (米家 卧室)", + "room": "房间名 (卧室)", + "home": "家庭名 (米家)" + }, + "option_status": { + "enable": "启用", + "disable": "禁用" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[提示]** 检测到多个网卡可能连接同一个网络,请注意选择。", + "net_unavailable": "接口不可用" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "认证信息失效,云端链路将不可用,请进入 Xiaomi Home 集成页面,点击“选项”重新认证", + "invalid_device_cache": "缓存设备信息异常,请进入 Xiaomi Home 集成页面,点击`选项->更新设备列表`,更新本地设备信息", + "invalid_cert_info": "无效的用户证书,本地中枢链路将不可用,请进入 Xiaomi Home 集成页面,点击“选项”重新认证", + "device_cloud_error": "从云端获取设备信息异常,请检查本地网络连接", + "xiaomi_home_error_title": "Xiaomi Home 集成错误", + "xiaomi_home_error": "检测到 **{nick_name}({uid}, {cloud_server})** 出现错误,请进入选项页面重新配置。\n\n**错误信息**: \n{message}", + "device_list_changed_title": "Xiaomi Home设备列表变化", + "device_list_changed": "检测到 **{nick_name}({uid}, {cloud_server})** 设备信息发生变化,请进入集成选项页面,点击`选项->更新设备列表`,更新本地设备信息。\n\n当前网络状态:{network_status}\n{message}\n", + "device_list_add": "\n**{count} 个新增设备**: \n{message}", + "device_list_del": "\n**{count} 个设备不可用**: \n{message}", + "device_list_offline": "\n**{count} 个设备离线**: \n{message}", + "network_status_online": "在线", + "network_status_offline": "离线", + "device_exec_error": "执行错误" + } + }, + "error": { + "common": { + "-10000": "未知错误", + "-10001": "服务不可用", + "-10002": "参数无效", + "-10003": "资源不足", + "-10004": "内部错误", + "-10005": "权限不足", + "-10006": "执行超时", + "-10007": "设备离线或者不存在", + "-10020": "未授权OAuth2)", + "-10030": "无效的token(HTTP)", + "-10040": "无效的消息格式", + "-10050": "无效的证书", + "-704000000": "未知错误", + "-704010000": "未授权(设备可能被删除)", + "-704014006": "没找到设备描述", + "-704030013": "Property不可读", + "-704030023": "Property不可写", + "-704030033": "Property不可订阅", + "-704040002": "Service不存在", + "-704040003": "Property不存在", + "-704040004": "Event不存在", + "-704040005": "Action不存在", + "-704040999": "功能未上线", + "-704042001": "Device不存在", + "-704042011": "设备离线", + "-704053036": "设备操作超时", + "-704053100": "设备在当前状态下无法执行此操作", + "-704083036": "设备操作超时", + "-704090001": "Device不存在", + "-704220008": "无效的ID", + "-704220025": "Action参数个数不匹配", + "-704220035": "Action参数错误", + "-704220043": "Property值错误", + "-704222034": "Action返回值错误", + "-705004000": "未知错误", + "-705004501": "未知错误", + "-705201013": "Property不可读", + "-705201015": "Action执行错误", + "-705201023": "Property不可写", + "-705201033": "Property不可订阅", + "-706012000": "未知错误", + "-706012013": "Property不可读", + "-706012015": "Action执行错误", + "-706012023": "Property不可写", + "-706012033": "Property不可订阅", + "-706012043": "Property值错误", + "-706014006": "没找到设备描述" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/i18n/zh-Hant.json b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json new file mode 100644 index 0000000..bf381a3 --- /dev/null +++ b/custom_components/xiaomi_home/miot/i18n/zh-Hant.json @@ -0,0 +1,97 @@ +{ + "config": { + "other": { + "devices": "個設備", + "found_central_gateway": ",發現本地中樞網關" + }, + "control_mode": { + "auto": "自動", + "cloud": "雲端" + }, + "room_name_rule": { + "none": "不同步", + "home_room": "家庭名 和 房間名 (米家 臥室)", + "room": "房間名 (臥室)", + "home": "家庭名 (米家)" + }, + "option_status": { + "enable": "啟用", + "disable": "禁用" + }, + "lan_ctrl_config": { + "notice_net_dup": "\r\n**[提示]** 檢測到多個網卡可能連接同一個網絡,請注意選擇。", + "net_unavailable": "接口不可用" + } + }, + "miot": { + "client": { + "invalid_oauth_info": "認證信息失效,雲端鏈路將不可用,請進入 Xiaomi Home 集成頁面,點擊“選項”重新認證", + "invalid_device_cache": "緩存設備信息異常,請進入 Xiaomi Home 集成頁面,點擊`選項->更新設備列表`,更新本地設備信息", + "invalid_cert_info": "無效的用戶證書,本地中樞鏈路將不可用,請進入 Xiaomi Home 集成頁面,點擊“選項”重新認證", + "device_cloud_error": "從雲端獲取設備信息異常,請檢查本地網絡連接", + "xiaomi_home_error_title": "Xiaomi Home 集成錯誤", + "xiaomi_home_error": "檢測到 **{nick_name}({uid}, {cloud_server})** 出現錯誤,請進入選項頁面重新配置。\n\n**錯誤信息**: \n{message}", + "device_list_changed_title": "Xiaomi Home設備列表變化", + "device_list_changed": "檢測到 **{nick_name}({uid}, {cloud_server})** 設備信息發生變化,請進入集成選項頁面,點擊`選項->更新設備列表`,更新本地設備信息。\n\n當前網絡狀態:{network_status}\n{message}\n", + "device_list_add": "\n**{count} 個新增設備:** \n{message}", + "device_list_del": "\n**{count} 個設備不可用:** \n{message}", + "device_list_offline": "\n**{count} 個設備離線:** \n{message}", + "network_status_online": "在線", + "network_status_offline": "離線", + "device_exec_error": "執行錯誤" + } + }, + "error": { + "common": { + "-1": "未知錯誤", + "-10000": "未知錯誤", + "-10001": "服務不可用", + "-10002": "無效參數", + "-10003": "資源不足", + "-10004": "內部錯誤", + "-10005": "權限不足", + "-10006": "執行超時", + "-10007": "設備離線或者不存在", + "-10020": "無效的消息格式" + }, + "gw": {}, + "lan": {}, + "cloud": { + "-704000000": "未知錯誤", + "-704010000": "未授權(設備可能被刪除)", + "-704014006": "沒找到設備描述", + "-704030013": "Property不可讀", + "-704030023": "Property不可寫", + "-704030033": "Property不可訂閱", + "-704040002": "Service不存在", + "-704040003": "Property不存在", + "-704040004": "Event不存在", + "-704040005": "Action不存在", + "-704040999": "功能未上線", + "-704042001": "Device不存在", + "-704042011": "設備離線", + "-704053036": "設備操作超時", + "-704053100": "設備在當前狀態下無法執行此操作", + "-704083036": "設備操作超時", + "-704090001": "Device不存在", + "-704220008": "無效的ID", + "-704220025": "Action參數個數不匹配", + "-704220035": "Action參數錯誤", + "-704220043": "Property值錯誤", + "-704222034": "Action返回值錯誤", + "-705004000": "未知錯誤", + "-705004501": "未知錯誤", + "-705201013": "Property不可讀", + "-705201015": "Action執行錯誤", + "-705201023": "Property不可寫", + "-705201033": "Property不可訂閱", + "-706012000": "未知錯誤", + "-706012013": "Property不可讀", + "-706012015": "Action執行錯誤", + "-706012023": "Property不可寫", + "-706012033": "Property不可訂閱", + "-706012043": "Property值錯誤", + "-706014006": "沒找到設備描述" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/miot_client.py b/custom_components/xiaomi_home/miot/miot_client.py new file mode 100644 index 0000000..317afeb --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_client.py @@ -0,0 +1,1811 @@ +# -*- coding: utf-8 -*- +"""MIoT client instance.""" +from copy import deepcopy +from typing import Callable, Optional, final +import asyncio +import json +import logging +import time +import traceback +from dataclasses import dataclass +from enum import Enum, auto + +from homeassistant.core import HomeAssistant +from homeassistant.components import zeroconf + +from .common import MIoTMatcher +from .const import ( + DEFAULT_CTRL_MODE, DEFAULT_INTEGRATION_LANGUAGE, DEFAULT_NICK_NAME, DOMAIN, + MIHOME_CERT_EXPIRE_MARGIN, NETWORK_REFRESH_INTERVAL, + OAUTH2_CLIENT_ID, SUPPORT_CENTRAL_GATEWAY_CTRL) +from .miot_cloud import MIoTHttpClient, MIoTOauthClient +from .miot_error import MIoTClientError, MIoTErrorCode +from .miot_mips import ( + MIoTDeviceState, MipsCloudClient, MipsDeviceState, + MipsLocalClient) +from .miot_lan import MIoTLan +from .miot_network import MIoTNetwork +from .miot_storage import MIoTCert, MIoTStorage +from .miot_mdns import MipsService, MipsServiceState +from .miot_i18n import MIoTI18n + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MIoTClientSub: + """MIoT client subscription.""" + topic: str = None + handler: Callable[[dict, any], None] = None + handler_ctx: any = None + + def __str__(self) -> str: + return f'{self.topic}, {id(self.handler)}, {id(self.handler_ctx)}' + + +class CtrlMode(Enum): + """MIoT client control mode.""" + AUTO = 0 + CLOUD = auto() + + @staticmethod + def load(mode: str) -> 'CtrlMode': + if mode == 'auto': + return CtrlMode.AUTO + if mode == 'cloud': + return CtrlMode.CLOUD + raise MIoTClientError(f'unknown ctrl mode, {mode}') + + +class MIoTClient: + """MIoT client instance.""" + # pylint: disable=unused-argument + _main_loop: asyncio.AbstractEventLoop + + _uid: str + _entry_id: str + _entry_data: dict + _cloud_server: str + _ctrl_mode: CtrlMode + # MIoT network monitor + _network: MIoTNetwork + # MIoT storage client + _storage: MIoTStorage + # MIoT mips service + _mips_service: MipsService + # MIoT oauth client + _oauth: MIoTOauthClient + # MIoT http client + _http: MIoTHttpClient + # MIoT i18n client + _i18n: MIoTI18n + # MIoT cert client + _cert: MIoTCert + # User config, store in the .storage/xiaomi_home + _user_config: dict + + # Multi local mips client, key=group_id + _mips_local: dict[str, MipsLocalClient] + # Cloud mips client + _mips_cloud: MipsCloudClient + # MIoT lan client + _miot_lan: MIoTLan + + # Device list load from local storage, {did: } + _device_list_cache: dict[str, dict] + # Device list obtained from cloud, {did: } + _device_list_cloud: dict[str, dict] + # Device list obtained from gateway, {did: } + _device_list_gateway: dict[str, dict] + # Device list scanned from LAN, {did: } + _device_list_lan: dict[str, dict] + # Device list update timestamp + _device_list_update_ts: int + + _sub_source_list: dict[str] + _sub_tree: MIoTMatcher + _sub_device_state: dict[str, MipsDeviceState] + + _mips_local_state_changed_timers: dict[str, asyncio.TimerHandle] + _refresh_token_timer: Optional[asyncio.TimerHandle] + _refresh_cert_timer: Optional[asyncio.TimerHandle] + _refresh_cloud_devices_timer: Optional[asyncio.TimerHandle] + # Refresh prop + _refresh_props_list: dict[str, dict] + _refresh_props_timer: Optional[asyncio.TimerHandle] + _refresh_props_retry_count: int + + # Persistence notify handler, params: notify_id, title, message + _persistence_notify: Callable[[str, str, str], None] + # Device list changed notify + _show_devices_changed_notify_timer: Optional[asyncio.TimerHandle] + + def __init__( + self, + entry_id: str, + entry_data: dict, + network: MIoTNetwork, + storage: MIoTStorage, + mips_service: MipsService, + miot_lan: MIoTLan, + loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + # MUST run in a running event loop + self._main_loop = loop or asyncio.get_running_loop() + # Check params + if not isinstance(entry_data, dict): + raise MIoTClientError('invalid entry data') + if 'uid' not in entry_data or 'cloud_server' not in entry_data: + raise MIoTClientError('invalid entry data content') + if not isinstance(network, MIoTNetwork): + raise MIoTClientError('invalid miot network') + if not isinstance(storage, MIoTStorage): + raise MIoTClientError('invalid miot storage') + if not isinstance(mips_service, MipsService): + raise MIoTClientError('invalid mips service') + self._entry_id = entry_id + self._entry_data = entry_data + self._uid = entry_data['uid'] + self._cloud_server = entry_data['cloud_server'] + self._ctrl_mode = CtrlMode.load( + entry_data.get('ctrl_mode', DEFAULT_CTRL_MODE)) + self._network = network + self._storage = storage + self._mips_service = mips_service + self._oauth = None + self._http = None + self._i18n = None + self._cert = None + self._user_config = None + + self._mips_local = {} + self._mips_cloud = None + self._miot_lan = miot_lan + + self._device_list_cache = {} + self._device_list_cloud = {} + self._device_list_gateway = {} + self._device_list_lan = {} + self._device_list_update_ts = 0 + self._sub_source_list = {} + self._sub_tree = MIoTMatcher() + self._sub_device_state = {} + + self._mips_local_state_changed_timers = {} + self._refresh_token_timer = None + self._refresh_cert_timer = None + self._refresh_cloud_devices_timer = None + + # Refresh prop + self._refresh_props_list = {} + self._refresh_props_timer = None + self._refresh_props_retry_count = 0 + + self._persistence_notify = None + self._show_devices_changed_notify_timer = None + + async def init_async(self) -> None: + # Load user config and check + self._user_config = await self._storage.load_user_config_async( + uid=self._uid, cloud_server=self._cloud_server) + if not self._user_config: + # Integration need to be add again + raise MIoTClientError('load_user_config_async error') + _LOGGER.debug('user config, %s', json.dumps(self._user_config)) + # Load cache device list + await self.__load_cache_device_async() + # MIoT i18n client + self._i18n = MIoTI18n( + lang=self._entry_data.get( + 'integration_language', DEFAULT_INTEGRATION_LANGUAGE), + loop=self._main_loop) + await self._i18n.init_async() + # MIoT oauth client instance + self._oauth = MIoTOauthClient( + client_id=OAUTH2_CLIENT_ID, + redirect_url=self._entry_data['oauth_redirect_url'], + cloud_server=self._cloud_server, + loop=self._main_loop) + # MIoT http client instance + self._http = MIoTHttpClient( + cloud_server=self._cloud_server, + client_id=OAUTH2_CLIENT_ID, + access_token=self._user_config['auth_info']['access_token'], + loop=self._main_loop) + # MIoT cert client + self._cert = MIoTCert( + storage=self._storage, + uid=self._uid, + cloud_server=self.cloud_server) + # MIoT cloud mips client + self._mips_cloud = MipsCloudClient( + uuid=self._entry_data['uuid'], + cloud_server=self._cloud_server, + app_id=OAUTH2_CLIENT_ID, + token=self._user_config['auth_info']['access_token'], + loop=self._main_loop) + self._mips_cloud.enable_logger(logger=_LOGGER) + self._mips_cloud.sub_mips_state( + key=f'{self._uid}-{self._cloud_server}', + handler=self.__on_mips_cloud_state_changed) + # Subscribe network status + self._network.sub_network_status( + key=f'{self._uid}-{self._cloud_server}', + handler=self.__on_network_status_changed) + await self.__on_network_status_changed( + status=self._network.network_status) + # Create multi mips local client instance according to the + # number of hub gateways + if self._ctrl_mode == CtrlMode.AUTO: + # Central hub gateway ctrl + if self._cloud_server in SUPPORT_CENTRAL_GATEWAY_CTRL: + for home_id, info in self._entry_data['home_selected'].items(): + # Create local mips service changed listener + self._mips_service.sub_service_change( + key=f'{self._uid}-{self._cloud_server}', + group_id=info['group_id'], + handler=self.__on_mips_service_state_change) + service_data = self._mips_service.get_services( + group_id=info['group_id']).get(info['group_id'], None) + if not service_data: + _LOGGER.info( + 'central mips service not scanned, %s', home_id) + continue + _LOGGER.info( + 'central mips service scanned, %s, %s', + home_id, service_data) + mips = MipsLocalClient( + did=self._entry_data['virtual_did'], + group_id=info['group_id'], + host=service_data['addresses'][0], + ca_file=self._cert.ca_file, + cert_file=self._cert.cert_file, + key_file=self._cert.key_file, + port=service_data['port'], + home_name=info['home_name'], + loop=self._main_loop) + self._mips_local[info['group_id']] = mips + mips.enable_logger(logger=_LOGGER) + mips.on_dev_list_changed = self.__on_gw_device_list_changed + mips.sub_mips_state( + key=info['group_id'], + handler=self.__on_mips_local_state_changed) + mips.connect() + # Lan ctrl + await self._miot_lan.vote_for_lan_ctrl_async( + key=f'{self._uid}-{self._cloud_server}', vote=True) + self._miot_lan.sub_lan_state( + key=f'{self._uid}-{self._cloud_server}', + handler=self.__on_miot_lan_state_change) + if self._miot_lan.init_done: + await self.__on_miot_lan_state_change(True) + else: + self._miot_lan.unsub_lan_state( + key=f'{self._uid}-{self._cloud_server}') + if self._miot_lan.init_done: + self._miot_lan.unsub_device_state( + key=f'{self._uid}-{self._cloud_server}') + self._miot_lan.delete_devices( + devices=list(self._device_list_cache.keys())) + await self._miot_lan.vote_for_lan_ctrl_async( + key=f'{self._uid}-{self._cloud_server}', vote=False) + + _LOGGER.info('init_async, %s, %s', self._uid, self._cloud_server) + + async def deinit_async(self) -> None: + # Cloud mips + self._mips_cloud.disconnect() + # Cancel refresh cloud devices + if self._refresh_cloud_devices_timer: + self._refresh_cloud_devices_timer.cancel() + self._refresh_cloud_devices_timer = None + # Cancel refresh props + if self._refresh_props_timer: + self._refresh_props_timer.cancel() + self._refresh_props_timer = None + self._refresh_props_list.clear() + self._refresh_props_retry_count = 0 + # Central hub gateway mips + for mips in self._mips_local.values(): + mips.disconnect() + if self._mips_local_state_changed_timers: + for timer_item in self._mips_local_state_changed_timers.values(): + timer_item.cancel() + self._mips_local_state_changed_timers.clear() + # Cancel refresh auth info + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = None + if self._refresh_cert_timer: + self._refresh_cert_timer.cancel() + self._refresh_cert_timer = None + # Remove notify + self._persistence_notify( + self.__gen_notify_key('dev_list_changed'), None, None) + self.__show_client_error_notify( + message=None, notify_key='oauth_info') + self.__show_client_error_notify( + message=None, notify_key='user_cert') + self.__show_client_error_notify( + message=None, notify_key='device_cache') + self.__show_client_error_notify( + message=None, notify_key='device_cloud') + + _LOGGER.info('deinit_async, %s', self._uid) + + @property + def main_loop(self) -> asyncio.AbstractEventLoop: + return self._main_loop + + @property + def miot_network(self) -> MIoTNetwork: + return self._network + + @property + def miot_storage(self) -> MIoTStorage: + return self._storage + + @property + def mips_service(self) -> MipsService: + return self._mips_service + + @property + def miot_oauth(self) -> MIoTOauthClient: + return self._oauth + + @property + def miot_http(self) -> MIoTHttpClient: + return self._http + + @property + def miot_i18n(self) -> MIoTI18n: + return self._i18n + + @property + def miot_lan(self) -> MIoTLan: + return self._miot_lan + + @property + def user_config(self) -> dict: + return self._user_config + + @property + def area_name_rule(self) -> Optional[str]: + return self._entry_data.get('area_name_rule', None) + + @property + def cloud_server(self) -> str: + return self._cloud_server + + @property + def action_debug(self) -> bool: + return self._entry_data.get('action_debug', False) + + @property + def hide_non_standard_entities(self) -> bool: + return self._entry_data.get( + 'hide_non_standard_entities', False) + + @property + def device_list(self) -> dict: + return self._device_list_cache + + @property + def persistent_notify(self) -> Callable: + return self._persistence_notify + + @persistent_notify.setter + def persistent_notify(self, func) -> None: + self._persistence_notify = func + + @final + async def refresh_oauth_info_async(self) -> bool: + try: + # Load auth info + auth_info: Optional[dict] = None + user_config: dict = await self._storage.load_user_config_async( + uid=self._uid, cloud_server=self._cloud_server, + keys=['auth_info']) + if ( + not user_config + or (auth_info := user_config.get('auth_info', None)) is None + ): + raise MIoTClientError('load_user_config_async error') + if ( + 'expires_ts' not in auth_info + or 'access_token' not in auth_info + or 'refresh_token' not in auth_info + ): + raise MIoTClientError('invalid auth info') + # Determine whether to update token + if (auth_info['expires_ts']-60) <= int(time.time()): + valid_auth_info = await self._oauth.refresh_access_token_async( + refresh_token=auth_info['refresh_token']) + auth_info = valid_auth_info + # Update http token + self._http.update_http_header( + access_token=valid_auth_info['access_token']) + # Update mips cloud token + self._mips_cloud.update_access_token( + access_token=valid_auth_info['access_token']) + # Update storage + if not await self._storage.update_user_config_async( + uid=self._uid, cloud_server=self._cloud_server, + config={'auth_info': auth_info}): + raise MIoTClientError('update_user_config_async error') + _LOGGER.info( + 'refresh oauth info, get new access_token, %s', + auth_info) + + refresh_time = int(auth_info['expires_ts'] - time.time()) + if refresh_time <= 0: + raise MIoTClientError('invalid expires time') + self.__show_client_error_notify(None, 'oauth_info') + self.__request_refresh_auth_info(refresh_time) + + _LOGGER.debug( + 'refresh oauth info (%s, %s) after %ds', + self._uid, self._cloud_server, refresh_time) + return True + except Exception as err: # pylint: disable=broad-exception-caught + self.__show_client_error_notify( + message=self._i18n.translate('miot.client.invalid_oauth_info'), + notify_key='oauth_info') + _LOGGER.error( + 'refresh oauth info error (%s, %s), %s, %s', + self._uid, self._cloud_server, err, traceback.format_exc()) + return False + + async def refresh_user_cert_async(self) -> bool: + try: + if self._cloud_server not in SUPPORT_CENTRAL_GATEWAY_CTRL: + return True + if not await self._cert.verify_ca_cert_async(): + raise MIoTClientError('ca cert is not ready') + if ( + await self._cert.user_cert_remaining_time_async() < + MIHOME_CERT_EXPIRE_MARGIN + ): + user_key = await self._cert.load_user_key_async() + if user_key is None: + user_key = self._cert.gen_user_key() + if not await self._cert.update_user_key_async(key=user_key): + raise MIoTClientError('update_user_key_async failed') + csr_str = self._cert.gen_user_csr( + user_key=user_key, did=self._entry_data['virtual_did']) + crt_str = await self.miot_http.get_central_cert_async(csr_str) + if not await self._cert.update_user_cert_async(cert=crt_str): + raise MIoTClientError('update user cert error') + _LOGGER.info('update_user_cert_async, %s', crt_str) + + # Create cert update task + refresh_time = ( + await self._cert.user_cert_remaining_time_async() - + MIHOME_CERT_EXPIRE_MARGIN) + if refresh_time <= 0: + raise MIoTClientError('invalid refresh time') + self.__show_client_error_notify(None, 'user_cert') + self.__request_refresh_user_cert(refresh_time) + + _LOGGER.debug( + 'refresh user cert (%s, %s) after %ds', + self._uid, self._cloud_server, refresh_time) + return True + except MIoTClientError as error: + self.__show_client_error_notify( + message=self._i18n.translate('miot.client.invalid_cert_info'), + notify_key='user_cert') + _LOGGER.error( + 'refresh user cert error, %s, %s', + error, traceback.format_exc()) + return False + + async def set_prop_async( + self, did: str, siid: int, piid: int, value: any + ) -> bool: + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + # Priority local control + if self._ctrl_mode == CtrlMode.AUTO: + # Gateway control + device_gw: dict = self._device_list_gateway.get(did, None) + if ( + device_gw and device_gw.get('online', False) + and device_gw.get('specv2_access', False) + and 'group_id' in device_gw + ): + mips = self._mips_local.get(device_gw['group_id'], None) + if mips is None: + _LOGGER.error( + 'no gw route, %s, try control throw cloud', + device_gw) + else: + result = await mips.set_prop_async( + did=did, siid=siid, piid=piid, value=value) + rc = (result or {}).get( + 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + if rc in [0, 1]: + return True + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=rc)) + # Lan control + device_lan: dict = self._device_list_lan.get(did, None) + if device_lan and device_lan.get('online', False): + result = await self._miot_lan.set_prop_async( + did=did, siid=siid, piid=piid, value=value) + _LOGGER.debug( + 'lan set prop, %s, %s, %s -> %s', did, siid, piid, result) + rc = (result or {}).get( + 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + if rc in [0, 1]: + return True + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=rc)) + + # Cloud control + device_cloud = self._device_list_cloud.get(did, None) + if device_cloud and device_cloud.get('online', False): + result: list = await self._http.set_prop_async( + params=[ + {'did': did, 'siid': siid, 'piid': piid, 'value': value} + ]) + _LOGGER.debug( + 'set prop response, %s.%d.%d, %s, result, %s', + did, siid, piid, value, result) + if result and len(result) == 1: + rc = result[0].get( + 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + if rc in [0, 1]: + return True + if rc in [-704010000, -704042011]: + # Device remove or offline + _LOGGER.error('device may be removed or offline, %s', did) + self._main_loop.create_task( + await self.__refresh_cloud_device_with_dids_async( + dids=[did])) + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=rc)) + + # Show error message + raise MIoTClientError( + f'{self._i18n.translate("miot.client.device_exec_error")}, ' + f'{self._i18n.translate("error.common.-10007")}') + + def request_refresh_prop( + self, did: str, siid: int, piid: int + ) -> None: + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + key: str = f'{did}|{siid}|{piid}' + if key in self._refresh_props_list: + return + self._refresh_props_list[key] = { + 'did': did, 'siid': siid, 'piid': piid} + if self._refresh_props_timer: + return + self._refresh_props_timer = self._main_loop.call_later( + 0.2, lambda: self._main_loop.create_task( + self.__refresh_props_handler())) + + async def get_prop_async(self, did: str, siid: int, piid: int) -> any: + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + + # NOTICE: Since there are too many request attributes and obtaining + # them directly from the hub or device will cause device abnormalities, + # so obtaining the cache from the cloud is the priority here. + try: + if self._network.network_status: + result = await self._http.get_prop_async( + did=did, siid=siid, piid=piid) + if result: + return result + except Exception as err: # pylint: disable=broad-exception-caught + # Catch all exceptions + _LOGGER.error( + 'client get prop from cloud error, %s, %s', + err, traceback.format_exc()) + if self._ctrl_mode == CtrlMode.AUTO: + # Central hub gateway + device_gw = self._device_list_gateway.get(did, None) + if ( + device_gw and device_gw.get('online', False) + and device_gw.get('specv2_access', False) + and 'group_id' in device_gw + ): + mips = self._mips_local.get(device_gw['group_id'], None) + if mips is None: + _LOGGER.error('no gw route, %s', device_gw) + else: + return await mips.get_prop_async( + did=did, siid=siid, piid=piid) + # Lan + device_lan = self._device_list_lan.get(did, None) + if device_lan and device_lan.get('online', False): + return await self._miot_lan.get_prop_async( + did=did, siid=siid, piid=piid) + # _LOGGER.error( + # 'client get prop failed, no-link, %s.%d.%d', did, siid, piid) + return None + + async def action_async( + self, did: str, siid: int, aiid: int, in_list: list + ) -> list: + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + + device_gw: dict = self._device_list_gateway.get(did, None) + # Priority local control + if self._ctrl_mode == CtrlMode.AUTO: + if ( + device_gw and device_gw.get('online', False) + and device_gw.get('specv2_access', False) + and 'group_id' in device_gw + ): + mips = self._mips_local.get( + device_gw['group_id'], None) + if mips is None: + _LOGGER.error('no gw route, %s', device_gw) + else: + result = await mips.action_async( + did=did, siid=siid, aiid=aiid, in_list=in_list) + rc = (result or {}).get( + 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + if rc in [0, 1]: + return result.get('out', []) + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=rc)) + # Lan control + device_lan = self._device_list_lan.get(did, None) + if device_lan and device_lan.get('online', False): + result = await self._miot_lan.action_async( + did=did, siid=siid, aiid=aiid, in_list=in_list) + _LOGGER.debug( + 'lan action, %s, %s, %s -> %s', did, siid, aiid, result) + rc = (result or {}).get( + 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + if rc in [0, 1]: + return result.get('out', []) + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=rc)) + # Cloud control + device_cloud = self._device_list_cloud.get(did, None) + if device_cloud.get('online', False): + result: dict = await self._http.action_async( + did=did, siid=siid, aiid=aiid, in_list=in_list) + if result: + rc = result.get( + 'code', MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value) + if rc in [0, 1]: + return result.get('out', []) + if rc in [-704010000, -704042011]: + # Device remove or offline + _LOGGER.error('device removed or offline, %s', did) + self._main_loop.create_task( + await self.__refresh_cloud_device_with_dids_async( + dids=[did])) + raise MIoTClientError( + self.__get_exec_error_with_rc(rc=rc)) + # Show error message + _LOGGER.error( + 'client action failed, %s.%d.%d', did, siid, aiid) + return None + + def sub_prop( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, piid: int = None, handler_ctx: any = None + ) -> bool: + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + + topic = ( + f'{did}/p/' + f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') + self._sub_tree[topic] = MIoTClientSub( + topic=topic, handler=handler, handler_ctx=handler_ctx) + _LOGGER.debug('client sub prop, %s', topic) + return True + + def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + topic = ( + f'{did}/p/' + f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') + if self._sub_tree.get(topic=topic): + del self._sub_tree[topic] + _LOGGER.debug('client unsub prop, %s', topic) + return True + + def sub_event( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, eiid: int = None, handler_ctx: any = None + ) -> bool: + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + topic = ( + f'{did}/e/' + f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') + self._sub_tree[topic] = MIoTClientSub( + topic=topic, handler=handler, handler_ctx=handler_ctx) + _LOGGER.debug('client sub event, %s', topic) + return True + + def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + topic = ( + f'{did}/e/' + f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') + if self._sub_tree.get(topic=topic): + del self._sub_tree[topic] + _LOGGER.debug('client unsub event, %s', topic) + return True + + def sub_device_state( + self, did: str, handler: Callable[[str, MIoTDeviceState, any], None], + handler_ctx: any = None + ) -> bool: + """Call callback handler in main loop""" + if did not in self._device_list_cache: + raise MIoTClientError(f'did not exist, {did}') + self._sub_device_state[did] = MipsDeviceState( + did=did, handler=handler, handler_ctx=handler_ctx) + _LOGGER.debug('client sub device state, %s', did) + return True + + def unsub_device_state(self, did: str) -> bool: + self._sub_device_state.pop(did, None) + _LOGGER.debug('client unsub device state, %s', did) + return True + + def __get_exec_error_with_rc(self, rc: int) -> str: + err_msg: str = self._i18n.translate(key=f'error.common.{rc}') + if not err_msg: + err_msg = f'{self._i18n.translate(key="error.common.-10000")}, ' + err_msg += f'code={rc}' + return ( + f'{self._i18n.translate(key="miot.client.device_exec_error")}, ' + + err_msg) + + @final + def __gen_notify_key(self, name: str) -> str: + return f'{DOMAIN}-{self._uid}-{self._cloud_server}-{name}' + + @final + def __request_refresh_auth_info(self, delay_sec: int) -> None: + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = None + self._refresh_token_timer = self._main_loop.call_later( + delay_sec, lambda: self._main_loop.create_task( + self.refresh_oauth_info_async())) + + @final + def __request_refresh_user_cert(self, delay_sec: int) -> None: + if self._refresh_cert_timer: + self._refresh_cert_timer.cancel() + self._refresh_cert_timer = None + self._refresh_cert_timer = self._main_loop.call_later( + delay_sec, lambda: self._main_loop.create_task( + self.refresh_user_cert_async())) + + @final + def __update_device_msg_sub(self, did: str) -> None: + if did not in self._device_list_cache: + return + from_old: Optional[str] = self._sub_source_list.get(did, None) + from_new: Optional[str] = None + if self._ctrl_mode == CtrlMode.AUTO: + if ( + did in self._device_list_gateway + and self._device_list_gateway[did].get('online', False) + and self._device_list_gateway[did].get('push_available', False) + ): + from_new = self._device_list_gateway[did]['group_id'] + elif ( + did in self._device_list_lan + and self._device_list_lan[did].get('online', False) + and self._device_list_lan[did].get('push_available', False) + ): + from_new = 'lan' + + if ( + from_new is None + and did in self._device_list_cloud + and self._device_list_cloud[did].get('online', False) + ): + from_new = 'cloud' + if from_new == from_old: + # No need to update + return + # Unsub old + if from_old: + if from_old == 'cloud': + self._mips_cloud.unsub_prop(did=did) + self._mips_cloud.unsub_event(did=did) + elif from_old == 'lan': + self._miot_lan.unsub_prop(did=did) + self._miot_lan.unsub_event(did=did) + elif from_old in self._mips_local: + mips = self._mips_local[from_old] + mips.unsub_prop(did=did) + mips.unsub_event(did=did) + # Sub new + if from_new == 'cloud': + self._mips_cloud.sub_prop(did=did, handler=self.__on_prop_msg) + self._mips_cloud.sub_event(did=did, handler=self.__on_event_msg) + elif from_new == 'lan': + self._miot_lan.sub_prop(did=did, handler=self.__on_prop_msg) + self._miot_lan.sub_event(did=did, handler=self.__on_event_msg) + elif from_new in self._mips_local: + mips = self._mips_local[from_new] + mips.sub_prop(did=did, handler=self.__on_prop_msg) + mips.sub_event(did=did, handler=self.__on_event_msg) + self._sub_source_list[did] = from_new + _LOGGER.info( + 'device sub changed, %s, from %s to %s', did, from_old, from_new) + + @final + async def __on_network_status_changed(self, status: bool) -> None: + _LOGGER.info('network status changed, %s', status) + if status: + # Check auth_info + if await self.refresh_oauth_info_async(): + # Connect to mips cloud + self._mips_cloud.connect() + # Update device list + self.__request_refresh_cloud_devices() + await self.refresh_user_cert_async() + else: + self.__request_show_devices_changed_notify(delay_sec=30) + # Cancel refresh cloud devices + if self._refresh_cloud_devices_timer: + self._refresh_cloud_devices_timer.cancel() + self._refresh_cloud_devices_timer = None + # Disconnect cloud mips + self._mips_cloud.disconnect() + + @final + async def __on_mips_service_state_change( + self, group_id: str, state: MipsServiceState, data: dict + ) -> None: + _LOGGER.info( + 'mips service state changed, %s, %s, %s', group_id, state, data) + + mips = self._mips_local.get(group_id, None) + if mips: + if state == MipsServiceState.REMOVED: + mips.disconnect() + self._mips_local.pop(group_id, None) + return + if ( + mips.client_id == self._entry_data['virtual_did'] + and mips.host == data['addresses'][0] + and mips.port == data['port'] + ): + return + mips.disconnect() + self._mips_local.pop(group_id, None) + home_name: str = '' + for info in list(self._entry_data['home_selected'].values()): + if info.get('group_id', None) == group_id: + home_name = info.get('home_name', '') + mips = MipsLocalClient( + did=self._entry_data['virtual_did'], + group_id=group_id, + host=data['addresses'][0], + ca_file=self._cert.ca_file, + cert_file=self._cert.cert_file, + key_file=self._cert.key_file, + port=data['port'], + home_name=home_name, + loop=self._main_loop) + self._mips_local[group_id] = mips + mips.enable_logger(logger=_LOGGER) + mips.on_dev_list_changed = self.__on_gw_device_list_changed + mips.sub_mips_state( + key=group_id, handler=self.__on_mips_local_state_changed) + mips.connect() + + @final + async def __on_mips_cloud_state_changed( + self, key: str, state: bool + ) -> None: + _LOGGER.info('cloud mips state changed, %s, %s', key, state) + if state: + # Connect + self.__request_refresh_cloud_devices(immediately=True) + # Sub cloud device state + for did in list(self._device_list_cache.keys()): + self._mips_cloud.sub_device_state( + did=did, handler=self.__on_cloud_device_state_changed) + else: + # Disconnect + for did, info in self._device_list_cloud.items(): + cloud_state_old: Optional[bool] = info.get('online', None) + if not cloud_state_old: + # Cloud state is None or False, no need to update + continue + info['online'] = False + if did not in self._device_list_cache: + continue + self.__update_device_msg_sub(did=did) + state_old: Optional[bool] = self._device_list_cache[did].get( + 'online', None) + state_new: Optional[bool] = self.__check_device_state( + False, + self._device_list_gateway.get( + did, {}).get('online', False), + self._device_list_lan.get(did, {}).get('online', False)) + if state_old == state_new: + continue + self._device_list_cache[did]['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx) + self.__request_show_devices_changed_notify() + + @final + async def __on_mips_local_state_changed( + self, group_id: str, state: bool + ) -> None: + _LOGGER.info('local mips state changed, %s, %s', group_id, state) + mips: MipsLocalClient = self._mips_local.get(group_id, None) + if mips is None: + _LOGGER.error( + 'local mips state changed, mips not exist, %s', group_id) + return + if state: + # Connected + self.__request_refresh_gw_devices_by_group_id(group_id=group_id) + else: + # Disconnect + for did, info in self._device_list_gateway.items(): + if info.get('group_id', None) != group_id: + # Not belong to this gateway + continue + if not info.get('online', False): + # Device offline, no need to update + continue + # Update local device info + info['online'] = False + info['push_available'] = False + if did not in self._device_list_cache: + # Device not exist + continue + self.__update_device_msg_sub(did=did) + state_old: Optional[bool] = self._device_list_cache.get( + did, {}).get('online', None) + state_new: Optional[bool] = self.__check_device_state( + self._device_list_cloud.get(did, {}).get('online', None), + False, + self._device_list_lan.get(did, {}).get('online', False)) + if state_old == state_new: + continue + self._device_list_cache[did]['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx) + self.__request_show_devices_changed_notify() + + @final + async def __on_miot_lan_state_change(self, state: bool) -> None: + _LOGGER.info( + 'miot lan state changed, %s, %s, %s', + self._uid, self._cloud_server, state) + if state: + # Update device + self._miot_lan.sub_device_state( + key=f'{self._uid}-{self._cloud_server}', + handler=self.__on_lan_device_state_changed) + for did, info in ( + await self._miot_lan.get_dev_list_async()).items(): + self.__on_lan_device_state_changed( + did=did, state=info, ctx=None) + _LOGGER.info('lan device list, %s', self._device_list_lan) + self._miot_lan.update_devices(devices={ + did: { + 'token': info['token'], + 'connect_type': info['connect_type']} + for did, info in self._device_list_cache.items() + if 'token' in info and 'connect_type' in info + and info['connect_type'] in [0, 8, 12, 23] + }) + else: + for did, info in self._device_list_lan.items(): + if not info.get('online', False): + continue + # Update local device info + info['online'] = False + info['push_available'] = False + self.__update_device_msg_sub(did=did) + state_old: Optional[bool] = self._device_list_cache.get( + did, {}).get('online', None) + state_new: Optional[bool] = self.__check_device_state( + self._device_list_cloud.get(did, {}).get('online', None), + self._device_list_gateway.get( + did, {}).get('online', False), + False) + if state_old == state_new: + continue + self._device_list_cache[did]['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler(did, MIoTDeviceState.OFFLINE, sub.handler_ctx) + self._device_list_lan = {} + self.__request_show_devices_changed_notify() + + @final + def __on_cloud_device_state_changed( + self, did: str, state: MIoTDeviceState, ctx: any + ) -> None: + _LOGGER.info('cloud device state changed, %s, %s', did, state) + cloud_device = self._device_list_cloud.get(did, None) + if not cloud_device: + return + cloud_state_new: bool = state == MIoTDeviceState.ONLINE + if cloud_device.get('online', False) == cloud_state_new: + return + cloud_device['online'] = cloud_state_new + if did not in self._device_list_cache: + return + self.__update_device_msg_sub(did=did) + state_old: Optional[bool] = self._device_list_cache[did].get( + 'online', None) + state_new: Optional[bool] = self.__check_device_state( + cloud_state_new, + self._device_list_gateway.get(did, {}).get('online', False), + self._device_list_lan.get(did, {}).get('online', False)) + if state_old == state_new: + return + self._device_list_cache[did]['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler( + did, MIoTDeviceState.ONLINE if state_new + else MIoTDeviceState.OFFLINE, sub.handler_ctx) + self.__request_show_devices_changed_notify() + + @final + async def __on_gw_device_list_changed( + self, mips: MipsLocalClient, did_list: list[str] + ) -> None: + _LOGGER.info( + 'gateway devices list changed, %s, %s', mips.group_id, did_list) + payload: dict = {'filter': {'did': did_list}} + gw_list = await mips.get_dev_list_async( + payload=json.dumps(payload)) + if gw_list is None: + _LOGGER.error('local mips get_dev_list_async failed, %s', did_list) + return + await self.__update_devices_from_gw_async( + gw_list=gw_list, group_id=mips.group_id, filter_dids=[ + did for did in did_list + if self._device_list_gateway.get(did, {}).get( + 'group_id', None) == mips.group_id]) + self.__request_show_devices_changed_notify() + + @final + async def __on_lan_device_state_changed( + self, did: str, state: dict, ctx: any + ) -> None: + _LOGGER.info('lan device state changed, %s, %s', did, state) + lan_state_new: bool = state.get('online', False) + lan_sub_new: bool = state.get('push_available', False) + self._device_list_lan.setdefault(did, {}) + if ( + lan_state_new == self._device_list_lan[did].get('online', False) + and lan_sub_new == self._device_list_lan[did].get( + 'push_available', False) + ): + return + self._device_list_lan[did]['online'] = lan_state_new + self._device_list_lan[did]['push_available'] = lan_sub_new + if did not in self._device_list_cache: + return + self.__update_device_msg_sub(did=did) + if lan_state_new == self._device_list_cache[did].get('online', False): + return + state_old: Optional[bool] = self._device_list_cache[did].get( + 'online', None) + state_new: Optional[bool] = self.__check_device_state( + self._device_list_cloud.get(did, {}).get('online', None), + self._device_list_gateway.get(did, {}).get('online', False), + lan_state_new) + if state_old == state_new: + return + self._device_list_cache[did]['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler( + did, MIoTDeviceState.ONLINE if state_new + else MIoTDeviceState.OFFLINE, sub.handler_ctx) + self.__request_show_devices_changed_notify() + + @final + def __on_prop_msg(self, params: dict, ctx: any) -> None: + """params MUST contain did, siid, piid, value""" + # BLE device has no online/offline msg + try: + subs: list[MIoTClientSub] = list(self._sub_tree.iter_match( + f'{params["did"]}/p/{params["siid"]}/{params["piid"]}')) + for sub in subs: + sub.handler(params, sub.handler_ctx) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('on prop msg error, %s, %s', params, err) + + @final + def __on_event_msg(self, params: dict, ctx: any) -> None: + try: + subs: list[MIoTClientSub] = list(self._sub_tree.iter_match( + f'{params["did"]}/e/{params["siid"]}/{params["eiid"]}')) + for sub in subs: + sub.handler(params, sub.handler_ctx) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('on event msg error, %s, %s', params, err) + + @final + def __check_device_state( + self, cloud_state: Optional[bool], gw_state: bool, lan_state: bool + ) -> Optional[bool]: + if cloud_state is None and not gw_state and not lan_state: + # Device remove + return None + if cloud_state or gw_state or lan_state: + return True + return False + + @final + async def __load_cache_device_async(self) -> None: + """Load device list from cache.""" + cache_list: Optional[dict[str, dict]] = await self._storage.load_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + type_=dict) + if not cache_list: + self.__show_client_error_notify( + message=self._i18n.translate( + 'miot.client.invalid_device_cache'), + notify_key='device_cache') + raise MIoTClientError('load device list from cache error') + else: + self.__show_client_error_notify( + message=None, notify_key='device_cache') + # Set default online status = False + self._device_list_cache = {} + for did, info in cache_list.items(): + if info.get('online', None): + self._device_list_cache[did] = { + **info, 'online': False} + else: + self._device_list_cache[did] = info + self._device_list_cloud = deepcopy(self._device_list_cache) + self._device_list_gateway = { + did: { + 'did': did, + 'name': info.get('name', None), + 'group_id': info.get('group_id', None), + 'online': False, + 'push_available': False} + for did, info in self._device_list_cache.items()} + + @final + async def __update_devices_from_cloud_async( + self, cloud_list: dict[str, dict], + filter_dids: Optional[list[str]] = None + ) -> None: + """Update cloud devices. + NOTICE: This function will operate the cloud_list + """ + for did, info in self._device_list_cache.items(): + if filter_dids and did not in filter_dids: + continue + state_old: Optional[bool] = info.get('online', None) + cloud_state_old: Optional[bool] = self._device_list_cloud.get( + did, {}).get('online', None) + cloud_state_new: Optional[bool] = None + device_new: dict = cloud_list.pop(did, None) + if device_new: + cloud_state_new = device_new.get('online', None) + # Update cache device info + info.update( + {**device_new, 'online': state_old}) + # Update cloud device + self._device_list_cloud[did] = device_new + else: + # Device deleted + self._device_list_cloud[did]['online'] = None + if cloud_state_old == cloud_state_new: + # Cloud online status no change + continue + # Update sub from + self.__update_device_msg_sub(did=did) + state_new: Optional[bool] = self.__check_device_state( + cloud_state_new, + self._device_list_gateway.get(did, {}).get('online', False), + self._device_list_lan.get(did, {}).get('online', False)) + if state_old == state_new: + # Online status no change + continue + info['online'] = state_new + # Call device state changed callback + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler( + did, MIoTDeviceState.ONLINE if state_new + else MIoTDeviceState.OFFLINE, sub.handler_ctx) + # New devices + self._device_list_cloud.update(cloud_list) + # Update storage + if not await self._storage.save_async( + domain='miot_devices', + name=f'{self._uid}_{self._cloud_server}', + data=self._device_list_cache + ): + _LOGGER.error('save device list to cache failed') + + @final + async def __refresh_cloud_devices_async(self) -> None: + _LOGGER.debug( + 'refresh cloud devices, %s, %s', self._uid, self._cloud_server) + self._refresh_cloud_devices_timer = None + result = await self._http.get_devices_async( + home_ids=list(self._entry_data.get('home_selected', {}).keys())) + if not result and 'devices' not in result: + self.__show_client_error_notify( + message=self._i18n.translate('miot.client.device_cloud_error'), + notify_key='device_cloud') + return + else: + self.__show_client_error_notify( + message=None, notify_key='device_cloud') + cloud_list: dict[str, dict] = result['devices'] + await self.__update_devices_from_cloud_async(cloud_list=cloud_list) + # Update lan device + if ( + self._ctrl_mode == CtrlMode.AUTO + and self._miot_lan.init_done + ): + self._miot_lan.update_devices(devices={ + did: { + 'token': info['token'], + 'connect_type': info['connect_type']} + for did, info in self._device_list_cache.items() + if 'token' in info and 'connect_type' in info + and info['connect_type'] in [0, 8, 12, 23] + }) + + self.__request_show_devices_changed_notify() + + @final + async def __refresh_cloud_device_with_dids_async( + self, dids: list[str] + ) -> None: + _LOGGER.debug('refresh cloud device with dids, %s', dids) + cloud_list: dict[str, dict] = ( + await self._http.get_devices_with_dids_async(dids=dids)) + if cloud_list is None: + _LOGGER.error('cloud http get_dev_list_async failed, %s', dids) + return + await self.__update_devices_from_cloud_async( + cloud_list=cloud_list, filter_dids=dids) + self.__request_show_devices_changed_notify() + + def __request_refresh_cloud_devices(self, immediately=False) -> None: + _LOGGER.debug( + 'request refresh cloud devices, %s, %s', + self._uid, self._cloud_server) + if immediately: + if self._refresh_cloud_devices_timer: + self._refresh_cloud_devices_timer.cancel() + self._refresh_cloud_devices_timer = self._main_loop.call_later( + 0, lambda: self._main_loop.create_task( + self.__refresh_cloud_devices_async())) + return + if self._refresh_cloud_devices_timer: + return + self._refresh_cloud_devices_timer = self._main_loop.call_later( + 6, lambda: self._main_loop.create_task( + self.__refresh_cloud_devices_async())) + + @final + async def __update_devices_from_gw_async( + self, gw_list: dict[str, dict], + group_id: Optional[str] = None, + filter_dids: Optional[list[str]] = None + ) -> None: + """Update cloud devices. + NOTICE: This function will operate the gw_list""" + _LOGGER.debug('update gw devices, %s, %s', group_id, filter_dids) + if not gw_list and not filter_dids: + return + for did, info in self._device_list_cache.items(): + if did not in filter_dids: + continue + device_old: dict = self._device_list_gateway.get(did, None) + gw_state_old = device_old.get( + 'online', False) if device_old else False + gw_state_new: bool = False + device_new: dict = gw_list.pop(did, None) + if device_new: + # Update gateway device info + self._device_list_gateway[did] = { + **device_new, 'group_id': group_id} + gw_state_new = device_new.get('online', False) + else: + # Device offline + if device_old: + device_old['online'] = False + # Update cache group_id + info['group_id'] = group_id + if gw_state_old == gw_state_new: + continue + self.__update_device_msg_sub(did=did) + state_old: Optional[bool] = info.get('online', None) + state_new: Optional[bool] = self.__check_device_state( + self._device_list_cloud.get(did, {}).get('online', None), + gw_state_new, + self._device_list_lan.get(did, {}).get('online', False)) + if state_old == state_new: + continue + info['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler( + did, MIoTDeviceState.ONLINE if state_new + else MIoTDeviceState.OFFLINE, sub.handler_ctx) + # New devices or device home info changed + for did, info in gw_list.items(): + self._device_list_gateway[did] = {**info, 'group_id': group_id} + if did not in self._device_list_cache: + continue + group_id_old: str = self._device_list_cache[did].get( + 'group_id', None) + self._device_list_cache[did]['group_id'] = group_id + _LOGGER.info( + 'move device %s from %s to %s', did, group_id_old, group_id) + self.__update_device_msg_sub(did=did) + state_old: Optional[bool] = self._device_list_cache[did].get( + 'online', None) + state_new: Optional[bool] = self.__check_device_state( + self._device_list_cloud.get(did, {}).get('online', None), + info.get('online', False), + self._device_list_lan.get(did, {}).get('online', False)) + if state_old == state_new: + continue + self._device_list_cache[did]['online'] = state_new + sub: MipsDeviceState = self._sub_device_state.get(did, None) + if sub and sub.handler: + sub.handler( + did, MIoTDeviceState.ONLINE if state_new + else MIoTDeviceState.OFFLINE, sub.handler_ctx) + + @final + async def __refresh_gw_devices_with_group_id_async( + self, group_id: str + ) -> None: + """Refresh gateway devices by group_id""" + _LOGGER.debug( + 'refresh gw devices with group_id, %s', group_id) + # Remove timer + self._mips_local_state_changed_timers.pop(group_id, None) + mips: MipsLocalClient = self._mips_local.get(group_id, None) + if not mips: + _LOGGER.error('mips not exist, %s', group_id) + return + if not mips.mips_state: + _LOGGER.debug('local mips disconnect, skip refresh, %s', group_id) + return + gw_list: dict = await mips.get_dev_list_async() + if gw_list is None: + _LOGGER.error( + 'refresh gw devices with group_id failed, %s, %s', + self._uid, group_id) + # Retry until success + self.__request_refresh_gw_devices_by_group_id( + group_id=group_id) + return + await self.__update_devices_from_gw_async( + gw_list=gw_list, group_id=group_id, filter_dids=[ + did for did, info in self._device_list_gateway.items() + if info.get('group_id', None) == group_id]) + self.__request_show_devices_changed_notify() + + @final + def __request_refresh_gw_devices_by_group_id( + self, group_id: str, immediately: bool = False + ) -> None: + """Request refresh gateway devices by group_id""" + refresh_timer = self._mips_local_state_changed_timers.get( + group_id, None) + if immediately: + if refresh_timer: + self._mips_local_state_changed_timers.pop(group_id, None) + refresh_timer.cancel() + self._mips_local_state_changed_timers[group_id] = ( + self._main_loop.call_later( + 0, lambda: self._main_loop.create_task( + self.__refresh_gw_devices_with_group_id_async( + group_id=group_id)))) + if refresh_timer: + return + self._mips_local_state_changed_timers[group_id] = ( + self._main_loop.call_later( + 3, lambda: self._main_loop.create_task( + self.__refresh_gw_devices_with_group_id_async( + group_id=group_id)))) + + @final + async def __refresh_props_from_cloud(self, patch_len: int = 150) -> bool: + if not self._network.network_status: + return False + + request_list = None + if len(self._refresh_props_list) < patch_len: + request_list = self._refresh_props_list + self._refresh_props_list = {} + else: + request_list = {} + for _ in range(patch_len): + key, value = self._refresh_props_list.popitem() + request_list[key] = value + try: + results = await self._http.get_props_async( + params=list(request_list.values())) + if not results: + raise MIoTClientError('get_props_async failed') + for result in results: + if ( + 'did' not in result + or 'siid' not in result + or 'piid' not in result + or 'value' not in result + ): + continue + request_list.pop( + f'{result["did"]}|{result["siid"]}|{result["piid"]}', + None) + self.__on_prop_msg(params=result, ctx=None) + if request_list: + _LOGGER.error( + 'refresh props failed, cloud, %s', + list(request_list.keys())) + request_list = None + return True + except Exception as err: # pylint:disable=broad-exception-caught + _LOGGER.error( + 'refresh props error, cloud, %s, %s', + err, traceback.format_exc()) + # Add failed request back to the list + self._refresh_props_list.update(request_list) + return False + + @final + async def __refresh_props_from_gw(self) -> bool: + if not self._mips_local or not self._device_list_gateway: + return False + request_list = {} + succeed_once = False + for key in list(self._refresh_props_list.keys()): + did = key.split('|')[0] + if did in request_list: + # NOTICE: A device only requests once a cycle, continuous + # acquisition of properties can cause device exceptions. + continue + params = self._refresh_props_list.pop(key) + device_gw = self._device_list_gateway.get(did, None) + if not device_gw: + # Device not exist + continue + mips_gw = self._mips_local.get(device_gw['group_id'], None) + if not mips_gw: + _LOGGER.error('mips gateway not exist, %s', key) + continue + request_list[did] = { + **params, + 'fut': mips_gw.get_prop_async( + did=did, siid=params['siid'], piid=params['piid'], + timeout_ms=6000)} + results = await asyncio.gather( + *[v['fut'] for v in request_list.values()]) + for (did, param), result in zip(request_list.items(), results): + if result is None: + # Don't use "not result", it will be skipped when result + # is 0, false + continue + self.__on_prop_msg( + params={ + 'did': did, + 'siid': param['siid'], + 'piid': param['piid'], + 'value': result}, + ctx=None) + succeed_once = True + if succeed_once: + return True + _LOGGER.error( + 'refresh props failed, gw, %s', list(request_list.keys())) + # Add failed request back to the list + self._refresh_props_list.update(request_list) + return False + + @final + async def __refresh_props_from_lan(self) -> bool: + if not self._miot_lan.init_done or len(self._mips_local) > 0: + return False + request_list = {} + succeed_once = False + for key in list(self._refresh_props_list.keys()): + did = key.split('|')[0] + if did in request_list: + # NOTICE: A device only requests once a cycle, continuous + # acquisition of properties can cause device exceptions. + continue + params = self._refresh_props_list.pop(key) + if did not in self._device_list_lan: + continue + request_list[did] = { + **params, + 'fut': self._miot_lan.get_prop_async( + did=did, siid=params['siid'], piid=params['piid'], + timeout_ms=6000)} + results = await asyncio.gather( + *[v['fut'] for v in request_list.values()]) + for (did, param), result in zip(request_list.items(), results): + if result is None: + # Don't use "not result", it will be skipped when result + # is 0, false + continue + self.__on_prop_msg( + params={ + 'did': did, + 'siid': param['siid'], + 'piid': param['piid'], + 'value': result}, + ctx=None) + succeed_once = True + if succeed_once: + return True + _LOGGER.error( + 'refresh props failed, lan, %s', list(request_list.keys())) + # Add failed request back to the list + self._refresh_props_list.update(request_list) + return False + + @final + async def __refresh_props_handler(self) -> None: + if not self._refresh_props_list: + return + # Cloud, Central hub gateway, Lan control + if ( + await self.__refresh_props_from_cloud() + or await self.__refresh_props_from_gw() + or await self.__refresh_props_from_lan() + ): + self._refresh_props_retry_count = 0 + if self._refresh_props_list: + self._refresh_props_timer = self._main_loop.call_later( + 0.2, lambda: self._main_loop.create_task( + self.__refresh_props_handler())) + else: + self._refresh_props_timer = None + return + + # Try three times, and if it fails three times, empty the list. + if self._refresh_props_retry_count >= 3: + self._refresh_props_list = {} + self._refresh_props_retry_count = 0 + if self._refresh_props_timer: + self._refresh_props_timer.cancel() + self._refresh_props_timer = None + _LOGGER.error('refresh props failed, retry count exceed') + return + self._refresh_props_retry_count += 1 + _LOGGER.error( + 'refresh props failed, retry, %s', self._refresh_props_retry_count) + self._refresh_props_timer = self._main_loop.call_later( + 3, lambda: self._main_loop.create_task( + self.__refresh_props_handler())) + + @final + def __show_client_error_notify( + self, message: str, notify_key: str = '' + ) -> None: + if message: + self._persistence_notify( + f'{DOMAIN}{self._uid}{self._cloud_server}{notify_key}error', + self._i18n.translate( + key='miot.client.xiaomi_home_error_title'), + self._i18n.translate( + key='miot.client.xiaomi_home_error', + replace={ + 'nick_name': self._entry_data.get( + 'nick_name', DEFAULT_NICK_NAME), + 'uid': self._uid, + 'cloud_server': self._cloud_server, + 'message': message + })) + else: + self._persistence_notify( + f'{DOMAIN}{self._uid}{self._cloud_server}{notify_key}error', + None, None) + + @final + def __show_devices_changed_notify(self) -> None: + """Show device list changed notify""" + self._show_devices_changed_notify_timer = None + if self._persistence_notify is None: + return + + message_add: str = '' + count_add: int = 0 + message_del: str = '' + count_del: int = 0 + message_offline: str = '' + count_offline: int = 0 + + # New devices + for did, info in { + **self._device_list_gateway, **self._device_list_cloud + }.items(): + if did in self._device_list_cache: + continue + count_add += 1 + message_add += ( + f'- {info.get("name", "unknown")} ({did}, ' + f'{info.get("model", "unknown")})\n') + # Get unavailable and offline devices + home_name_del: Optional[str] = None + home_name_offline: Optional[str] = None + for did, info in self._device_list_cache.items(): + online: Optional[bool] = info.get('online', None) + home_name_new = info.get('home_name', 'unknown') + if online: + # Skip online device + continue + if online is None: + # Device not exist + if home_name_del != home_name_new: + message_del += f'\n[{home_name_new}]\n' + home_name_del = home_name_new + count_del += 1 + message_del += ( + f'- {info.get("name", "unknown")} ({did}, ' + f'{info.get("room_name", "unknown")})\n') + else: + # Device offline + if home_name_offline != home_name_new: + message_offline += f'\n[{home_name_new}]\n' + home_name_offline = home_name_new + count_offline += 1 + message_offline += ( + f'- {info.get("name", "unknown")} ({did}, ' + f'{info.get("room_name", "unknown")})\n') + + message = '' + if count_add: + message += self._i18n.translate( + key='miot.client.device_list_add', + replace={ + 'count': count_add, + 'message': message_add}) + if count_del: + message += self._i18n.translate( + key='miot.client.device_list_del', + replace={ + 'count': count_del, + 'message': message_del}) + if count_offline: + message += self._i18n.translate( + key='miot.client.device_list_offline', + replace={ + 'count': count_offline, + 'message': message_offline}) + if message != '': + network_status = self._i18n.translate( + key='miot.client.network_status_online' + if self._network.network_status + else 'miot.client.network_status_offline') + self._persistence_notify( + self.__gen_notify_key('dev_list_changed'), + self._i18n.translate('miot.client.device_list_changed_title'), + self._i18n.translate( + key='miot.client.device_list_changed', + replace={ + 'nick_name': self._entry_data.get( + 'nick_name', DEFAULT_NICK_NAME), + 'uid': self._uid, + 'cloud_server': self._cloud_server, + 'network_status': network_status, + 'message': message + })) + _LOGGER.debug( + 'show device list changed notify, add %s, del %s, offline %s', + count_add, count_del, count_offline) + else: + self._persistence_notify( + self.__gen_notify_key('dev_list_changed'), None, None) + + @final + def __request_show_devices_changed_notify( + self, delay_sec: float = 6 + ) -> None: + if self._show_devices_changed_notify_timer: + self._show_devices_changed_notify_timer.cancel() + self._show_devices_changed_notify_timer = self._main_loop.call_later( + delay_sec, self.__show_devices_changed_notify) + + +@ staticmethod +async def get_miot_instance_async( + hass: HomeAssistant, entry_id: str, entry_data: Optional[dict] = None, + persistent_notify: Optional[Callable[[str, str, str], None]] = None +) -> MIoTClient: + if entry_id is None: + raise MIoTClientError('invalid entry_id') + miot_client: MIoTClient = None + if a := hass.data[DOMAIN].get('miot_clients', {}).get(entry_id, None): + _LOGGER.info('instance exist, %s', entry_id) + miot_client = a + else: + if entry_data is None: + raise MIoTClientError('entry data is None') + # Get running loop + loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + if loop is None: + raise MIoTClientError('loop is None') + # MIoT network + network: Optional[MIoTNetwork] = hass.data[DOMAIN].get( + 'miot_network', None) + if not network: + network = MIoTNetwork(loop=loop) + hass.data[DOMAIN]['miot_network'] = network + await network.init_async( + refresh_interval=NETWORK_REFRESH_INTERVAL) + _LOGGER.info('create miot_network instance') + # MIoT storage + storage: Optional[MIoTStorage] = hass.data[DOMAIN].get( + 'miot_storage', None) + if not storage: + storage = MIoTStorage( + root_path=entry_data['storage_path'], loop=loop) + hass.data[DOMAIN]['miot_storage'] = storage + _LOGGER.info('create miot_storage instance') + # MIoT service + mips_service: Optional[MipsService] = hass.data[DOMAIN].get( + 'mips_service', None) + if not mips_service: + aiozc = await zeroconf.async_get_async_instance(hass) + mips_service: MipsService = MipsService(aiozc=aiozc, loop=loop) + hass.data[DOMAIN]['mips_service'] = mips_service + await mips_service.init_async() + _LOGGER.info('create mips_service instance') + # MIoT lan + miot_lan: Optional[MIoTLan] = hass.data[DOMAIN].get( + 'miot_lan', None) + if not miot_lan: + lan_config = (await storage.load_user_config_async( + uid='global_config', + cloud_server='all', + keys=['net_interfaces', 'enable_subscribe'])) or {} + miot_lan = MIoTLan( + net_ifs=lan_config.get('net_interfaces', []), + network=network, + mips_service=mips_service, + enable_subscribe=lan_config.get('enable_subscribe', False), + loop=loop) + hass.data[DOMAIN]['miot_lan'] = miot_lan + _LOGGER.info('create miot_lan instance') + # MIoT client + miot_client = MIoTClient( + entry_id=entry_id, + entry_data=entry_data, + network=network, + storage=storage, + mips_service=mips_service, + miot_lan=miot_lan, + loop=loop + ) + miot_client.persistent_notify = persistent_notify + hass.data[DOMAIN]['miot_clients'].setdefault(entry_id, miot_client) + _LOGGER.info( + 'new miot_client instance, %s, %s', entry_id, entry_data) + await miot_client.init_async() + + return miot_client diff --git a/custom_components/xiaomi_home/miot/miot_cloud.py b/custom_components/xiaomi_home/miot/miot_cloud.py new file mode 100644 index 0000000..fc9ea35 --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_cloud.py @@ -0,0 +1,809 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT http client. +""" +import asyncio +import base64 +import json +import logging +import re +import time +from functools import partial +from typing import Optional +from urllib.parse import urlencode +import requests + +from .common import calc_group_id +from .const import ( + DEFAULT_OAUTH2_API_HOST, + MIHOME_HTTP_API_TIMEOUT, + OAUTH2_AUTH_URL) +from .miot_error import MIoTErrorCode, MIoTHttpError, MIoTOauthError + +_LOGGER = logging.getLogger(__name__) + +TOKEN_EXPIRES_TS_RATIO = 0.7 + + +class MIoTOauthClient: + """oauth agent url, default: product env.""" + _main_loop: asyncio.AbstractEventLoop = None + _oauth_host: str = None + _client_id: int + _redirect_url: str + + def __init__( + self, client_id: str, redirect_url: str, cloud_server: str, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_running_loop() + if client_id is None or client_id.strip() == '': + raise MIoTOauthError('invalid client_id') + if not redirect_url: + raise MIoTOauthError('invalid redirect_url') + if not cloud_server: + raise MIoTOauthError('invalid cloud_server') + + self._client_id = int(client_id) + self._redirect_url = redirect_url + if cloud_server == 'cn': + self._oauth_host = DEFAULT_OAUTH2_API_HOST + else: + self._oauth_host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' + + async def __call_async(self, func): + return await self._main_loop.run_in_executor(executor=None, func=func) + + def set_redirect_url(self, redirect_url: str) -> None: + if not isinstance(redirect_url, str) or redirect_url.strip() == '': + raise MIoTOauthError('invalid redirect_url') + self._redirect_url = redirect_url + + def gen_auth_url( + self, + redirect_url: Optional[str] = None, + state: Optional[str] = None, + scope: Optional[list] = None, + skip_confirm: Optional[bool] = False, + ) -> str: + """get auth url + + Args: + redirect_url + state + scope (list, optional): + 开放数据接口权限 ID,可以传递多个,用空格分隔,具体值可以参考开放 + [数据接口权限列表](https://dev.mi.com/distribute/doc/details?pId=1518). + Defaults to None.\n + skip_confirm (bool, optional): + 默认值为true,授权有效期内的用户在已登录情况下,不显示授权页面,直接通过。 + 如果需要用户每次手动授权,设置为false. Defaults to True.\n + + Returns: + str: _description_ + """ + params: dict = { + 'redirect_uri': redirect_url or self._redirect_url, + 'client_id': self._client_id, + 'response_type': 'code', + } + if state: + params['state'] = state + if scope: + params['scope'] = ' '.join(scope).strip() + params['skip_confirm'] = skip_confirm + encoded_params = urlencode(params) + + return f'{OAUTH2_AUTH_URL}?{encoded_params}' + + def _get_token(self, data) -> dict: + http_res = requests.get( + url=f'https://{self._oauth_host}/app/v2/ha/oauth/get_token', + params={'data': json.dumps(data)}, + headers={'content-type': 'application/x-www-form-urlencoded'}, + timeout=MIHOME_HTTP_API_TIMEOUT + ) + if http_res.status_code == 401: + raise MIoTOauthError( + 'unauthorized(401)', MIoTErrorCode.CODE_OAUTH_UNAUTHORIZED) + if http_res.status_code != 200: + raise MIoTOauthError( + f'invalid http status code, {http_res.status_code}') + + res_obj = http_res.json() + if ( + not res_obj + or res_obj.get('code', None) != 0 + or 'result' not in res_obj + or not all( + key in res_obj['result'] + for key in ['access_token', 'refresh_token', 'expires_in']) + ): + raise MIoTOauthError(f'invalid http response, {http_res.text}') + + return { + **res_obj['result'], + 'expires_ts': int( + time.time() + + (res_obj['result'].get('expires_in', 0)*TOKEN_EXPIRES_TS_RATIO)) + } + + def get_access_token(self, code: str) -> dict: + """get access token by authorization code + + Args: + code (str): auth code + + Returns: + str: _description_ + """ + if not isinstance(code, str): + raise MIoTOauthError('invalid code') + + return self._get_token(data={ + 'client_id': self._client_id, + 'redirect_uri': self._redirect_url, + 'code': code, + }) + + async def get_access_token_async(self, code: str) -> dict: + return await self.__call_async(partial(self.get_access_token, code)) + + def refresh_access_token(self, refresh_token: str) -> dict: + """get access token by refresh token. + + Args: + refresh_token (str): refresh_token + + Returns: + str: _description_ + """ + if not isinstance(refresh_token, str): + raise MIoTOauthError('invalid refresh_token') + + return self._get_token(data={ + 'client_id': self._client_id, + 'redirect_uri': self._redirect_url, + 'refresh_token': refresh_token, + }) + + async def refresh_access_token_async(self, refresh_token: str) -> dict: + return await self.__call_async( + partial(self.refresh_access_token, refresh_token)) + + +class MIoTHttpClient: + """MIoT http client.""" + GET_PROP_AGGREGATE_INTERVAL: float = 0.2 + GET_PROP_MAX_REQ_COUNT = 150 + _main_loop: asyncio.AbstractEventLoop + _host: str + _base_url: str + _client_id: str + _access_token: str + + _get_prop_timer: asyncio.TimerHandle + _get_prop_list: dict[str, dict[str, asyncio.Future | str | bool]] + + def __init__( + self, cloud_server: str, client_id: str, access_token: str, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_running_loop() + self._host = None + self._base_url = None + self._client_id = None + self._access_token = None + + self._get_prop_timer: asyncio.TimerHandle = None + self._get_prop_list = {} + + if ( + not isinstance(cloud_server, str) + or not isinstance(client_id, str) + or not isinstance(access_token, str) + ): + raise MIoTHttpError('invalid params') + + self.update_http_header( + cloud_server=cloud_server, client_id=client_id, + access_token=access_token) + + async def __call_async(self, func) -> any: + if self._main_loop is None: + raise MIoTHttpError('miot http, un-support async methods') + return await self._main_loop.run_in_executor(executor=None, func=func) + + def update_http_header( + self, cloud_server: Optional[str] = None, + client_id: Optional[str] = None, + access_token: Optional[str] = None + ) -> None: + if isinstance(cloud_server, str): + if cloud_server == 'cn': + self._host = DEFAULT_OAUTH2_API_HOST + else: + self._host = f'{cloud_server}.{DEFAULT_OAUTH2_API_HOST}' + self._base_url = f'https://{self._host}' + if isinstance(client_id, str): + self._client_id = client_id + if isinstance(access_token, str): + self._access_token = access_token + + @property + def __api_session(self) -> requests.Session: + session = requests.Session() + session.headers.update({ + 'Host': self._host, + 'X-Client-BizId': 'haapi', + 'Content-Type': 'application/json', + 'Authorization': f'Bearer{self._access_token}', + 'X-Client-AppId': self._client_id, + }) + return session + + def mihome_api_get( + self, url_path: str, params: dict, + timeout: int = MIHOME_HTTP_API_TIMEOUT + ) -> dict: + http_res = None + with self.__api_session as session: + http_res = session.get( + url=f'{self._base_url}{url_path}', + params=params, + timeout=timeout) + if http_res.status_code == 401: + raise MIoTHttpError( + 'mihome api get failed, unauthorized(401)', + MIoTErrorCode.CODE_HTTP_INVALID_ACCESS_TOKEN) + if http_res.status_code != 200: + raise MIoTHttpError( + f'mihome api get failed, {http_res.status_code}, ' + f'{url_path}, {params}') + res_obj: dict = http_res.json() + if res_obj.get('code', None) != 0: + raise MIoTHttpError( + f'invalid response code, {res_obj.get("code",None)}, ' + f'{res_obj.get("message","")}') + _LOGGER.debug( + 'mihome api get, %s%s, %s -> %s', + self._base_url, url_path, params, res_obj) + return res_obj + + def mihome_api_post( + self, url_path: str, data: dict, + timeout: int = MIHOME_HTTP_API_TIMEOUT + ) -> dict: + encoded_data = None + if data: + encoded_data = json.dumps(data).encode('utf-8') + http_res = None + with self.__api_session as session: + http_res = session.post( + url=f'{self._base_url}{url_path}', + data=encoded_data, + timeout=timeout) + if http_res.status_code == 401: + raise MIoTHttpError( + 'mihome api get failed, unauthorized(401)', + MIoTErrorCode.CODE_HTTP_INVALID_ACCESS_TOKEN) + if http_res.status_code != 200: + raise MIoTHttpError( + f'mihome api post failed, {http_res.status_code}, ' + f'{url_path}, {data}') + res_obj: dict = http_res.json() + if res_obj.get('code', None) != 0: + raise MIoTHttpError( + f'invalid response code, {res_obj.get("code",None)}, ' + f'{res_obj.get("message","")}') + _LOGGER.debug( + 'mihome api post, %s%s, %s -> %s', + self._base_url, url_path, data, res_obj) + return res_obj + + def get_user_info(self) -> dict: + http_res = requests.get( + url='https://open.account.xiaomi.com/user/profile', + params={'clientId': self._client_id, + 'token': self._access_token}, + headers={'content-type': 'application/x-www-form-urlencoded'}, + timeout=MIHOME_HTTP_API_TIMEOUT + ) + + res_obj = http_res.json() + if ( + not res_obj + or res_obj.get('code', None) != 0 + or 'data' not in res_obj + or 'miliaoNick' not in res_obj['data'] + ): + raise MIoTOauthError(f'invalid http response, {http_res.text}') + + return res_obj['data'] + + async def get_user_info_async(self) -> dict: + return await self.__call_async(partial(self.get_user_info)) + + def get_central_cert(self, csr: str) -> Optional[str]: + if not isinstance(csr, str): + raise MIoTHttpError('invalid params') + + res_obj: dict = self.mihome_api_post( + url_path='/app/v2/ha/oauth/get_central_crt', + data={ + 'csr': str(base64.b64encode(csr.encode('utf-8')), 'utf-8') + } + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + cert: str = res_obj['result'].get('cert', None) + if not isinstance(cert, str): + raise MIoTHttpError('invalid cert') + + return cert + + async def get_central_cert_async(self, csr: str) -> Optional[str]: + return await self.__call_async(partial(self.get_central_cert, csr)) + + def __get_dev_room_page(self, max_id: str = None) -> dict: + res_obj = self.mihome_api_post( + url_path='/app/v2/homeroom/get_dev_room_page', + data={ + 'start_id': max_id, + 'limit': 150, + }, + ) + if 'result' not in res_obj and 'info' not in res_obj['result']: + raise MIoTHttpError('invalid response result') + home_list: dict = {} + for home in res_obj['result']['info']: + if 'id' not in home: + _LOGGER.error( + 'get dev room page error, invalid home, %s', home) + continue + home_list[str(home['id'])] = {'dids': home.get( + 'dids', None) or [], 'room_info': {}} + for room in home.get('roomlist', []): + if 'id' not in room: + _LOGGER.error( + 'get dev room page error, invalid room, %s', room) + continue + home_list[str(home['id'])]['room_info'][str(room['id'])] = { + 'dids': room.get('dids', None) or []} + if ( + res_obj['result'].get('has_more', False) + and isinstance(res_obj['result'].get('max_id', None), str) + ): + next_list = self.__get_dev_room_page( + max_id=res_obj['result']['max_id']) + for home_id, info in next_list.items(): + home_list.setdefault(home_id, {'dids': [], 'room_info': {}}) + home_list[home_id]['dids'].extend(info['dids']) + for room_id, info in info['room_info'].items(): + home_list[home_id]['room_info'].setdefault( + room_id, {'dids': []}) + home_list[home_id]['room_info'][room_id]['dids'].extend( + info['dids']) + + return home_list + + def get_homeinfos(self) -> dict: + res_obj = self.mihome_api_post( + url_path='/app/v2/homeroom/gethome', + data={ + 'limit': 150, + 'fetch_share': True, + 'fetch_share_dev': True, + 'plat_form': 0, + 'app_ver': 9, + }, + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + + uid: str = None + home_infos: dict = {} + for device_source in ['homelist', 'share_home_list']: + home_infos.setdefault(device_source, {}) + for home in res_obj['result'].get(device_source, []): + if ( + 'id' not in home + or 'name' not in home + or 'roomlist' not in home + ): + continue + if uid is None and device_source == 'homelist': + uid = str(home['uid']) + home_infos[device_source][home['id']] = { + 'home_id': home['id'], + 'home_name': home['name'], + 'city_id': home.get('city_id', None), + 'longitude': home.get('longitude', None), + 'latitude': home.get('latitude', None), + 'address': home.get('address', None), + 'dids': home.get('dids', []), + 'room_info': { + room['id']: { + 'room_id': room['id'], + 'room_name': room['name'], + 'dids': room.get('dids', []) + } + for room in home.get('roomlist', []) + }, + 'group_id': calc_group_id( + uid=home['uid'], home_id=home['id']), + 'uid': str(home['uid']) + } + home_infos['uid'] = uid + if ( + res_obj['result'].get('has_more', False) + and isinstance(res_obj['result'].get('max_id', None), str) + ): + more_list = self.__get_dev_room_page( + max_id=res_obj['result']['max_id']) + for home_id, info in more_list.items(): + if home_id not in home_infos['homelist']: + _LOGGER.info('unknown home, %s, %s', home_id, info) + continue + home_infos['homelist'][home_id]['dids'].extend(info['dids']) + for room_id, info in info['room_info'].items(): + home_infos['homelist'][home_id]['room_info'].setdefault( + room_id, {'dids': []}) + home_infos['homelist'][home_id]['room_info'][ + room_id]['dids'].extend(info['dids']) + + return { + 'uid': uid, + 'home_list': home_infos.get('homelist', {}), + 'share_home_list': home_infos.get('share_home_list', []) + } + + async def get_homeinfos_async(self) -> dict: + return await self.__call_async(self.get_homeinfos) + + def get_uid(self) -> str: + return self.get_homeinfos().get('uid', None) + + async def get_uid_async(self) -> str: + return (await self.get_homeinfos_async()).get('uid', None) + + def __get_device_list_page( + self, dids: list[str], start_did: str = None + ) -> dict[str, dict]: + req_data: dict = { + 'limit': 200, + 'get_split_device': True, + 'dids': dids + } + if start_did: + req_data['start_did'] = start_did + device_infos: dict = {} + res_obj = self.mihome_api_post( + url_path='/app/v2/home/device_list_page', + data=req_data + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + res_obj = res_obj['result'] + + for device in res_obj.get('list', []) or []: + did = device.get('did', None) + name = device.get('name', None) + urn = device.get('spec_type', None) + model = device.get('model', None) + if did is None or name is None or urn is None or model is None: + _LOGGER.error( + 'get_device_list, cloud, invalid device, %s', device) + continue + device_infos[did] = { + 'did': did, + 'uid': device.get('uid', None), + 'name': name, + 'urn': urn, + 'model': model, + 'connect_type': device.get('pid', -1), + 'token': device.get('token', None), + 'online': device.get('isOnline', False), + 'icon': device.get('icon', None), + 'parent_id': device.get('parent_id', None), + 'manufacturer': model.split('.')[0], + # 2: xiao-ai, 1: general speaker + 'voice_ctrl': device.get('voice_ctrl', 0), + 'rssi': device.get('rssi', None), + 'owner': device.get('owner', None), + 'pid': device.get('pid', None), + 'local_ip': device.get('local_ip', None), + 'ssid': device.get('ssid', None), + 'bssid': device.get('bssid', None), + 'order_time': device.get('orderTime', 0), + 'fw_version': device.get('extra', {}).get( + 'fw_version', 'unknown'), + } + if isinstance(device.get('extra', None), dict) and device['extra']: + device_infos[did]['fw_version'] = device['extra'].get( + 'fw_version', None) + device_infos[did]['mcu_version'] = device['extra'].get( + 'mcu_version', None) + device_infos[did]['platform'] = device['extra'].get( + 'platform', None) + + next_start_did = res_obj.get('next_start_did', None) + if res_obj.get('has_more', False) and next_start_did: + device_infos.update(self.__get_device_list_page( + dids=dids, start_did=next_start_did)) + + return device_infos + + async def get_devices_with_dids_async( + self, dids: list[str] + ) -> dict[str, dict]: + results: list[dict[str, dict]] = await asyncio.gather( + *[self.__call_async( + partial(self.__get_device_list_page, dids[index:index+150])) + for index in range(0, len(dids), 150)]) + devices = {} + for result in results: + if result is None: + return None + devices.update(result) + return devices + + async def get_devices_async( + self, home_ids: list[str] = None + ) -> dict[str, dict]: + homeinfos = await self.get_homeinfos_async() + homes: dict[str, dict[str, any]] = {} + devices: dict[str, dict] = {} + for device_type in ['home_list', 'share_home_list']: + homes.setdefault(device_type, {}) + for home_id, home_info in (homeinfos.get( + device_type, None) or {}).items(): + if isinstance(home_ids, list) and home_id not in home_ids: + continue + homes[device_type].setdefault( + home_id, { + 'home_name': home_info['home_name'], + 'uid': home_info['uid'], + 'group_id': home_info['group_id'], + 'room_info': {} + }) + devices.update({did: { + 'home_id': home_id, + 'home_name': home_info['home_name'], + 'room_id': home_id, + 'room_name': home_info['home_name'], + 'group_id': home_info['group_id'] + } for did in home_info.get('dids', [])}) + for room_id, room_info in home_info.get('room_info').items(): + homes[device_type][home_id]['room_info'][ + room_id] = room_info['room_name'] + devices.update({ + did: { + 'home_id': home_id, + 'home_name': home_info['home_name'], + 'room_id': room_id, + 'room_name': room_info['room_name'], + 'group_id': home_info['group_id'] + } for did in room_info.get('dids', [])}) + dids = sorted(list(devices.keys())) + results: dict[str, dict] = await self.get_devices_with_dids_async( + dids=dids) + for did in dids: + if did not in results: + devices.pop(did, None) + _LOGGER.error('get device info failed, %s', did) + continue + devices[did].update(results[did]) + # Whether sub devices + match_str = re.search(r'\.s\d+$', did) + if not match_str: + continue + device = devices.pop(did, None) + parent_did = did.replace(match_str.group(), '') + if parent_did in devices: + devices[parent_did].setdefault('sub_devices', {}) + devices[parent_did]['sub_devices'][match_str.group()[ + 1:]] = device + else: + _LOGGER.error( + 'unknown sub devices, %s, %s', did, parent_did) + return { + 'uid': homeinfos['uid'], + 'homes': homes, + 'devices': devices + } + + def get_props(self, params: list) -> list: + """ + params = [{"did": "xxxx", "siid": 2, "piid": 1}, + {"did": "xxxxxx", "siid": 2, "piid": 2}] + """ + res_obj = self.mihome_api_post( + url_path='/app/v2/miotspec/prop/get', + data={ + 'datasource': 1, + 'params': params + }, + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + return res_obj['result'] + + async def get_props_async(self, params: list) -> list: + return await self.__call_async(partial(self.get_props, params)) + + def get_prop(self, did: str, siid: int, piid: int) -> any: + results = self.get_props( + params=[{'did': did, 'siid': siid, 'piid': piid}]) + if not results: + return None + result = results[0] + if 'value' not in result: + return None + return result['value'] + + async def __get_prop_handler(self) -> bool: + props_req: set[str] = set() + props_buffer: list[dict] = [] + + for key, item in self._get_prop_list.items(): + if item.get('tag', False): + continue + # NOTICE: max req prop + if len(props_req) >= self.GET_PROP_MAX_REQ_COUNT: + break + item['tag'] = True + props_buffer.append(item['param']) + props_req.add(key) + + if not props_buffer: + _LOGGER.error('get prop error, empty request list') + return False + results = await self.__call_async(partial(self.get_props, props_buffer)) + + for result in results: + if not all( + key in result for key in ['did', 'siid', 'piid', 'value']): + continue + key = f'{result["did"]}.{result["siid"]}.{result["piid"]}' + prop_obj = self._get_prop_list.pop(key, None) + if prop_obj is None: + _LOGGER.error('get prop error, key not exists, %s', result) + continue + prop_obj['fut'].set_result(result['value']) + props_req.remove(key) + + for key in props_req: + prop_obj = self._get_prop_list.pop(key, None) + if prop_obj is None: + continue + prop_obj['fut'].set_result(None) + if props_req: + _LOGGER.error( + 'get prop from cloud failed, %s, %s', len(key), props_req) + + if self._get_prop_list: + self._get_prop_timer = self._main_loop.call_later( + self.GET_PROP_AGGREGATE_INTERVAL, + lambda: self._main_loop.create_task( + self.__get_prop_handler())) + else: + self._get_prop_timer = None + return True + + async def get_prop_async( + self, did: str, siid: int, piid: int, immediately: bool = False + ) -> any: + if immediately: + return await self.__call_async( + partial(self.get_prop, did, siid, piid)) + key: str = f'{did}.{siid}.{piid}' + prop_obj = self._get_prop_list.get(key, None) + if prop_obj: + return await prop_obj['fut'] + fut = self._main_loop.create_future() + self._get_prop_list[key] = { + 'param': {'did': did, 'siid': siid, 'piid': piid}, + 'fut': fut + } + if self._get_prop_timer is None: + self._get_prop_timer = self._main_loop.call_later( + self.GET_PROP_AGGREGATE_INTERVAL, + lambda: self._main_loop.create_task( + self.__get_prop_handler())) + + return await fut + + def set_prop(self, params: list) -> list: + """ + params = [{"did": "xxxx", "siid": 2, "piid": 1, "value": False}] + """ + res_obj = self.mihome_api_post( + url_path='/app/v2/miotspec/prop/set', + data={ + 'params': params + }, + timeout=15 + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + + return res_obj['result'] + + async def set_prop_async(self, params: list) -> list: + """ + params = [{"did": "xxxx", "siid": 2, "piid": 1, "value": False}] + """ + return await self.__call_async(partial(self.set_prop, params)) + + def action( + self, did: str, siid: int, aiid: int, in_list: list[dict] + ) -> dict: + """ + params = {"did": "xxxx", "siid": 2, "aiid": 1, "in": []} + """ + # NOTICE: Non-standard action param + res_obj = self.mihome_api_post( + url_path='/app/v2/miotspec/action', + data={ + 'params': { + 'did': did, + 'siid': siid, + 'aiid': aiid, + 'in': [item['value'] for item in in_list]} + }, + timeout=15 + ) + if 'result' not in res_obj: + raise MIoTHttpError('invalid response result') + + return res_obj['result'] + + async def action_async( + self, did: str, siid: int, aiid: int, in_list: list[dict] + ) -> dict: + return await self.__call_async( + partial(self.action, did, siid, aiid, in_list)) diff --git a/custom_components/xiaomi_home/miot/miot_device.py b/custom_components/xiaomi_home/miot/miot_device.py new file mode 100644 index 0000000..12217d9 --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_device.py @@ -0,0 +1,1289 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT device instance. +""" +import asyncio +from abc import abstractmethod +from typing import Callable, Optional +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONCENTRATION_PARTS_PER_BILLION, + LIGHT_LUX, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS, + UnitOfEnergy, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfInformation, + UnitOfLength, + UnitOfMass, + UnitOfSpeed, + UnitOfTime, + UnitOfTemperature, + UnitOfPressure, + UnitOfPower, + UnitOfVolume, + UnitOfVolumeFlowRate, + UnitOfConductivity +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.util import slugify + +from .specs.specv2entity import ( + SPEC_ACTION_TRANS_MAP, + SPEC_DEVICE_TRANS_MAP, + SPEC_EVENT_TRANS_MAP, + SPEC_PROP_TRANS_MAP, + SPEC_SERVICE_TRANS_MAP +) +from .const import DOMAIN +from .miot_client import MIoTClient +from .miot_error import MIoTClientError, MIoTDeviceError +from .miot_mips import MIoTDeviceState +from .miot_spec import ( + MIoTSpecAction, + MIoTSpecEvent, + MIoTSpecInstance, + MIoTSpecProperty, + MIoTSpecService +) + +_LOGGER = logging.getLogger(__name__) + + +class MIoTEntityData: + """MIoT Entity Data.""" + platform: str + device_class: any + spec: MIoTSpecInstance | MIoTSpecService + + props: set[MIoTSpecProperty] + events: set[MIoTSpecEvent] + actions: set[MIoTSpecAction] + + def __init__( + self, platform: str, spec: MIoTSpecInstance | MIoTSpecService + ) -> None: + self.platform = platform + self.spec = spec + self.device_class = None + self.props = set() + self.events = set() + self.actions = set() + + +class MIoTDevice: + """MIoT Device Instance.""" + # pylint: disable=unused-argument + miot_client: MIoTClient + spec_instance: MIoTSpecInstance + + _online: bool + + _did: str + _name: str + _model: str + _model_strs: list[str] + _manufacturer: str + _fw_version: str + + _icon: str + _home_id: str + _home_name: str + _room_id: str + _room_name: str + + _suggested_area: str + + _device_state_sub_list: dict[str, Callable[[str, MIoTDeviceState], None]] + + _entity_list: dict[str, list[MIoTEntityData]] + _prop_list: dict[str, list[MIoTSpecProperty]] + _event_list: dict[str, list[MIoTSpecEvent]] + _action_list: dict[str, list[MIoTSpecAction]] + + def __init__( + self, miot_client: MIoTClient, + device_info: dict[str, str], + spec_instance: MIoTSpecInstance + ) -> None: + self.miot_client = miot_client + self.spec_instance = spec_instance + + self._online = device_info.get('online', False) + self._did = device_info['did'] + self._name = device_info['name'] + self._model = device_info['model'] + self._model_strs = self._model.split('.') + self._manufacturer = device_info.get('manufacturer', None) + self._fw_version = device_info.get('fw_version', None) + + self._icon = device_info.get('icon', None) + self._home_id = device_info.get('home_id', None) + self._home_name = device_info.get('home_name', None) + self._room_id = device_info.get('room_id', None) + self._room_name = device_info.get('room_name', None) + match self.miot_client.area_name_rule: + case 'home_room': + self._suggested_area = ( + f'{self._home_name} {self._room_name}'.strip()) + case 'home': + self._suggested_area = self._home_name.strip() + case 'room': + self._suggested_area = self._room_name.strip() + case _: + self._suggested_area = None + + self._device_state_sub_list = {} + self._entity_list = {} + self._prop_list = {} + self._event_list = {} + self._action_list = {} + + # Sub devices name + sub_devices: dict[str, dict] = device_info.get('sub_devices', None) + if isinstance(sub_devices, dict) and sub_devices: + for service in spec_instance.services: + sub_info = sub_devices.get(f's{service.iid}', None) + if sub_info is None: + continue + _LOGGER.debug( + 'miot device, update service sub info, %s, %s', + self.did, sub_info) + service.description_trans = sub_info.get( + 'name', service.description_trans) + + # Sub device state + self.miot_client.sub_device_state( + self._did, self.__on_device_state_changed) + + _LOGGER.debug('miot device init %s', device_info) + + @property + def online(self) -> bool: + return self._online + + @property + def entity_list(self) -> dict[str, list[MIoTEntityData]]: + return self._entity_list + + @property + def prop_list(self) -> dict[str, list[MIoTSpecProperty]]: + return self._prop_list + + @property + def event_list(self) -> dict[str, list[MIoTSpecEvent]]: + return self._event_list + + @property + def action_list(self) -> dict[str, list[MIoTSpecAction]]: + return self._action_list + + async def action_async(self, siid: int, aiid: int, in_list: list) -> list: + return await self.miot_client.action_async( + did=self._did, siid=siid, aiid=aiid, in_list=in_list) + + def sub_device_state( + self, key: str, handler: Callable[[str, MIoTDeviceState], None] + ) -> bool: + self._device_state_sub_list[key] = handler + return True + + def unsub_device_state(self, key: str) -> bool: + self._device_state_sub_list.pop(key, None) + return True + + def sub_property( + self, handler: Callable[[dict, any], None], siid: int = None, + piid: int = None, handler_ctx: any = None + ) -> bool: + return self.miot_client.sub_prop( + did=self._did, handler=handler, siid=siid, piid=piid, + handler_ctx=handler_ctx) + + def unsub_property(self, siid: int = None, piid: int = None) -> bool: + return self.miot_client.unsub_prop(did=self._did, siid=siid, piid=piid) + + def sub_event( + self, handler: Callable[[dict, any], None], siid: int = None, + eiid: int = None, handler_ctx: any = None + ) -> bool: + return self.miot_client.sub_event( + did=self._did, handler=handler, siid=siid, eiid=eiid, + handler_ctx=handler_ctx) + + def unsub_event(self, siid: int = None, eiid: int = None) -> bool: + return self.miot_client.unsub_event( + did=self._did, siid=siid, eiid=eiid) + + @property + def device_info(self) -> DeviceInfo: + """information about this entity/device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.did_tag)}, + name=self._name, + sw_version=self._fw_version, + model=self._model, + manufacturer=self._manufacturer, + suggested_area=self._suggested_area, + configuration_url=( + f'https://home.mi.com/webapp/content/baike/product/index.html?' + f'model={self._model}') + ) + + @property + def did(self) -> str: + """Device Id.""" + return self._did + + @property + def did_tag(self) -> str: + return slugify(f'{self.miot_client.cloud_server}_{self._did}') + + @staticmethod + def gen_did_tag(cloud_server: str, did: str) -> str: + return slugify(f'{cloud_server}_{did}') + + def gen_device_entity_id(self, ha_domain: str) -> str: + return ( + f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' + f'{self._model_strs[-1][:20]}') + + def gen_service_entity_id(self, ha_domain: str, siid: int) -> str: + return ( + f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' + f'{self._model_strs[-1][:20]}_s_{siid}') + + def gen_prop_entity_id( + self, ha_domain: str, spec_name: str, siid: int, piid: int + ) -> str: + return ( + f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' + f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_p_{siid}_{piid}') + + def gen_event_entity_id( + self, ha_domain: str, spec_name: str, siid: int, eiid: int + ) -> str: + return ( + f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' + f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_e_{siid}_{eiid}') + + def gen_action_entity_id( + self, ha_domain: str, spec_name: str, siid: int, aiid: int + ) -> str: + return ( + f'{ha_domain}.{self._model_strs[0][:9]}_{self.did_tag}_' + f'{self._model_strs[-1][:20]}_{slugify(spec_name)}_a_{siid}_{aiid}') + + @property + def name(self) -> str: + return self._name + + @property + def model(self) -> str: + return self._model + + @property + def icon(self) -> str: + return self._icon + + def append_entity(self, entity_data: MIoTEntityData) -> None: + self._entity_list.setdefault(entity_data.platform, []) + self._entity_list[entity_data.platform].append(entity_data) + + def append_prop(self, prop: MIoTSpecProperty) -> None: + self._prop_list.setdefault(prop.platform, []) + self._prop_list[prop.platform].append(prop) + + def append_event(self, event: MIoTSpecEvent) -> None: + self._event_list.setdefault(event.platform, []) + self._event_list[event.platform].append(event) + + def append_action(self, action: MIoTSpecAction) -> None: + self._action_list.setdefault(action.platform, []) + self._action_list[action.platform].append(action) + + def parse_miot_device_entity( + self, spec_instance: MIoTSpecInstance + ) -> Optional[MIoTEntityData]: + if spec_instance.name not in SPEC_DEVICE_TRANS_MAP: + return None + spec_name: str = spec_instance.name + if isinstance(SPEC_DEVICE_TRANS_MAP[spec_name], str): + spec_name = SPEC_DEVICE_TRANS_MAP[spec_name] + # 1. The device shall have all required services. + required_services = SPEC_DEVICE_TRANS_MAP[spec_name]['required'].keys() + if not { + service.name for service in spec_instance.services + }.issuperset(required_services): + return None + optional_services = SPEC_DEVICE_TRANS_MAP[spec_name]['optional'].keys() + + platform = SPEC_DEVICE_TRANS_MAP[spec_name]['entity'] + entity_data = MIoTEntityData(platform=platform, spec=spec_instance) + for service in spec_instance.services: + if service.platform: + continue + # 2. The service shall have all required properties, actions. + if service.name in required_services: + required_properties: dict = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'required'].get( + service.name, {} + ).get('required', {}).get('properties', {}) + optional_properties = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'required'].get( + service.name, {} + ).get('optional', {}).get('properties', set({})) + required_actions = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'required'].get( + service.name, {} + ).get('required', {}).get('actions', set({})) + optional_actions = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'required'].get( + service.name, {} + ).get('optional', {}).get('actions', set({})) + elif service.name in optional_services: + required_properties: dict = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'optional'].get( + service.name, {} + ).get('required', {}).get('properties', {}) + optional_properties = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'optional'].get( + service.name, {} + ).get('optional', {}).get('properties', set({})) + required_actions = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'optional'].get( + service.name, {} + ).get('required', {}).get('actions', set({})) + optional_actions = SPEC_DEVICE_TRANS_MAP[spec_name][ + 'optional'].get( + service.name, {} + ).get('optional', {}).get('actions', set({})) + else: + continue + if not { + prop.name for prop in service.properties if prop.access + }.issuperset(set(required_properties.keys())): + return None + if not { + action.name for action in service.actions + }.issuperset(required_actions): + return None + # 3. The required property shall have all required access mode. + for prop in service.properties: + if prop.name in required_properties: + if not set(prop.access).issuperset( + required_properties[prop.name]): + return None + # property + for prop in service.properties: + if prop.name in set.union( + set(required_properties.keys()), optional_properties): + if prop.unit: + prop.external_unit = self.unit_convert(prop.unit) + prop.icon = self.icon_convert(prop.unit) + prop.platform = platform + entity_data.props.add(prop) + # action + for action in service.actions: + if action.name in set.union( + required_actions, optional_actions): + action.platform = platform + entity_data.actions.add(action) + # event + # No events is in SPEC_DEVICE_TRANS_MAP now. + service.platform = platform + return entity_data + + def parse_miot_service_entity( + self, service_instance: MIoTSpecService + ) -> Optional[MIoTEntityData]: + service = service_instance + if service.platform or (service.name not in SPEC_SERVICE_TRANS_MAP): + return None + + service_name = service.name + if isinstance(SPEC_SERVICE_TRANS_MAP[service_name], str): + service_name = SPEC_SERVICE_TRANS_MAP[service_name] + # 1. The service shall have all required properties. + required_properties: dict = SPEC_SERVICE_TRANS_MAP[service_name][ + 'required'].get('properties', {}) + if not { + prop.name for prop in service.properties if prop.access + }.issuperset(set(required_properties.keys())): + return None + # 2. The required property shall have all required access mode. + for prop in service.properties: + if prop.name in required_properties: + if not set(prop.access).issuperset( + required_properties[prop.name]): + return None + platform = SPEC_SERVICE_TRANS_MAP[service_name]['entity'] + entity_data = MIoTEntityData(platform=platform, spec=service_instance) + optional_properties = SPEC_SERVICE_TRANS_MAP[service_name][ + 'optional'].get('properties', set({})) + for prop in service.properties: + if prop.name in set.union( + set(required_properties.keys()), optional_properties): + if prop.unit: + prop.external_unit = self.unit_convert(prop.unit) + prop.icon = self.icon_convert(prop.unit) + prop.platform = platform + entity_data.props.add(prop) + # action + # event + # No actions or events is in SPEC_SERVICE_TRANS_MAP now. + service.platform = platform + return entity_data + + def parse_miot_property_entity( + self, property_instance: MIoTSpecProperty + ) -> Optional[dict[str, str]]: + prop = property_instance + if ( + prop.platform + or (prop.name not in SPEC_PROP_TRANS_MAP['properties']) + ): + return None + + prop_name = prop.name + if isinstance(SPEC_PROP_TRANS_MAP['properties'][prop_name], str): + prop_name = SPEC_PROP_TRANS_MAP['properties'][prop_name] + platform = SPEC_PROP_TRANS_MAP['properties'][prop_name]['entity'] + prop_access: set = set({}) + if prop.readable: + prop_access.add('read') + if prop.writable: + prop_access.add('write') + if prop_access != (SPEC_PROP_TRANS_MAP['entities'][platform]['access']): + return None + if prop.format_ not in SPEC_PROP_TRANS_MAP[ + 'entities'][platform]['format']: + return None + if prop.unit: + prop.external_unit = self.unit_convert(prop.unit) + prop.icon = self.icon_convert(prop.unit) + device_class = SPEC_PROP_TRANS_MAP['properties'][prop_name][ + 'device_class'] + prop.platform = device_class + + return {'platform': platform, 'device_class': device_class} + + def spec_transform(self) -> None: + """Parse service, property, event, action from device spec.""" + # STEP 1: device conversion + device_entity = self.parse_miot_device_entity( + spec_instance=self.spec_instance) + if device_entity: + self.append_entity(entity_data=device_entity) + # STEP 2: service conversion + for service in self.spec_instance.services: + service_entity = self.parse_miot_service_entity( + service_instance=service) + if service_entity: + self.append_entity(entity_data=service_entity) + # STEP 3.1: property conversion + for prop in service.properties: + if prop.platform or not prop.access: + continue + if prop.unit: + prop.external_unit = self.unit_convert(prop.unit) + prop.icon = self.icon_convert(prop.unit) + prop_entity = self.parse_miot_property_entity( + property_instance=prop) + if prop_entity: + prop.platform = prop_entity['platform'] + prop.device_class = prop_entity['device_class'] + # general conversion + if not prop.platform: + if prop.writable: + if prop.format_ == 'str': + prop.platform = 'text' + elif prop.format_ == 'bool': + prop.platform = 'switch' + prop.device_class = SwitchDeviceClass.SWITCH + elif prop.value_list: + prop.platform = 'select' + elif prop.value_range: + prop.platform = 'number' + else: + # Irregular property will not be transformed. + pass + elif prop.readable or prop.notifiable: + prop.platform = 'sensor' + if prop.platform: + self.append_prop(prop=prop) + # STEP 3.2: event conversion + for event in service.events: + if event.platform: + continue + event.platform = 'event' + if event.name in SPEC_EVENT_TRANS_MAP: + event.device_class = SPEC_EVENT_TRANS_MAP[event.name] + self.append_event(event=event) + # STEP 3.3: action conversion + for action in service.actions: + if action.platform: + continue + if action.name in SPEC_ACTION_TRANS_MAP: + continue + if action.in_: + action.platform = 'notify' + else: + action.platform = 'button' + self.append_action(action=action) + + def unit_convert(self, spec_unit: str) -> Optional[str]: + return { + 'percentage': PERCENTAGE, + 'weeks': UnitOfTime.WEEKS, + 'days': UnitOfTime.DAYS, + 'hours': UnitOfTime.HOURS, + 'minutes': UnitOfTime.MINUTES, + 'seconds': UnitOfTime.SECONDS, + 'ms': UnitOfTime.MILLISECONDS, + 'μs': UnitOfTime.MICROSECONDS, + 'celsius': UnitOfTemperature.CELSIUS, + 'fahrenheit': UnitOfTemperature.FAHRENHEIT, + 'kelvin': UnitOfTemperature.KELVIN, + 'μg/m3': CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + 'mg/m3': CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + 'ppm': CONCENTRATION_PARTS_PER_MILLION, + 'ppb': CONCENTRATION_PARTS_PER_BILLION, + 'lux': LIGHT_LUX, + 'pascal': UnitOfPressure.PA, + 'bar': UnitOfPressure.BAR, + 'watt': UnitOfPower.WATT, + 'L': UnitOfVolume.LITERS, + 'mL': UnitOfVolume.MILLILITERS, + 'km/h': UnitOfSpeed.KILOMETERS_PER_HOUR, + 'm/s': UnitOfSpeed.METERS_PER_SECOND, + 'kWh': UnitOfEnergy.KILO_WATT_HOUR, + 'A': UnitOfElectricCurrent.AMPERE, + 'mA': UnitOfElectricCurrent.MILLIAMPERE, + 'V': UnitOfElectricPotential.VOLT, + 'mV': UnitOfElectricPotential.MILLIVOLT, + 'm': UnitOfLength.METERS, + 'km': UnitOfLength.KILOMETERS, + 'm3/h': UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + 'μS/cm': UnitOfConductivity.MICROSIEMENS_PER_CM, + 'gram': UnitOfMass.GRAMS, + 'dB': SIGNAL_STRENGTH_DECIBELS, + 'kB': UnitOfInformation.KILOBYTES, + }.get(spec_unit, None) + + def icon_convert(self, spec_unit: str) -> Optional[str]: + if spec_unit in ['percentage']: + return 'mdi:percent' + if spec_unit in [ + 'weeks', 'days', 'hours', 'minutes', 'seconds', 'ms', 'μs']: + return 'mdi:clock' + if spec_unit in ['celsius']: + return 'mdi:temperature-celsius' + if spec_unit in ['fahrenheit']: + return 'mdi:temperature-fahrenheit' + if spec_unit in ['kelvin']: + return 'mdi:temperature-kelvin' + if spec_unit in ['μg/m3', 'mg/m3', 'ppm', 'ppb']: + return 'mdi:blur' + if spec_unit in ['lux']: + return 'mdi:brightness-6' + if spec_unit in ['pascal', 'megapascal', 'bar']: + return 'mdi:gauge' + if spec_unit in ['watt']: + return 'mdi:flash-triangle' + if spec_unit in ['L', 'mL']: + return 'mdi:gas-cylinder' + if spec_unit in ['km/h', 'm/s']: + return 'mdi:speedometer' + if spec_unit in ['kWh']: + return 'mdi:transmission-tower' + if spec_unit in ['A', 'mA']: + return 'mdi:current-ac' + if spec_unit in ['V', 'mV']: + return 'mdi:current-dc' + if spec_unit in ['m', 'km']: + return 'mdi:ruler' + if spec_unit in ['rgb']: + return 'mdi:palette' + if spec_unit in ['m3/h', 'L/s']: + return 'mdi:pipe-leak' + if spec_unit in ['μS/cm']: + return 'mdi:resistor-nodes' + if spec_unit in ['gram']: + return 'mdi:weight' + if spec_unit in ['dB']: + return 'mdi:signal-distance-variant' + if spec_unit in ['times']: + return 'mdi:counter' + if spec_unit in ['mmol/L']: + return 'mdi:dots-hexagon' + if spec_unit in ['arcdegress']: + return 'mdi:angle-obtuse' + if spec_unit in ['kB']: + return 'mdi:network-pos' + if spec_unit in ['calorie', 'kCal']: + return 'mdi:food' + return None + + def __on_device_state_changed( + self, did: str, state: MIoTDeviceState, ctx: any + ) -> None: + self._online = state + for key, handler in self._device_state_sub_list.items(): + self.miot_client.main_loop.call_soon_threadsafe( + handler, key, state) + + +class MIoTServiceEntity(Entity): + """MIoT Service Entity.""" + # pylint: disable=unused-argument + miot_device: MIoTDevice + entity_data: MIoTEntityData + + _main_loop: asyncio.AbstractEventLoop + _prop_value_map: dict[MIoTSpecProperty, any] + + _event_occurred_handler: Callable[[MIoTSpecEvent, dict], None] + _prop_changed_subs: dict[ + MIoTSpecProperty, Callable[[MIoTSpecProperty, any], None]] + + _pending_write_ha_state_timer: Optional[asyncio.TimerHandle] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + if ( + miot_device is None + or entity_data is None + or entity_data.spec is None + ): + raise MIoTDeviceError('init error, invalid params') + self.miot_device = miot_device + self.entity_data = entity_data + self._main_loop = miot_device.miot_client.main_loop + self._prop_value_map = {} + # Gen entity id + if isinstance(entity_data.spec, MIoTSpecInstance): + self.entity_id = miot_device.gen_device_entity_id(DOMAIN) + self._attr_name = f' {self.entity_data.spec.description_trans}' + elif isinstance(entity_data.spec, MIoTSpecService): + self.entity_id = miot_device.gen_service_entity_id( + DOMAIN, siid=entity_data.spec.iid) + self._attr_name = ( + f'{"* "if self.entity_data.spec.proprietary else " "}' + f'{self.entity_data.spec.description_trans}') + # Set entity attr + self._attr_unique_id = self.entity_id + self._attr_should_poll = False + self._attr_has_entity_name = True + self._attr_available = miot_device.online + + self._event_occurred_handler = None + self._prop_changed_subs = {} + self._pending_write_ha_state_timer = None + _LOGGER.info( + 'new miot service entity, %s, %s, %s, %s', + self.miot_device.name, self._attr_name, self.entity_data.spec.name, + self.entity_id) + + @property + def event_occurred_handler(self) -> Callable[[MIoTSpecEvent, dict], None]: + return self._event_occurred_handler + + @event_occurred_handler.setter + def event_occurred_handler(self, func) -> None: + self._event_occurred_handler = func + + def sub_prop_changed( + self, prop: MIoTSpecProperty, + handler: Callable[[MIoTSpecProperty, any], None] + ) -> None: + if not prop or not handler: + _LOGGER.error( + 'sub_prop_changed error, invalid prop/handler') + return + self._prop_changed_subs[prop] = handler + + def unsub_prop_changed(self, prop: MIoTSpecProperty) -> None: + self._prop_changed_subs.pop(prop, None) + + @property + def device_info(self) -> dict: + return self.miot_device.device_info + + async def async_added_to_hass(self) -> None: + state_id = 's.0' + if isinstance(self.entity_data.spec, MIoTSpecService): + state_id = f's.{self.entity_data.spec.iid}' + self.miot_device.sub_device_state( + key=state_id, handler=self.__on_device_state_changed) + # Sub prop + for prop in self.entity_data.props: + if not prop.notifiable and not prop.readable: + continue + self.miot_device.sub_property( + handler=self.__on_properties_changed, + siid=prop.service.iid, piid=prop.iid) + # Sub event + for event in self.entity_data.events: + self.miot_device.sub_event( + handler=self.__on_event_occurred, + siid=event.service.iid, eiid=event.iid) + + # Refresh value + if self._attr_available: + self.__refresh_props_value() + + async def async_will_remove_from_hass(self) -> None: + if self._pending_write_ha_state_timer: + self._pending_write_ha_state_timer.cancel() + self._pending_write_ha_state_timer = None + state_id = 's.0' + if isinstance(self.entity_data.spec, MIoTSpecService): + state_id = f's.{self.entity_data.spec.iid}' + self.miot_device.unsub_device_state(key=state_id) + # Unsub prop + for prop in self.entity_data.props: + if not prop.notifiable and not prop.readable: + continue + self.miot_device.unsub_property( + siid=prop.service.iid, piid=prop.iid) + # Unsub event + for event in self.entity_data.events: + self.miot_device.unsub_event( + siid=event.service.iid, eiid=event.iid) + + def get_map_description(self, map_: dict[int, any], key: int) -> any: + if map_ is None: + return None + return map_.get(key, None) + + def get_map_value( + self, map_: dict[int, any], description: any + ) -> Optional[int]: + if map_ is None: + return None + for key, value in map_.items(): + if value == description: + return key + return None + + def get_prop_value(self, prop: MIoTSpecProperty) -> any: + if not prop: + _LOGGER.error( + 'get_prop_value error, property is None, %s, %s', + self._attr_name, self.entity_id) + return None + return self._prop_value_map.get(prop, None) + + def set_prop_value(self, prop: MIoTSpecProperty, value: any) -> None: + if not prop: + _LOGGER.error( + 'set_prop_value error, property is None, %s, %s', + self._attr_name, self.entity_id) + return + self._prop_value_map[prop] = value + + async def set_property_async( + self, prop: MIoTSpecProperty, value: any, update: bool = True + ) -> bool: + value = prop.value_format(value) + if not prop: + raise RuntimeError( + f'set property failed, property is None, ' + f'{self.entity_id}, {self.name}') + if prop not in self.entity_data.props: + raise RuntimeError( + f'set property failed, unknown property, ' + f'{self.entity_id}, {self.name}, {prop.name}') + if not prop.writable: + raise RuntimeError( + f'set property failed, not writable, ' + f'{self.entity_id}, {self.name}, {prop.name}') + try: + await self.miot_device.miot_client.set_prop_async( + did=self.miot_device.did, siid=prop.service.iid, + piid=prop.iid, value=value) + except MIoTClientError as e: + raise RuntimeError( + f'{e}, {self.entity_id}, {self.name}, {prop.name}') from e + if update: + self._prop_value_map[prop] = value + self.async_write_ha_state() + return True + + async def get_property_async(self, prop: MIoTSpecProperty) -> any: + if not prop: + _LOGGER.error( + 'get property failed, property is None, %s, %s', + self.entity_id, self.name) + return None + if prop not in self.entity_data.props: + _LOGGER.error( + 'get property failed, unknown property, %s, %s, %s', + self.entity_id, self.name, prop.name) + return None + if not prop.readable: + _LOGGER.error( + 'get property failed, not readable, %s, %s, %s', + self.entity_id, self.name, prop.name) + return None + result = prop.value_format( + await self.miot_device.miot_client.get_prop_async( + did=self.miot_device.did, siid=prop.service.iid, piid=prop.iid)) + if result != self._prop_value_map[prop]: + self._prop_value_map[prop] = result + self.async_write_ha_state() + return result + + async def action_async( + self, action: MIoTSpecAction, in_list: Optional[list] = None + ) -> bool: + if not action: + raise RuntimeError( + f'action failed, action is None, {self.entity_id}, {self.name}') + try: + await self.miot_device.miot_client.action_async( + did=self.miot_device.did, siid=action.service.iid, + aiid=action.iid, in_list=in_list or []) + except MIoTClientError as e: + raise RuntimeError( + f'{e}, {self.entity_id}, {self.name}, {action.name}') from e + return True + + def __on_properties_changed(self, params: dict, ctx: any) -> None: + _LOGGER.debug('properties changed, %s', params) + for prop in self.entity_data.props: + if ( + prop.iid != params['piid'] + or prop.service.iid != params['siid'] + ): + continue + value: any = prop.value_format(params['value']) + self._prop_value_map[prop] = value + if prop in self._prop_changed_subs: + self._prop_changed_subs[prop](prop, value) + break + if not self._pending_write_ha_state_timer: + self.async_write_ha_state() + + def __on_event_occurred(self, params: dict, ctx: any) -> None: + _LOGGER.debug('event occurred, %s', params) + if self._event_occurred_handler is None: + return + for event in self.entity_data.events: + if ( + event.iid != params['eiid'] + or event.service.iid != params['siid'] + ): + continue + trans_arg = {} + for item in params['arguments']: + for prop in event.argument: + if prop.iid == item['piid']: + trans_arg[prop.description_trans] = item['value'] + break + self._event_occurred_handler(event, trans_arg) + break + + def __on_device_state_changed( + self, key: str, state: MIoTDeviceState + ) -> None: + state_new = state == MIoTDeviceState.ONLINE + if state_new == self._attr_available: + return + self._attr_available = state_new + if not self._attr_available: + self.async_write_ha_state() + return + self.__refresh_props_value() + + def __refresh_props_value(self) -> None: + for prop in self.entity_data.props: + if not prop.readable: + continue + self.miot_device.miot_client.request_refresh_prop( + did=self.miot_device.did, siid=prop.service.iid, piid=prop.iid) + if self._pending_write_ha_state_timer: + self._pending_write_ha_state_timer.cancel() + self._pending_write_ha_state_timer = self._main_loop.call_later( + 1, self.__write_ha_state_handler) + + def __write_ha_state_handler(self) -> None: + self._pending_write_ha_state_timer = None + self.async_write_ha_state() + + +class MIoTPropertyEntity(Entity): + """MIoT Property Entity.""" + # pylint: disable=unused-argument + miot_device: MIoTDevice + spec: MIoTSpecProperty + service: MIoTSpecService + + _main_loop: asyncio.AbstractEventLoop + # {'min':int, 'max':int, 'step': int} + _value_range: dict[str, int] + # {any: any} + _value_list: dict[any, any] + _value: any + + _pending_write_ha_state_timer: Optional[asyncio.TimerHandle] + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + if miot_device is None or spec is None or spec.service is None: + raise MIoTDeviceError('init error, invalid params') + self.miot_device = miot_device + self.spec = spec + self.service = spec.service + self._main_loop = miot_device.miot_client.main_loop + self._value_range = spec.value_range + if spec.value_list: + self._value_list = { + item['value']: item['description'] for item in spec.value_list} + else: + self._value_list = None + self._value = None + self._pending_write_ha_state_timer = None + # Gen entity_id + self.entity_id = self.miot_device.gen_prop_entity_id( + ha_domain=DOMAIN, spec_name=spec.name, + siid=spec.service.iid, piid=spec.iid) + # Set entity attr + self._attr_unique_id = self.entity_id + self._attr_should_poll = False + self._attr_has_entity_name = True + self._attr_name = ( + f'{"* "if self.spec.proprietary else " "}' + f'{self.service.description_trans} {spec.description_trans}') + self._attr_available = miot_device.online + + _LOGGER.info( + 'new miot property entity, %s, %s, %s, %s, %s, %s, %s', + self.miot_device.name, self._attr_name, spec.platform, + spec.device_class, self.entity_id, self._value_range, + self._value_list) + + @property + def device_info(self) -> dict: + return self.miot_device.device_info + + async def async_added_to_hass(self) -> None: + # Sub device state changed + self.miot_device.sub_device_state( + key=f'{ self.service.iid}.{self.spec.iid}', + handler=self.__on_device_state_changed) + # Sub value changed + self.miot_device.sub_property( + handler=self.__on_value_changed, + siid=self.service.iid, piid=self.spec.iid) + # Refresh value + if self._attr_available: + self.__request_refresh_prop() + + async def async_will_remove_from_hass(self) -> None: + if self._pending_write_ha_state_timer: + self._pending_write_ha_state_timer.cancel() + self._pending_write_ha_state_timer = None + self.miot_device.unsub_device_state( + key=f'{ self.service.iid}.{self.spec.iid}') + self.miot_device.unsub_property( + siid=self.service.iid, piid=self.spec.iid) + + def get_vlist_description(self, value: any) -> str: + if not self._value_list: + return None + return self._value_list.get(value, None) + + def get_vlist_value(self, description: str) -> any: + if not self._value_list: + return None + for key, value in self._value_list.items(): + if value == description: + return key + return None + + async def set_property_async(self, value: any) -> bool: + if not self.spec.writable: + raise RuntimeError( + f'set property failed, not writable, ' + f'{self.entity_id}, {self.name}') + value = self.spec.value_format(value) + try: + await self.miot_device.miot_client.set_prop_async( + did=self.miot_device.did, siid=self.spec.service.iid, + piid=self.spec.iid, value=value) + except MIoTClientError as e: + raise RuntimeError( + f'{e}, {self.entity_id}, {self.name}') from e + self._value = value + self.async_write_ha_state() + return True + + async def get_property_async(self) -> any: + if not self.spec.readable: + _LOGGER.error( + 'get property failed, not readable, %s, %s', + self.entity_id, self.name) + return None + return self.spec.value_format( + await self.miot_device.miot_client.get_prop_async( + did=self.miot_device.did, siid=self.spec.service.iid, + piid=self.spec.iid)) + + def __on_value_changed(self, params: dict, ctx: any) -> None: + _LOGGER.debug('property changed, %s', params) + self._value = self.spec.value_format(params['value']) + if not self._pending_write_ha_state_timer: + self.async_write_ha_state() + + def __on_device_state_changed( + self, key: str, state: MIoTDeviceState + ) -> None: + self._attr_available = state == MIoTDeviceState.ONLINE + if not self._attr_available: + self.async_write_ha_state() + return + # Refresh value + self.__request_refresh_prop() + + def __request_refresh_prop(self) -> None: + if self.spec.readable: + self.miot_device.miot_client.request_refresh_prop( + did=self.miot_device.did, siid=self.service.iid, + piid=self.spec.iid) + if self._pending_write_ha_state_timer: + self._pending_write_ha_state_timer.cancel() + self._pending_write_ha_state_timer = self._main_loop.call_later( + 1, self.__write_ha_state_handler) + + def __write_ha_state_handler(self) -> None: + self._pending_write_ha_state_timer = None + self.async_write_ha_state() + + +class MIoTEventEntity(Entity): + """MIoT Event Entity.""" + # pylint: disable=unused-argument + miot_device: MIoTDevice + spec: MIoTSpecEvent + service: MIoTSpecService + + _main_loop: asyncio.AbstractEventLoop + _value: any + _attr_event_types: list[str] + _arguments_map: dict[int, str] + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecEvent) -> None: + if miot_device is None or spec is None or spec.service is None: + raise MIoTDeviceError('init error, invalid params') + self.miot_device = miot_device + self.spec = spec + self.service = spec.service + self._main_loop = miot_device.miot_client.main_loop + self._value = None + # Gen entity_id + self.entity_id = self.miot_device.gen_event_entity_id( + ha_domain=DOMAIN, spec_name=spec.name, + siid=spec.service.iid, eiid=spec.iid) + # Set entity attr + self._attr_unique_id = self.entity_id + self._attr_should_poll = False + self._attr_has_entity_name = True + self._attr_name = ( + f'{"* "if self.spec.proprietary else " "}' + f'{self.service.description_trans} {spec.description_trans}') + self._attr_available = miot_device.online + self._attr_event_types = [spec.description_trans] + + self._arguments_map = {} + for prop in spec.argument: + self._arguments_map[prop.iid] = prop.description_trans + + _LOGGER.info( + 'new miot event entity, %s, %s, %s, %s, %s', + self.miot_device.name, self._attr_name, spec.platform, + spec.device_class, self.entity_id) + + @property + def device_info(self) -> dict: + return self.miot_device.device_info + + async def async_added_to_hass(self) -> None: + # Sub device state changed + self.miot_device.sub_device_state( + key=f'event.{ self.service.iid}.{self.spec.iid}', + handler=self.__on_device_state_changed) + # Sub value changed + self.miot_device.sub_event( + handler=self.__on_event_occurred, siid=self.service.iid, + eiid=self.spec.iid) + + async def async_will_remove_from_hass(self) -> None: + self.miot_device.unsub_device_state( + key=f'event.{ self.service.iid}.{self.spec.iid}') + self.miot_device.unsub_event( + siid=self.service.iid, eiid=self.spec.iid) + + @abstractmethod + def on_event_occurred( + self, name: str, arguments: list[dict[int, any]] + ): ... + + def __on_event_occurred(self, params: dict, ctx: any) -> None: + _LOGGER.debug('event occurred, %s', params) + trans_arg = {} + try: + for item in params['arguments']: + if 'value' not in item: + continue + if 'piid' in item: + trans_arg[self._arguments_map[item['piid']]] = item[ + 'value'] + elif ( + isinstance(item['value'], list) + and len(item['value']) == len(self.spec.argument) + ): + # Dirty fix for cloud multi-arguments + trans_arg = { + prop.description_trans: item['value'][index] + for index, prop in enumerate(self.spec.argument) + } + break + except KeyError as error: + _LOGGER.error( + 'on event msg, invalid args, %s, %s, %s', + self.entity_id, params, error) + self.on_event_occurred( + name=self.spec.description_trans, arguments=trans_arg) + self.async_write_ha_state() + + def __on_device_state_changed( + self, key: str, state: MIoTDeviceState + ) -> None: + state_new = state == MIoTDeviceState.ONLINE + if state_new == self._attr_available: + return + self._attr_available = state_new + self.async_write_ha_state() + + +class MIoTActionEntity(Entity): + """MIoT Action Entity.""" + # pylint: disable=unused-argument + miot_device: MIoTDevice + spec: MIoTSpecAction + service: MIoTSpecService + action_platform: str + + _main_loop: asyncio.AbstractEventLoop + _in_map: dict[int, MIoTSpecProperty] + _out_map: dict[int, MIoTSpecProperty] + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecAction) -> None: + if miot_device is None or spec is None or spec.service is None: + raise MIoTDeviceError('init error, invalid params') + self.miot_device = miot_device + self.spec = spec + self.service = spec.service + self.action_platform = 'action' + self._main_loop = miot_device.miot_client.main_loop + # Gen entity_id + self.entity_id = self.miot_device.gen_action_entity_id( + ha_domain=DOMAIN, spec_name=spec.name, + siid=spec.service.iid, aiid=spec.iid) + # Set entity attr + self._attr_unique_id = self.entity_id + self._attr_should_poll = False + self._attr_has_entity_name = True + self._attr_name = ( + f'{"* "if self.spec.proprietary else " "}' + f'{self.service.description_trans} {spec.description_trans}') + self._attr_available = miot_device.online + + _LOGGER.debug( + 'new miot action entity, %s, %s, %s, %s, %s', + self.miot_device.name, self._attr_name, spec.platform, + spec.device_class, self.entity_id) + + @property + def device_info(self) -> dict: + return self.miot_device.device_info + + async def async_added_to_hass(self) -> None: + self.miot_device.sub_device_state( + key=f'{self.action_platform}.{ self.service.iid}.{self.spec.iid}', + handler=self.__on_device_state_changed) + + async def async_will_remove_from_hass(self) -> None: + self.miot_device.unsub_device_state( + key=f'{self.action_platform}.{ self.service.iid}.{self.spec.iid}') + + async def action_async(self, in_list: list = None) -> Optional[list]: + try: + return await self.miot_device.miot_client.action_async( + did=self.miot_device.did, + siid=self.service.iid, + aiid=self.spec.iid, + in_list=in_list or []) + except MIoTClientError as e: + raise RuntimeError(f'{e}, {self.entity_id}, {self.name}') from e + + def __on_device_state_changed( + self, key: str, state: MIoTDeviceState + ) -> None: + state_new = state == MIoTDeviceState.ONLINE + if state_new == self._attr_available: + return + self._attr_available = state_new + self.async_write_ha_state() diff --git a/custom_components/xiaomi_home/miot/miot_error.py b/custom_components/xiaomi_home/miot/miot_error.py new file mode 100644 index 0000000..1004a15 --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_error.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT error code and exception. +""" +from enum import Enum + + +class MIoTErrorCode(Enum): + """MIoT error code.""" + # Base error code + CODE_UNKNOWN = -10000 + CODE_UNAVAILABLE = -10001 + CODE_INVALID_PARAMS = -10002 + CODE_RESOURCE_ERROR = -10003 + CODE_INTERNAL_ERROR = -10004 + CODE_UNAUTHORIZED_ACCESS = -10005 + CODE_TIMEOUT = -10006 + # OAuth error code + CODE_OAUTH_UNAUTHORIZED = -10020 + # Http error code + CODE_HTTP_INVALID_ACCESS_TOKEN = -10030 + # MIoT mips error code + CODE_MIPS_INVALID_RESULT = -10040 + # MIoT cert error code + CODE_CERT_INVALID_CERT = -10050 + # MIoT spec error code, -10060 + # MIoT storage error code, -10070 + # MIoT ev error code, -10080 + # Mips service error code, -10090 + # Config flow error code, -10100 + # Options flow error code , -10110 + # MIoT lan error code, -10120 + + +class MIoTError(Exception): + """MIoT error.""" + code: MIoTErrorCode + message: any + + def __init__( + self, message: any, code: MIoTErrorCode = MIoTErrorCode.CODE_UNKNOWN + ) -> None: + self.message = message + self.code = code + super().__init__(self.message) + + def to_str(self) -> str: + return f'{{"code":{self.code.value},"message":"{self.message}"}}' + + def to_dict(self) -> dict: + return {"code": self.code.value, "message": self.message} + + +class MIoTOauthError(MIoTError): + ... + + +class MIoTHttpError(MIoTError): + ... + + +class MIoTMipsError(MIoTError): + ... + + +class MIoTDeviceError(MIoTError): + ... + + +class MIoTSpecError(MIoTError): + ... + + +class MIoTStorageError(MIoTError): + ... + + +class MIoTCertError(MIoTError): + ... + + +class MIoTClientError(MIoTError): + ... + + +class MIoTEvError(MIoTError): + ... + + +class MipsServiceError(MIoTError): + ... + + +class MIoTConfigError(MIoTError): + ... + + +class MIoTOptionsError(MIoTError): + ... diff --git a/custom_components/xiaomi_home/miot/miot_ev.py b/custom_components/xiaomi_home/miot/miot_ev.py new file mode 100644 index 0000000..424bd28 --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_ev.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT event loop. +""" +import selectors +import heapq +import time +import traceback +from typing import Callable, TypeVar +import logging +import threading + +from .miot_error import MIoTEvError + +_LOGGER = logging.getLogger(__name__) + +TimeoutHandle = TypeVar('TimeoutHandle') + + +class MIoTFdHandler: + """File descriptor handler.""" + fd: int + read_handler: Callable[[any], None] + read_handler_ctx: any + write_handler: Callable[[any], None] + write_handler_ctx: any + + def __init__( + self, fd: int, + read_handler: Callable[[any], None] = None, + read_handler_ctx: any = None, + write_handler: Callable[[any], None] = None, + write_handler_ctx: any = None + ) -> None: + self.fd = fd + self.read_handler = read_handler + self.read_handler_ctx = read_handler_ctx + self.write_handler = write_handler + self.write_handler_ctx = write_handler_ctx + + +class MIoTTimeout: + """Timeout handler.""" + key: TimeoutHandle + target: int + handler: Callable[[any], None] + handler_ctx: any + + def __init__( + self, key: str = None, target: int = None, + handler: Callable[[any], None] = None, + handler_ctx: any = None + ) -> None: + self.key = key + self.target = target + self.handler = handler + self.handler_ctx = handler_ctx + + def __lt__(self, other): + return self.target < other.target + + +class MIoTEventLoop: + """MIoT event loop.""" + _poll_fd: selectors.DefaultSelector + + _fd_handlers: dict[str, MIoTFdHandler] + + _timer_heap: list[MIoTTimeout] + _timer_handlers: dict[str, MIoTTimeout] + _timer_handle_seed: int + + # Label if the current fd handler is freed inside a read handler to + # avoid invalid reading. + _fd_handler_freed_in_read_handler: bool + + def __init__(self) -> None: + self._poll_fd = selectors.DefaultSelector() + self._timer_heap = [] + self._timer_handlers = {} + self._timer_handle_seed = 1 + self._fd_handlers = {} + self._fd_handler_freed_in_read_handler = False + + def loop_forever(self) -> None: + """Run an event loop in current thread.""" + next_timeout: int + while True: + next_timeout = 0 + # Handle timer + now_ms: int = self.__get_monotonic_ms + while len(self._timer_heap) > 0: + timer: MIoTTimeout = self._timer_heap[0] + if timer is None: + break + if timer.target <= now_ms: + heapq.heappop(self._timer_heap) + del self._timer_handlers[timer.key] + if timer.handler: + timer.handler(timer.handler_ctx) + else: + next_timeout = timer.target-now_ms + break + # Are there any files to listen to + if next_timeout == 0 and self._fd_handlers: + next_timeout = None # None == infinite + # Wait for timers & fds + if next_timeout == 0: + # Neither timer nor fds exist, exit loop + break + # Handle fd event + events = self._poll_fd.select( + timeout=next_timeout/1000.0 if next_timeout else next_timeout) + for key, mask in events: + fd_handler: MIoTFdHandler = key.data + if fd_handler is None: + continue + self._fd_handler_freed_in_read_handler = False + fd_key = str(id(fd_handler.fd)) + if fd_key not in self._fd_handlers: + continue + if ( + mask & selectors.EVENT_READ > 0 + and fd_handler.read_handler + ): + fd_handler.read_handler(fd_handler.read_handler_ctx) + if ( + mask & selectors.EVENT_WRITE > 0 + and self._fd_handler_freed_in_read_handler is False + and fd_handler.write_handler + ): + fd_handler.write_handler(fd_handler.write_handler_ctx) + + def loop_stop(self) -> None: + """Stop the event loop.""" + if self._poll_fd: + self._poll_fd.close() + self._poll_fd = None + self._fd_handlers = {} + self._timer_heap = [] + self._timer_handlers = {} + + def set_timeout( + self, timeout_ms: int, handler: Callable[[any], None], + handler_ctx: any = None + ) -> TimeoutHandle: + """Set a timer.""" + if timeout_ms is None or handler is None: + raise MIoTEvError('invalid params') + new_timeout: MIoTTimeout = MIoTTimeout() + new_timeout.key = self.__get_next_timeout_handle + new_timeout.target = self.__get_monotonic_ms + timeout_ms + new_timeout.handler = handler + new_timeout.handler_ctx = handler_ctx + heapq.heappush(self._timer_heap, new_timeout) + self._timer_handlers[new_timeout.key] = new_timeout + return new_timeout.key + + def clear_timeout(self, timer_key: TimeoutHandle) -> None: + """Stop and remove the timer.""" + if timer_key is None: + return + timer: MIoTTimeout = self._timer_handlers.pop(timer_key, None) + if timer: + self._timer_heap = list(self._timer_heap) + self._timer_heap.remove(timer) + heapq.heapify(self._timer_heap) + + def set_read_handler( + self, fd: int, handler: Callable[[any], None], handler_ctx: any = None + ) -> bool: + """Set a read handler for a file descriptor. + + Returns: + bool: True, success. False, failed. + """ + self.__set_handler( + fd, is_read=True, handler=handler, handler_ctx=handler_ctx) + + def set_write_handler( + self, fd: int, handler: Callable[[any], None], handler_ctx: any = None + ) -> bool: + """Set a write handler for a file descriptor. + + Returns: + bool: True, success. False, failed. + """ + self.__set_handler( + fd, is_read=False, handler=handler, handler_ctx=handler_ctx) + + def __set_handler( + self, fd, is_read: bool, handler: Callable[[any], None], + handler_ctx: any = None + ) -> bool: + """Set a handler.""" + if fd is None: + raise MIoTEvError('invalid params') + + fd_key: str = str(id(fd)) + fd_handler = self._fd_handlers.get(fd_key, None) + + if fd_handler is None: + fd_handler = MIoTFdHandler(fd=fd) + fd_handler.fd = fd + self._fd_handlers[fd_key] = fd_handler + + read_handler_existed = fd_handler.read_handler is not None + write_handler_existed = fd_handler.write_handler is not None + if is_read is True: + fd_handler.read_handler = handler + fd_handler.read_handler_ctx = handler_ctx + else: + fd_handler.write_handler = handler + fd_handler.write_handler_ctx = handler_ctx + + if fd_handler.read_handler is None and fd_handler.write_handler is None: + # Remove from epoll and map + try: + self._poll_fd.unregister(fd) + except (KeyError, ValueError, OSError) as e: + del e + self._fd_handlers.pop(fd_key, None) + # May be inside a read handler, if not, this has no effect + self._fd_handler_freed_in_read_handler = True + elif read_handler_existed is False and write_handler_existed is False: + # Add to epoll + events = 0x0 + if fd_handler.read_handler: + events |= selectors.EVENT_READ + if fd_handler.write_handler: + events |= selectors.EVENT_WRITE + try: + self._poll_fd.register(fd, events=events, data=fd_handler) + except (KeyError, ValueError, OSError) as e: + _LOGGER.error( + '%s, register fd, error, %s, %s, %s, %s, %s', + threading.current_thread().name, + 'read' if is_read else 'write', + fd_key, handler, e, traceback.format_exc()) + self._fd_handlers.pop(fd_key, None) + return False + elif ( + read_handler_existed != (fd_handler.read_handler is not None) + or write_handler_existed != (fd_handler.write_handler is not None) + ): + # Modify epoll + events = 0x0 + if fd_handler.read_handler: + events |= selectors.EVENT_READ + if fd_handler.write_handler: + events |= selectors.EVENT_WRITE + try: + self._poll_fd.modify(fd, events=events, data=fd_handler) + except (KeyError, ValueError, OSError) as e: + _LOGGER.error( + '%s, modify fd, error, %s, %s, %s, %s, %s', + threading.current_thread().name, + 'read' if is_read else 'write', + fd_key, handler, e, traceback.format_exc()) + self._fd_handlers.pop(fd_key, None) + return False + + return True + + @property + def __get_next_timeout_handle(self) -> str: + # Get next timeout handle, that is not larger than the maximum + # value of UINT64 type. + self._timer_handle_seed += 1 + # uint64 max + self._timer_handle_seed %= 0xFFFFFFFFFFFFFFFF + return str(self._timer_handle_seed) + + @property + def __get_monotonic_ms(self) -> int: + """Get monotonic ms timestamp.""" + return int(time.monotonic()*1000) diff --git a/custom_components/xiaomi_home/miot/miot_i18n.py b/custom_components/xiaomi_home/miot/miot_i18n.py new file mode 100644 index 0000000..856fde5 --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_i18n.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT internationalization translation. +""" +import asyncio +import logging +import os +from typing import Optional + +from .common import load_json_file + +_LOGGER = logging.getLogger(__name__) + + +class MIoTI18n: + """MIoT Internationalization Translation. + Translate by Copilot, which does not guarantee the accuracy of the + translation. If there is a problem with the translation, please submit + the ISSUE feedback. After the review, we will modify it as soon as possible. + """ + _main_loop: asyncio.AbstractEventLoop + _lang: str + _data: dict + + def __init__( + self, lang: str, loop: Optional[asyncio.AbstractEventLoop] + ) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._lang = lang + self._data = None + + async def init_async(self) -> None: + if self._data: + return + data = None + self._data = {} + try: + data = await self._main_loop.run_in_executor( + None, load_json_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + f'i18n/{self._lang}.json')) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('load file error, %s', err) + return + # Check if the file is a valid JSON file + if not isinstance(data, dict): + _LOGGER.error('valid file, %s', data) + return + self._data = data + + async def deinit_async(self) -> None: + self._data = None + + def translate( + self, key: str, replace: Optional[dict[str, str]] = None + ) -> str | dict | None: + result = self._data + for item in key.split('.'): + if item not in result: + return None + result = result[item] + if isinstance(result, str) and replace: + for k, v in replace.items(): + result = result.replace('{'+k+'}', str(v)) + return result or None diff --git a/custom_components/xiaomi_home/miot/miot_lan.py b/custom_components/xiaomi_home/miot/miot_lan.py new file mode 100644 index 0000000..c3ff7cb --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_lan.py @@ -0,0 +1,1331 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT lan device control, only support MIoT SPEC-v2 WiFi devices. +""" + + +import json +import time +import asyncio +from dataclasses import dataclass +from enum import Enum, auto +import logging +import os +import queue +import random +import secrets +import socket +import struct +import threading +from typing import Callable, Optional, final +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes + +from .miot_error import MIoTErrorCode +from .miot_ev import MIoTEventLoop, TimeoutHandle +from .miot_network import InterfaceStatus, MIoTNetwork, NetworkInfo +from .miot_mdns import MipsService, MipsServiceState +from .common import randomize_int, MIoTMatcher + + +_LOGGER = logging.getLogger(__name__) + + +class MIoTLanCmdType(Enum): + """MIoT lan command.""" + DEINIT = 0 + CALL_API = auto() + SUB_DEVICE_STATE = auto() + UNSUB_DEVICE_STATE = auto() + REG_BROADCAST = auto() + UNREG_BROADCAST = auto() + GET_DEV_LIST = auto() + DEVICE_UPDATE = auto() + DEVICE_DELETE = auto() + NET_INFO_UPDATE = auto() + NET_IFS_UPDATE = auto() + OPTIONS_UPDATE = auto() + + +@dataclass +class MIoTLanCmd: + """MIoT lan command.""" + type_: MIoTLanCmdType + data: any + + +@dataclass +class MIoTLanCmdData: + handler: Callable[[dict, any], None] + handler_ctx: any + timeout_ms: int + + +@dataclass +class MIoTLanGetDevListData(MIoTLanCmdData): + ... + + +@dataclass +class MIoTLanCallApiData(MIoTLanCmdData): + did: str + msg: dict + + +class MIoTLanSendBroadcastData(MIoTLanCallApiData): + ... + + +@dataclass +class MIoTLanUnregisterBroadcastData: + key: str + + +@dataclass +class MIoTLanRegisterBroadcastData: + key: str + handler: Callable[[dict, any], None] + handler_ctx: any + + +@dataclass +class MIoTLanUnsubDeviceState: + key: str + + +@dataclass +class MIoTLanSubDeviceState: + key: str + handler: Callable[[str, dict, any], None] + handler_ctx: any + + +@dataclass +class MIoTLanNetworkUpdateData: + status: InterfaceStatus + if_name: str + + +@dataclass +class MIoTLanRequestData: + msg_id: int + handler: Callable[[dict, any], None] + handler_ctx: any + timeout: TimeoutHandle + + +class MIoTLanDeviceState(Enum): + FRESH = 0 + PING1 = auto() + PING2 = auto() + PING3 = auto() + DEAD = auto() + + +class MIoTLanDevice: + """MIoT lan device.""" + # pylint: disable=unused-argument + OT_HEADER: int = 0x2131 + OT_HEADER_LEN: int = 32 + NETWORK_UNSTABLE_CNT_TH: int = 10 + NETWORK_UNSTABLE_TIME_TH: int = 120000 + NETWORK_UNSTABLE_RESUME_TH: int = 300 + FAST_PING_INTERVAL: int = 5000 + CONSTRUCT_STATE_PENDING: int = 15000 + KA_INTERVAL_MIN = 10000 + KA_INTERVAL_MAX = 50000 + + did: str + token: bytes + cipher: Cipher + ip: Optional[str] + + offset: int + subscribed: bool + sub_ts: int + supported_wildcard_sub: bool + + _manager: any + _if_name: Optional[str] + _sub_locked: bool + _state: MIoTLanDeviceState + _online: bool + _online_offline_history: list[dict[str, any]] + _online_offline_timer: Optional[TimeoutHandle] + + _ka_timer: TimeoutHandle + _ka_internal: int + + def __init__( + self, manager: any, did: str, token: str, ip: Optional[str] = None + ) -> None: + self._manager: MIoTLan = manager + self.did = did + self.token = bytes.fromhex(token) + aes_key: bytes = self.__md5(self.token) + aex_iv: bytes = self.__md5(aes_key + self.token) + self.cipher = Cipher( + algorithms.AES128(aes_key), modes.CBC(aex_iv), default_backend()) + self.ip = ip + self.offset = 0 + self.subscribed = False + self.sub_ts = 0 + self.supported_wildcard_sub = False + self._if_name = None + self._sub_locked = False + self._state = MIoTLanDeviceState.DEAD + self._online = False + self._online_offline_history = [] + self._online_offline_timer = None + + def ka_init_handler(ctx: any) -> None: + self._ka_internal = self.KA_INTERVAL_MIN + self.__update_keep_alive(state=MIoTLanDeviceState.DEAD) + self._ka_timer = self._manager.mev.set_timeout( + randomize_int(self.CONSTRUCT_STATE_PENDING, 0.5), + ka_init_handler, None) + _LOGGER.debug('miot lan device add, %s', self.did) + + def keep_alive(self, ip: str, if_name: str) -> None: + self.ip = ip + if self._if_name != if_name: + self._if_name = if_name + _LOGGER.info( + 'device if_name change, %s, %s', self._if_name, self.did) + self.__update_keep_alive(state=MIoTLanDeviceState.FRESH) + + @property + def online(self) -> bool: + return self._online + + @online.setter + def online(self, online: bool) -> None: + if self._online == online: + return + self._online = online + self._manager.broadcast_device_state( + did=self.did, state={ + 'online': self._online, 'push_available': self.subscribed}) + + @property + def if_name(self) -> Optional[str]: + return self._if_name + + def gen_packet( + self, out_buffer: bytearray, clear_data: dict, did: str, offset: int + ) -> int: + clear_bytes = json.dumps(clear_data).encode('utf-8') + padder = padding.PKCS7(algorithms.AES128.block_size).padder() + padded_data = padder.update(clear_bytes) + padder.finalize() + if len(padded_data) + self.OT_HEADER_LEN > len(out_buffer): + raise ValueError('rpc too long') + encryptor = self.cipher.encryptor() + encrypted_data = encryptor.update(padded_data) + encryptor.finalize() + data_len: int = len(encrypted_data)+self.OT_HEADER_LEN + out_buffer[:32] = struct.pack( + '>HHQI16s', self.OT_HEADER, data_len, int(did), offset, + self.token) + out_buffer[32:data_len] = encrypted_data + msg_md5: bytes = self.__md5(out_buffer[0:data_len]) + out_buffer[16:32] = msg_md5 + return data_len + + def decrypt_packet(self, encrypted_data: bytearray) -> dict: + data_len: int = struct.unpack('>H', encrypted_data[2:4])[0] + md5_orig: bytes = encrypted_data[16:32] + encrypted_data[16:32] = self.token + md5_calc: bytes = self.__md5(encrypted_data[0:data_len]) + if md5_orig != md5_calc: + raise ValueError(f'invalid md5, {md5_orig}, {md5_calc}') + decryptor = self.cipher.decryptor() + decrypted_padded_data = decryptor.update( + encrypted_data[32:data_len]) + decryptor.finalize() + unpadder = padding.PKCS7(algorithms.AES128.block_size).unpadder() + decrypted_data = unpadder.update( + decrypted_padded_data) + unpadder.finalize() + # Some device will add a redundant \0 at the end of JSON string + decrypted_data = decrypted_data.rstrip(b'\x00') + return json.loads(decrypted_data) + + def subscribe(self) -> None: + if self._sub_locked: + return + self._sub_locked = True + try: + sub_ts: int = int(time.time()) + self._manager.send2device( + did=self.did, + msg={ + 'method': 'miIO.sub', + 'params': { + 'version': '2.0', + 'did': self._manager.virtual_did, + 'update_ts': sub_ts, + 'sub_method': '.' + } + }, + handler=self.__subscribe_handler, + handler_ctx=sub_ts, + timeout_ms=5000) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('subscribe device error, %s', err) + + self._sub_locked = False + + def unsubscribe(self) -> None: + if not self.subscribed: + return + self._manager.send2device( + did=self.did, + msg={ + 'method': 'miIO.unsub', + 'params': { + 'version': '2.0', + 'did': self._manager.virtual_did, + 'update_ts': self.sub_ts or 0, + 'sub_method': '.' + } + }, + handler=self.__unsubscribe_handler, + timeout_ms=5000) + self.subscribed = False + self._manager.broadcast_device_state( + did=self.did, state={ + 'online': self._online, 'push_available': self.subscribed}) + + def on_delete(self) -> None: + if self._ka_timer: + self._manager.mev.clear_timeout(self._ka_timer) + if self._online_offline_timer: + self._manager.mev.clear_timeout(self._online_offline_timer) + self._manager = None + self.cipher = None + _LOGGER.debug('miot lan device delete, %s', self.did) + + def update_info(self, info: dict) -> None: + if ( + 'token' in info + and len(info['token']) == 32 + and info['token'].upper() != self.token.hex().upper() + ): + # Update token + self.token = bytes.fromhex(info['token']) + aes_key: bytes = self.__md5(self.token) + aex_iv: bytes = self.__md5(aes_key + self.token) + self.cipher = Cipher( + algorithms.AES128(aes_key), + modes.CBC(aex_iv), default_backend()) + _LOGGER.debug('update token, %s', self.did) + + def __subscribe_handler(self, msg: dict, sub_ts: int) -> None: + if ( + 'result' not in msg + or 'code' not in msg['result'] + or msg['result']['code'] != 0 + ): + _LOGGER.error('subscribe device error, %s, %s', self.did, msg) + return + self.subscribed = True + self.sub_ts = sub_ts + self._manager.broadcast_device_state( + did=self.did, state={ + 'online': self._online, 'push_available': self.subscribed}) + _LOGGER.info('subscribe success, %s, %s', self._if_name, self.did) + + def __unsubscribe_handler(self, msg: dict, ctx: any) -> None: + if ( + 'result' not in msg + or 'code' not in msg['result'] + or msg['result']['code'] != 0 + ): + _LOGGER.error('unsubscribe device error, %s, %s', self.did, msg) + return + _LOGGER.info('unsubscribe success, %s, %s', self._if_name, self.did) + + def __update_keep_alive(self, state: MIoTLanDeviceState) -> None: + last_state: MIoTLanDeviceState = self._state + self._state = state + if self._state != MIoTLanDeviceState.FRESH: + _LOGGER.debug('device status, %s, %s', self.did, self._state) + if self._ka_timer: + self._manager.mev.clear_timeout(self._ka_timer) + self._ka_timer = None + match state: + case MIoTLanDeviceState.FRESH: + if last_state == MIoTLanDeviceState.DEAD: + self._ka_internal = self.KA_INTERVAL_MIN + self.__change_online(True) + self._ka_timer = self._manager.mev.set_timeout( + self.__get_next_ka_timeout(), self.__update_keep_alive, + MIoTLanDeviceState.PING1) + case ( + MIoTLanDeviceState.PING1 + | MIoTLanDeviceState.PING2 + | MIoTLanDeviceState.PING3 + ): + self._manager.ping(if_name=self._if_name, target_ip=self.ip) + # Fast ping + self._ka_timer = self._manager.mev.set_timeout( + self.FAST_PING_INTERVAL, self.__update_keep_alive, + MIoTLanDeviceState(state.value+1)) + case MIoTLanDeviceState.DEAD: + if last_state == MIoTLanDeviceState.PING3: + self._ka_internal = self.KA_INTERVAL_MIN + self.__change_online(False) + case _: + _LOGGER.error('invalid state, %s', state) + + def __get_next_ka_timeout(self) -> int: + self._ka_internal = min(self._ka_internal*2, self.KA_INTERVAL_MAX) + return randomize_int(self._ka_internal, 0.1) + + def __change_online(self, online: bool) -> None: + _LOGGER.info('change online, %s, %s', self.did, online) + ts_now: int = int(time.time()) + self._online_offline_history.append({'ts': ts_now, 'online': online}) + if len(self._online_offline_history) > self.NETWORK_UNSTABLE_CNT_TH: + self._online_offline_history.pop(0) + if self._online_offline_timer: + self._manager.mev.clear_timeout(self._online_offline_timer) + if not online: + self.online = False + else: + if ( + len(self._online_offline_history) < self.NETWORK_UNSTABLE_CNT_TH + or ( + ts_now - self._online_offline_history[0]['ts'] > + self.NETWORK_UNSTABLE_TIME_TH) + ): + self.online = True + else: + _LOGGER.info('unstable device detected, %s', self.did) + self._online_offline_timer = self._manager.mev.set_timeout( + self.NETWORK_UNSTABLE_RESUME_TH, + self.__online_resume_handler, None) + + def __online_resume_handler(self, ctx: any) -> None: + _LOGGER.info('unstable resume threshold past, %s', self.did) + self.online = True + + def __md5(self, data: bytes) -> bytes: + hasher = hashes.Hash(hashes.MD5(), default_backend()) + hasher.update(data) + return hasher.finalize() + + +class MIoTLan: + """MIoT lan device control.""" + # pylint: disable=unused-argument + OT_HEADER: bytes = b'\x21\x31' + OT_PORT: int = 54321 + OT_PROBE_LEN: int = 32 + OT_MSG_LEN: int = 1400 + OT_SUPPORT_WILDCARD_SUB: int = 0xFE + + OT_PROBE_INTERVAL_MIN: int = 5000 + OT_PROBE_INTERVAL_MAX: int = 45000 + + _main_loop: asyncio.AbstractEventLoop + _net_ifs: set[str] + _network: MIoTNetwork + _mips_service: MipsService + _enable_subscribe: bool + _lan_devices: dict[str, MIoTLanDevice] + _virtual_did: str + _probe_msg: bytes + _write_buffer: bytearray + _read_buffer: bytearray + + _mev: MIoTEventLoop + _thread: threading.Thread + _queue: queue.Queue + _cmd_event_fd: os.eventfd + + _available_net_ifs: set[str] + _broadcast_socks: dict[str, socket.socket] + _local_port: Optional[int] + _scan_timer: TimeoutHandle + _last_scan_interval: Optional[int] + _msg_id_counter: int + _pending_requests: dict[int, MIoTLanRequestData] + _device_msg_matcher: MIoTMatcher + _device_state_sub_map: dict[str, MIoTLanSubDeviceState] + _reply_msg_buffer: dict[str, TimeoutHandle] + + _lan_state_sub_map: dict[str, Callable[[bool], asyncio.Future]] + _lan_ctrl_vote_map: dict[str, bool] + + _init_done: bool + + def __init__( + self, + net_ifs: list[str], + network: MIoTNetwork, + mips_service: MipsService, + enable_subscribe: bool = False, + virtual_did: Optional[int] = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + if not network: + raise ValueError('network is required') + if not mips_service: + raise ValueError('mips_service is required') + self._main_loop = loop or asyncio.get_event_loop() + self._net_ifs = set(net_ifs) + self._network = network + self._network.sub_network_info( + key='miot_lan', handler=self.__on_network_info_change) + self._mips_service = mips_service + self._mips_service.sub_service_change( + key='miot_lan', group_id='*', + handler=self.__on_mips_service_change) + self._enable_subscribe = enable_subscribe + self._virtual_did = virtual_did or str(secrets.randbits(64)) + # Init socket probe message + probe_bytes = bytearray(self.OT_PROBE_LEN) + probe_bytes[:20] = ( + b'!1\x00\x20\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFFMDID') + probe_bytes[20:28] = struct.pack('>Q', int(self._virtual_did)) + probe_bytes[28:32] = b'\x00\x00\x00\x00' + self._probe_msg = bytes(probe_bytes) + self._read_buffer = bytearray(self.OT_MSG_LEN) + self._write_buffer = bytearray(self.OT_MSG_LEN) + + self._lan_devices = {} + self._available_net_ifs = set() + self._broadcast_socks = {} + self._local_port = None + self._scan_timer = None + self._last_scan_interval = None + self._msg_id_counter = int(random.random()*0x7FFFFFFF) + self._pending_requests = {} + self._device_msg_matcher = MIoTMatcher() + self._device_state_sub_map = {} + self._reply_msg_buffer = {} + + self._lan_state_sub_map = {} + self._lan_ctrl_vote_map = {} + + self._init_done = False + + if ( + len(self._mips_service.get_services()) == 0 + and len(self._net_ifs) > 0 + ): + _LOGGER.info('no central hub gateway service, init miot lan') + self._main_loop.call_later( + 0, lambda: self._main_loop.create_task( + self.init_async())) + + @ property + def virtual_did(self) -> str: + return self._virtual_did + + @ property + def mev(self) -> MIoTEventLoop: + return self._mev + + @property + def init_done(self) -> bool: + return self._init_done + + async def init_async(self) -> None: + if self._init_done: + _LOGGER.info('miot lan already init') + return + if len(self._net_ifs) == 0: + _LOGGER.info('no net_ifs') + return + if not any(self._lan_ctrl_vote_map.values()): + _LOGGER.info('no vote for lan ctrl') + return + if len(self._mips_service.get_services()) > 0: + _LOGGER.info('central hub gateway service exist') + return + for if_name in list(self._network.network_info.keys()): + self._available_net_ifs.add(if_name) + if len(self._available_net_ifs) == 0: + _LOGGER.info('no available net_ifs') + return + if self._net_ifs.isdisjoint(self._available_net_ifs): + _LOGGER.info('no valid net_ifs') + return + self._mev = MIoTEventLoop() + self._queue = queue.Queue() + self._cmd_event_fd = os.eventfd(0, os.O_NONBLOCK) + self._mev.set_read_handler( + self._cmd_event_fd, self.__cmd_read_handler, None) + self._thread = threading.Thread(target=self.__lan_thread_handler) + self._thread.name = 'miot_lan' + self._thread.daemon = True + self._thread.start() + self._init_done = True + for handler in list(self._lan_state_sub_map.values()): + self._main_loop.create_task(handler(True)) + _LOGGER.info( + 'miot lan init, %s ,%s', self._net_ifs, self._available_net_ifs) + + async def deinit_async(self) -> None: + if not self._init_done: + _LOGGER.info('miot lan not init') + return + self._init_done = False + self.__lan_send_cmd(MIoTLanCmdType.DEINIT, None) + self._thread.join() + + self._lan_devices = {} + self._broadcast_socks = {} + self._local_port = None + self._scan_timer = None + self._last_scan_interval = None + self._msg_id_counter = int(random.random()*0x7FFFFFFF) + self._pending_requests = {} + self._device_msg_matcher = MIoTMatcher() + self._device_state_sub_map = {} + self._reply_msg_buffer = {} + for handler in list(self._lan_state_sub_map.values()): + self._main_loop.create_task(handler(False)) + _LOGGER.info('miot lan deinit') + + async def update_net_ifs_async(self, net_ifs: list[str]) -> None: + _LOGGER.info('update net_ifs, %s', net_ifs) + if not isinstance(net_ifs, list): + _LOGGER.error('invalid net_ifs, %s', net_ifs) + return + if len(net_ifs) == 0: + # Deinit lan + await self.deinit_async() + self._net_ifs = set(net_ifs) + return + available_net_ifs = set() + for if_name in list(self._network.network_info.keys()): + available_net_ifs.add(if_name) + if set(net_ifs).isdisjoint(available_net_ifs): + _LOGGER.error('no valid net_ifs, %s', net_ifs) + await self.deinit_async() + self._net_ifs = set(net_ifs) + self._available_net_ifs = available_net_ifs + return + if not self._init_done: + self._net_ifs = set(net_ifs) + await self.init_async() + return + self.__lan_send_cmd( + cmd=MIoTLanCmdType.NET_IFS_UPDATE, + data=net_ifs) + + async def vote_for_lan_ctrl_async(self, key: str, vote: bool) -> None: + _LOGGER.info('vote for lan ctrl, %s, %s', key, vote) + self._lan_ctrl_vote_map[key] = vote + if not any(self._lan_ctrl_vote_map.values()): + await self.deinit_async() + return + await self.init_async() + + async def update_subscribe_option(self, enable_subscribe: bool) -> None: + _LOGGER.info('update subscribe option, %s', enable_subscribe) + if not self._init_done: + self._enable_subscribe = enable_subscribe + return + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.OPTIONS_UPDATE, + data={ + 'enable_subscribe': enable_subscribe, }) + + def update_devices(self, devices: dict[str, dict]) -> bool: + _LOGGER.info('update devices, %s', devices) + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.DEVICE_UPDATE, + data=devices) + + def delete_devices(self, devices: list[str]) -> bool: + _LOGGER.info('delete devices, %s', devices) + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.DEVICE_DELETE, + data=devices) + + def sub_lan_state( + self, key: str, handler: Callable[[bool], asyncio.Future] + ) -> None: + self._lan_state_sub_map[key] = handler + + def unsub_lan_state(self, key: str) -> None: + self._lan_state_sub_map.pop(key, None) + + @final + def sub_device_state( + self, key: str, handler: Callable[[str, dict, any], None], + handler_ctx: any = None + ) -> bool: + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.SUB_DEVICE_STATE, + data=MIoTLanSubDeviceState( + key=key, handler=handler, handler_ctx=handler_ctx)) + + @final + def unsub_device_state(self, key: str) -> bool: + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.UNSUB_DEVICE_STATE, + data=MIoTLanUnsubDeviceState(key=key)) + + @final + def sub_prop( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, piid: int = None, handler_ctx: any = None + ) -> bool: + if not self._enable_subscribe: + return False + key = ( + f'{did}/p/' + f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.REG_BROADCAST, + data=MIoTLanRegisterBroadcastData( + key=key, handler=handler, handler_ctx=handler_ctx)) + + @final + def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + if not self._enable_subscribe: + return False + key = ( + f'{did}/p/' + f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.UNREG_BROADCAST, + data=MIoTLanUnregisterBroadcastData(key=key)) + + @final + def sub_event( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, eiid: int = None, handler_ctx: any = None + ) -> bool: + if not self._enable_subscribe: + return False + key = ( + f'{did}/e/' + f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.REG_BROADCAST, + data=MIoTLanRegisterBroadcastData( + key=key, handler=handler, handler_ctx=handler_ctx)) + + @final + def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + if not self._enable_subscribe: + return False + key = ( + f'{did}/e/' + f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') + return self.__lan_send_cmd( + cmd=MIoTLanCmdType.UNREG_BROADCAST, + data=MIoTLanUnregisterBroadcastData(key=key)) + + @final + async def get_prop_async( + self, did: str, siid: int, piid: int, timeout_ms: int = 10000 + ) -> any: + result_obj = await self.__call_api_async( + did=did, msg={ + 'method': 'get_properties', + 'params': [{'did': did, 'siid': siid, 'piid': piid}] + }, timeout_ms=timeout_ms) + + if ( + result_obj and 'result' in result_obj + and len(result_obj['result']) == 1 + and 'did' in result_obj['result'][0] + and result_obj['result'][0]['did'] == did + ): + return result_obj['result'][0].get('value', None) + return None + + @final + async def set_prop_async( + self, did: str, siid: int, piid: int, value: any, + timeout_ms: int = 10000 + ) -> dict: + result_obj = await self.__call_api_async( + did=did, msg={ + 'method': 'set_properties', + 'params': [{ + 'did': did, 'siid': siid, 'piid': piid, 'value': value}] + }, timeout_ms=timeout_ms) + if result_obj: + if ( + 'result' in result_obj + and len(result_obj['result']) == 1 + and 'did' in result_obj['result'][0] + and result_obj['result'][0]['did'] == did + and 'code' in result_obj['result'][0] + ): + return result_obj['result'][0] + if 'code' in result_obj: + return result_obj + return { + 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, + 'message': 'Invalid result'} + + @final + async def action_async( + self, did: str, siid: int, aiid: int, in_list: list, + timeout_ms: int = 10000 + ) -> dict: + result_obj = await self.__call_api_async( + did=did, msg={ + 'method': 'action', + 'params': { + 'did': did, 'siid': siid, 'aiid': aiid, 'in': in_list} + }, timeout_ms=timeout_ms) + if result_obj: + if 'result' in result_obj and 'code' in result_obj['result']: + return result_obj['result'] + if 'code' in result_obj: + return result_obj + return { + 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, + 'message': 'Invalid result'} + + @final + async def get_dev_list_async( + self, timeout_ms: int = 10000 + ) -> dict[str, dict]: + if not self._init_done: + return {} + + def get_device_list_handler(msg: dict, fut: asyncio.Future): + self._main_loop.call_soon_threadsafe( + fut.set_result, msg) + + fut: asyncio.Future = self._main_loop.create_future() + if self.__lan_send_cmd( + MIoTLanCmdType.GET_DEV_LIST, + MIoTLanGetDevListData( + handler=get_device_list_handler, + handler_ctx=fut, + timeout_ms=timeout_ms)): + return await fut + _LOGGER.error('get_dev_list_async error, send cmd failed') + fut.set_result({}) + return await fut + + def ping(self, if_name: str, target_ip: str) -> None: + if not target_ip: + return + self.__sendto( + if_name=if_name, data=self._probe_msg, address=target_ip, + port=self.OT_PORT) + + def send2device( + self, did: str, + msg: dict, + handler: Optional[Callable[[dict, any], None]] = None, + handler_ctx: any = None, + timeout_ms: Optional[int] = None + ) -> None: + if timeout_ms and not handler: + raise ValueError('handler is required when timeout_ms is set') + device: MIoTLanDevice = self._lan_devices.get(did) + if not device: + raise ValueError('invalid device') + if not device.cipher: + raise ValueError('invalid device cipher') + if not device.if_name: + raise ValueError('invalid device if_name') + if not device.ip: + raise ValueError('invalid device ip') + in_msg = {'id': self.__gen_msg_id(), **msg} + msg_len = device.gen_packet( + out_buffer=self._write_buffer, + clear_data=in_msg, + did=did, + offset=int(time.time())-device.offset) + + return self.make_request( + msg_id=in_msg['id'], + msg=self._write_buffer[0: msg_len], + if_name=device.if_name, + ip=device.ip, + handler=handler, + handler_ctx=handler_ctx, + timeout_ms=timeout_ms) + + def make_request( + self, + msg_id: int, + msg: bytearray, + if_name: str, + ip: str, + handler: Callable[[dict, any], None], + handler_ctx: any = None, + timeout_ms: Optional[int] = None + ) -> None: + def request_timeout_handler(req_data: MIoTLanRequestData): + self._pending_requests.pop(req_data.msg_id, None) + if req_data: + req_data.handler({ + 'code': MIoTErrorCode.CODE_TIMEOUT.value, + 'error': 'timeout'}, + req_data.handler_ctx) + + timer: Optional[TimeoutHandle] = None + request_data = MIoTLanRequestData( + msg_id=msg_id, + handler=handler, + handler_ctx=handler_ctx, + timeout=timer) + if timeout_ms: + timer = self._mev.set_timeout( + timeout_ms, request_timeout_handler, request_data) + request_data.timeout = timer + self._pending_requests[msg_id] = request_data + self.__sendto(if_name=if_name, data=msg, address=ip, port=self.OT_PORT) + + def broadcast_device_state(self, did: str, state: dict) -> None: + for handler in self._device_state_sub_map.values(): + self._main_loop.call_soon_threadsafe( + self._main_loop.create_task, + handler.handler(did, state, handler.handler_ctx)) + + def __gen_msg_id(self) -> int: + if not self._msg_id_counter: + self._msg_id_counter = int(random.random()*0x7FFFFFFF) + self._msg_id_counter += 1 + if self._msg_id_counter > 0x80000000: + self._msg_id_counter = 1 + return self._msg_id_counter + + def __lan_send_cmd(self, cmd: MIoTLanCmd, data: any) -> bool: + try: + self._queue.put(MIoTLanCmd(type_=cmd, data=data)) + os.eventfd_write(self._cmd_event_fd, 1) + return True + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('send cmd error, %s, %s', cmd, err) + return False + + async def __call_api_async( + self, did: str, msg: dict, timeout_ms: int = 10000 + ) -> dict: + def call_api_handler(msg: dict, fut: asyncio.Future): + self._main_loop.call_soon_threadsafe( + fut.set_result, msg) + + fut: asyncio.Future = self._main_loop.create_future() + if self.__lan_send_cmd( + cmd=MIoTLanCmdType.CALL_API, + data=MIoTLanCallApiData( + did=did, + msg=msg, + handler=call_api_handler, + handler_ctx=fut, + timeout_ms=timeout_ms)): + return await fut + + fut.set_result({ + 'code': MIoTErrorCode.CODE_UNAVAILABLE.value, + 'error': 'send cmd error'}) + return await fut + + def __lan_thread_handler(self) -> None: + _LOGGER.info('miot lan thread start') + self.__init_socket() + # Create scan devices timer + self._scan_timer = self._mev.set_timeout( + int(3000*random.random()), self.__scan_devices, None) + self._mev.loop_forever() + _LOGGER.info('miot lan thread exit') + + def __cmd_read_handler(self, ctx: any) -> None: + fd_value = os.eventfd_read(self._cmd_event_fd) + if fd_value == 0: + return + while not self._queue.empty(): + mips_cmd: MIoTLanCmd = self._queue.get(block=False) + if mips_cmd.type_ == MIoTLanCmdType.CALL_API: + call_api_data: MIoTLanCallApiData = mips_cmd.data + try: + self.send2device( + did=call_api_data.did, + msg={'from': 'ha.xiaomi_home', **call_api_data.msg}, + handler=call_api_data.handler, + handler_ctx=call_api_data.handler_ctx, + timeout_ms=call_api_data.timeout_ms) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('send2device error, %s', err) + call_api_data.handler({ + 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, + 'error': str(err)}, + call_api_data.handler_ctx) + elif mips_cmd.type_ == MIoTLanCmdType.SUB_DEVICE_STATE: + sub_data: MIoTLanSubDeviceState = mips_cmd.data + self._device_state_sub_map[sub_data.key] = sub_data + elif mips_cmd.type_ == MIoTLanCmdType.UNSUB_DEVICE_STATE: + sub_data: MIoTLanUnsubDeviceState = mips_cmd.data + self._device_state_sub_map.pop(sub_data.key, None) + elif mips_cmd.type_ == MIoTLanCmdType.REG_BROADCAST: + reg_data: MIoTLanRegisterBroadcastData = mips_cmd.data + self._device_msg_matcher[reg_data.key] = reg_data + _LOGGER.debug('lan register broadcast, %s', reg_data.key) + elif mips_cmd.type_ == MIoTLanCmdType.UNREG_BROADCAST: + unreg_data: MIoTLanUnregisterBroadcastData = mips_cmd.data + if self._device_msg_matcher.get(topic=unreg_data.key): + del self._device_msg_matcher[unreg_data.key] + _LOGGER.debug('lan unregister broadcast, %s', unreg_data.key) + elif mips_cmd.type_ == MIoTLanCmdType.GET_DEV_LIST: + get_dev_list_data: MIoTLanGetDevListData = mips_cmd.data + dev_list = { + device.did: { + 'online': device.online, + 'push_available': device.subscribed + } + for device in self._lan_devices.values() + if device.online} + get_dev_list_data.handler( + dev_list, get_dev_list_data.handler_ctx) + elif mips_cmd.type_ == MIoTLanCmdType.DEVICE_UPDATE: + devices: dict[str, dict] = mips_cmd.data + for did, info in devices.items(): + if did not in self._lan_devices: + if 'token' not in info: + _LOGGER.error( + 'token not found, %s, %s', did, info) + continue + if len(info['token']) != 32: + _LOGGER.error( + 'invalid device token, %s, %s', did, info) + continue + self._lan_devices[did] = MIoTLanDevice( + manager=self, did=did, token=info['token'], + ip=info.get('ip', None)) + else: + self._lan_devices[did].update_info(info) + elif mips_cmd.type_ == MIoTLanCmdType.DEVICE_DELETE: + device_dids: list[str] = mips_cmd.data + for did in device_dids: + lan_device = self._lan_devices.pop(did, None) + if not lan_device: + continue + lan_device.on_delete() + elif mips_cmd.type_ == MIoTLanCmdType.NET_INFO_UPDATE: + net_data: MIoTLanNetworkUpdateData = mips_cmd.data + if net_data.status == InterfaceStatus.ADD: + self._available_net_ifs.add(net_data.if_name) + if net_data.if_name in self._net_ifs: + self.__create_socket(if_name=net_data.if_name) + elif net_data.status == InterfaceStatus.REMOVE: + self._available_net_ifs.remove(net_data.if_name) + self.__destroy_socket(if_name=net_data.if_name) + elif mips_cmd.type_ == MIoTLanCmdType.NET_IFS_UPDATE: + net_ifs: list[str] = mips_cmd.data + if self._net_ifs != set(net_ifs): + self._net_ifs = set(net_ifs) + for if_name in self._net_ifs: + self.__create_socket(if_name=if_name) + for if_name in list(self._broadcast_socks.keys()): + if if_name not in self._net_ifs: + self.__destroy_socket(if_name=if_name) + elif mips_cmd.type_ == MIoTLanCmdType.OPTIONS_UPDATE: + options: dict = mips_cmd.data + if 'enable_subscribe' in options: + if options['enable_subscribe'] != self._enable_subscribe: + self._enable_subscribe = options['enable_subscribe'] + if not self._enable_subscribe: + # Unsubscribe all + for device in self._lan_devices.values(): + device.unsubscribe() + elif mips_cmd.type_ == MIoTLanCmdType.DEINIT: + # stop the thread + if self._scan_timer: + self._mev.clear_timeout(self._scan_timer) + self._scan_timer = None + for device in self._lan_devices.values(): + device.on_delete() + self._lan_devices.clear() + for req_data in self._pending_requests.values(): + self._mev.clear_timeout(req_data.timeout) + self._pending_requests.clear() + for timer in self._reply_msg_buffer.values(): + self._mev.clear_timeout(timer) + self._reply_msg_buffer.clear() + self._device_msg_matcher = MIoTMatcher() + self.__deinit_socket() + self._mev.loop_stop() + + def __init_socket(self) -> None: + self.__deinit_socket() + for if_name in self._net_ifs: + if if_name not in self._available_net_ifs: + return + self.__create_socket(if_name=if_name) + + def __create_socket(self, if_name: str) -> None: + if if_name in self._broadcast_socks: + _LOGGER.info('socket already created, %s', if_name) + return + # Create socket + try: + sock = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # Set SO_BINDTODEVICE + sock.setsockopt( + socket.SOL_SOCKET, socket.SO_BINDTODEVICE, if_name.encode()) + sock.bind(('', self._local_port or 0)) + self._mev.set_read_handler( + sock.fileno(), self.__socket_read_handler, (if_name, sock)) + self._broadcast_socks[if_name] = sock + self._local_port = self._local_port or sock.getsockname()[1] + _LOGGER.info( + 'created socket, %s, %s', if_name, self._local_port) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('create socket error, %s, %s', if_name, err) + + def __deinit_socket(self) -> None: + for if_name in list(self._broadcast_socks.keys()): + self.__destroy_socket(if_name) + self._broadcast_socks.clear() + + def __destroy_socket(self, if_name: str) -> None: + sock = self._broadcast_socks.pop(if_name, None) + if not sock: + return + self._mev.set_read_handler(sock.fileno(), None, None) + sock.close() + _LOGGER.info('destroyed socket, %s', if_name) + + def __socket_read_handler(self, ctx: tuple[str, socket.socket]) -> None: + try: + data_len, addr = ctx[1].recvfrom_into( + self._read_buffer, self.OT_MSG_LEN, socket.MSG_DONTWAIT) + if data_len < 0: + # Socket error + _LOGGER.error('socket read error, %s, %s', ctx[0], data_len) + return + if addr[1] != self.OT_PORT: + # Not ot msg + return + self.__raw_message_handler( + self._read_buffer[:data_len], data_len, addr[0], ctx[0]) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('socket read handler error, %s', err) + + def __raw_message_handler( + self, data: bytearray, data_len: int, ip: str, if_name: str + ) -> None: + if data[:2] != self.OT_HEADER: + return + # Keep alive message + did: str = str(struct.unpack('>Q', data[4:12])[0]) + device: MIoTLanDevice = self._lan_devices.get(did) + if not device: + return + timestamp: int = struct.unpack('>I', data[12:16])[0] + device.offset = int(time.time()) - timestamp + # Keep alive if this is a probe + if data_len == self.OT_PROBE_LEN or device.subscribed: + device.keep_alive(ip=ip, if_name=if_name) + # Manage device subscribe status + if ( + self._enable_subscribe + and data_len == self.OT_PROBE_LEN + and data[16:20] == b'MSUB' + and data[24:27] == b'PUB' + ): + device.supported_wildcard_sub = ( + int(data[28]) == self.OT_SUPPORT_WILDCARD_SUB) + sub_ts = struct.unpack('>I', data[20:24])[0] + sub_type = int(data[27]) + if ( + device.supported_wildcard_sub + and sub_type in [0, 1, 4] + and sub_ts != device.sub_ts + ): + device.subscribed = False + device.subscribe() + if data_len > self.OT_PROBE_LEN: + # handle device message + try: + decrypted_data = device.decrypt_packet(data) + self.__message_handler(did, decrypted_data) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('decrypt packet error, %s, %s', did, err) + return + + def __message_handler(self, did: str, msg: dict) -> None: + if 'id' not in msg: + _LOGGER.warning('invalid message, no id, %s, %s', did, msg) + return + # Reply + req: MIoTLanRequestData = self._pending_requests.pop(msg['id'], None) + if req: + self._mev.clear_timeout(req.timeout) + self._main_loop.call_soon_threadsafe( + req.handler, msg, req.handler_ctx) + return + # Handle up link message + if 'method' not in msg or 'params' not in msg: + _LOGGER.debug( + 'invalid message, no method or params, %s, %s', did, msg) + return + # Filter dup message + if self.__filter_dup_message(did, msg['id']): + self.send2device( + did=did, msg={'id': msg['id'], 'result': {'code': 0}}) + return + _LOGGER.debug('lan message, %s, %s', did, msg) + if msg['method'] == 'properties_changed': + for param in msg['params']: + if 'siid' not in param and 'piid' not in param: + _LOGGER.debug( + 'invalid message, no siid or piid, %s, %s', did, msg) + continue + key = f'{did}/p/{param["siid"]}/{param["piid"]}' + subs: list[MIoTLanRegisterBroadcastData] = list( + self._device_msg_matcher.iter_match(key)) + for sub in subs: + self._main_loop.call_soon_threadsafe( + sub.handler, param, sub.handler_ctx) + elif ( + msg['method'] == 'event_occured' + and 'siid' in msg['params'] + and 'eiid' in msg['params'] + ): + key = f'{did}/e/{msg["params"]["siid"]}/{msg["params"]["eiid"]}' + subs: list[MIoTLanRegisterBroadcastData] = list( + self._device_msg_matcher.iter_match(key)) + for sub in subs: + self._main_loop.call_soon_threadsafe( + sub.handler, msg['params'], sub.handler_ctx) + else: + _LOGGER.debug( + 'invalid message, unknown method, %s, %s', did, msg) + # Reply + self.send2device( + did=did, msg={'id': msg['id'], 'result': {'code': 0}}) + + def __filter_dup_message(self, did: str, msg_id: int) -> bool: + filter_id = f'{did}.{msg_id}' + if filter_id in self._reply_msg_buffer: + return True + self._reply_msg_buffer[filter_id] = self._mev.set_timeout( + 5000, + lambda filter_id: self._reply_msg_buffer.pop(filter_id, None), + filter_id) + + def __sendto( + self, if_name: str, data: bytes, address: str, port: int + ) -> None: + if address == '255.255.255.255': + # Broadcast + for if_n, sock in self._broadcast_socks.items(): + _LOGGER.debug('send broadcast, %s', if_n) + sock.sendto(data, socket.MSG_DONTWAIT, (address, port)) + else: + # Unicast + sock = self._broadcast_socks.get(if_name, None) + if not sock: + _LOGGER.error('invalid socket, %s', if_name) + return + sock.sendto(data, socket.MSG_DONTWAIT, (address, port)) + + def __scan_devices(self, ctx: any) -> None: + if self._scan_timer: + self._mev.clear_timeout(self._scan_timer) + # Scan devices + self.ping(if_name=None, target_ip='255.255.255.255') + scan_time = self.__get_next_scan_time() + self._scan_timer = self._mev.set_timeout( + scan_time, self.__scan_devices, None) + _LOGGER.debug('next scan time: %sms', scan_time) + + def __get_next_scan_time(self) -> int: + if not self._last_scan_interval: + self._last_scan_interval = self.OT_PROBE_INTERVAL_MIN + self._last_scan_interval = min( + self._last_scan_interval*2, self.OT_PROBE_INTERVAL_MAX) + return self._last_scan_interval + + async def __on_network_info_change( + self, + status: InterfaceStatus, + info: NetworkInfo + ) -> None: + _LOGGER.info( + 'on network info change, status: %s, info: %s', status, info) + available_net_ifs = set() + for if_name in list(self._network.network_info.keys()): + available_net_ifs.add(if_name) + if len(available_net_ifs) == 0: + await self.deinit_async() + self._available_net_ifs = available_net_ifs + return + if self._net_ifs.isdisjoint(available_net_ifs): + _LOGGER.info('no valid net_ifs') + await self.deinit_async() + self._available_net_ifs = available_net_ifs + return + if not self._init_done: + self._available_net_ifs = available_net_ifs + await self.init_async() + return + self.__lan_send_cmd( + MIoTLanCmdType.NET_INFO_UPDATE, MIoTLanNetworkUpdateData( + status=status, if_name=info.name)) + + async def __on_mips_service_change( + self, group_id: str, state: MipsServiceState, data: dict + ) -> None: + _LOGGER.info( + 'on mips service change, %s, %s, %s', group_id, state, data) + if len(self._mips_service.get_services()) > 0: + _LOGGER.info('find central service, deinit miot lan') + await self.deinit_async() + else: + _LOGGER.info('no central service, init miot lan') + await self.init_async() diff --git a/custom_components/xiaomi_home/miot/miot_mdns.py b/custom_components/xiaomi_home/miot/miot_mdns.py new file mode 100644 index 0000000..78ed0cd --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_mdns.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT central hub gateway service discovery. +""" +import asyncio +import base64 +import binascii +import copy +from enum import Enum +from typing import Callable, Optional +import logging + +from zeroconf import ( + DNSQuestionType, + IPVersion, + ServiceStateChange, + Zeroconf) +from zeroconf.asyncio import ( + AsyncServiceInfo, + AsyncZeroconf, + AsyncServiceBrowser) + +from .miot_error import MipsServiceError + +_LOGGER = logging.getLogger(__name__) + +MIPS_MDNS_TYPE = '_miot-central._tcp.local.' +MIPS_MDNS_REQUEST_TIMEOUT_MS = 5000 +MIPS_MDNS_UPDATE_INTERVAL_S = 600 + + +class MipsServiceState(Enum): + ADDED = 1 + REMOVED = 2 + UPDATED = 3 + + +class MipsServiceData: + """Mips service data.""" + profile: str + profile_bin: bytes + + name: str + addresses: list[str] + port: int + type: str + server: str + + did: str + group_id: str + role: int + suite_mqtt: bool + + def __init__(self, service_info: AsyncServiceInfo) -> None: + if service_info is None: + raise MipsServiceError('invalid params') + properties = service_info.decoded_properties + if properties is None: + raise MipsServiceError('invalid service properties') + self.profile = properties.get('profile', None) + if self.profile is None: + raise MipsServiceError('invalid service profile') + self.profile_bin = base64.b64decode(self.profile) + self.name = service_info.name + self.addresses = service_info.parsed_addresses( + version=IPVersion.V4Only) + if not self.addresses: + raise MipsServiceError('invalid addresses') + self.addresses.sort() + self.port = service_info.port + self.type = service_info.type + self.server = service_info.server + # Parse profile + self.did = str(int.from_bytes(self.profile_bin[1:9])) + self.group_id = binascii.hexlify( + self.profile_bin[9:17][::-1]).decode('utf-8') + self.role = int(self.profile_bin[20] >> 4) + self.suite_mqtt = ((self.profile_bin[22] >> 1) & 0x01) == 0x01 + + def valid_service(self) -> bool: + if self.role != 1: + return False + return self.suite_mqtt + + def to_dict(self) -> dict: + return { + 'name': self.name, + 'addresses': self.addresses, + 'port': self.port, + 'type': self.type, + 'server': self.server, + 'did': self.did, + 'group_id': self.group_id, + 'role': self.role, + 'suite_mqtt': self.suite_mqtt + } + + def __str__(self) -> str: + return str(self.to_dict()) + + +class MipsService: + """MIPS service discovery.""" + _aiozc: AsyncZeroconf + _main_loop: asyncio.AbstractEventLoop + _aio_browser: AsyncServiceBrowser + _services: dict[str, dict] + # key = (key, group_id) + _sub_list: dict[(str, str), Callable[[ + str, MipsServiceState, dict], asyncio.Future]] + + def __init__( + self, aiozc: AsyncZeroconf, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._aiozc = aiozc + self._main_loop = loop or asyncio.get_running_loop() + self._aio_browser = None + + self._services = {} + self._sub_list = {} + + async def init_async(self) -> None: + await self._aiozc.zeroconf.async_wait_for_start() + + self._aio_browser = AsyncServiceBrowser( + zeroconf=self._aiozc.zeroconf, + type_=MIPS_MDNS_TYPE, + handlers=[self.__on_service_state_change], + question_type=DNSQuestionType.QM) + + async def deinit_async(self) -> None: + await self._aio_browser.async_cancel() + self._services = {} + self._sub_list = {} + + def get_services(self, group_id: Optional[str] = None) -> dict[str, dict]: + """get mips services. + + Args: + group_id (str, optional): _description_. Defaults to None. + + Returns: { + [group_id:str]: { + "name": str, + "addresses": list[str], + "port": number, + "type": str, + "server": str, + "version": int, + "did": str, + "group_id": str, + "role": int, + "suite_mqtt": bool + } + } + """ + if group_id: + if group_id not in self._services: + return {} + return {group_id: copy.deepcopy(self._services[group_id])} + return copy.deepcopy(self._services) + + def sub_service_change( + self, key: str, group_id: str, + handler: Callable[[str, MipsServiceState, dict], asyncio.Future] + ) -> None: + if key is None or group_id is None or handler is None: + raise MipsServiceError('invalid params') + self._sub_list[(key, group_id)] = handler + + def unsub_service_change(self, key: str) -> None: + if key is None: + return + for keys in list(self._sub_list.keys()): + if key == keys[0]: + self._sub_list.pop(keys, None) + + def __on_service_state_change( + self, zeroconf: Zeroconf, service_type: str, name: str, + state_change: ServiceStateChange + ) -> None: + _LOGGER.debug( + 'mips service state changed, %s, %s, %s', + state_change, name, service_type) + + if state_change is ServiceStateChange.Removed: + for item in list(self._services.values()): + if item['name'] != name: + continue + service_data = self._services.pop(item['group_id'], None) + self.__call_service_change( + state=MipsServiceState.REMOVED, data=service_data) + return + self._main_loop.create_task( + self.__request_service_info_async(zeroconf, service_type, name)) + + async def __request_service_info_async( + self, zeroconf: Zeroconf, service_type: str, name: str + ) -> None: + info = AsyncServiceInfo(service_type, name) + await info.async_request( + zeroconf, MIPS_MDNS_REQUEST_TIMEOUT_MS, + question_type=DNSQuestionType.QU) + + try: + service_data = MipsServiceData(info) + if not service_data.valid_service(): + raise MipsServiceError( + 'no primary role, no support mqtt connection') + if service_data.group_id in self._services: + # Update mips service + buffer_data = self._services[service_data.group_id] + if ( + service_data.did != buffer_data['did'] + or service_data.addresses != buffer_data['addresses'] + or service_data.port != buffer_data['port'] + ): + self._services[service_data.group_id].update( + service_data.to_dict()) + self.__call_service_change( + state=MipsServiceState.UPDATED, + data=service_data.to_dict()) + else: + # Add mips service + self._services[service_data.group_id] = service_data.to_dict() + self.__call_service_change( + state=MipsServiceState.ADDED, + data=self._services[service_data.group_id]) + except MipsServiceError as error: + _LOGGER.error('invalid mips service, %s, %s', error, info) + + def __call_service_change( + self, state: MipsServiceState, data: dict = None + ) -> None: + _LOGGER.info('call service change, %s, %s', state, data) + for keys in list(self._sub_list.keys()): + if keys[1] in [data['group_id'], '*']: + self._main_loop.create_task( + self._sub_list[keys](data['group_id'], state, data)) diff --git a/custom_components/xiaomi_home/miot/miot_mips.py b/custom_components/xiaomi_home/miot/miot_mips.py new file mode 100644 index 0000000..e1e76cc --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_mips.py @@ -0,0 +1,1806 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT Pub/Sub client. +""" +import asyncio +import json +import logging +import os +import queue +import random +import re +import ssl +import struct +import threading +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum, auto +from typing import Callable, Optional, final + +from paho.mqtt.client import ( + MQTT_ERR_SUCCESS, + MQTT_ERR_UNKNOWN, + Client, + MQTTv5) + +from .common import MIoTMatcher +from .const import MIHOME_MQTT_KEEPALIVE +from .miot_error import MIoTErrorCode, MIoTMipsError +from .miot_ev import MIoTEventLoop, TimeoutHandle + +_LOGGER = logging.getLogger(__name__) + + +class MipsMsgTypeOptions(Enum): + """MIoT Pub/Sub message type.""" + ID = 0 + RET_TOPIC = auto() + PAYLOAD = auto() + FROM = auto() + MAX = auto() + + +class MipsMessage: + """MIoT Pub/Sub message.""" + mid: int = 0 + msg_from: str = None + ret_topic: str = None + payload: str = None + + @staticmethod + def unpack(data: bytes): + mips_msg = MipsMessage() + data_len = len(data) + data_start = 0 + data_end = 0 + while data_start < data_len: + data_end = data_start+5 + unpack_len, unpack_type = struct.unpack( + ' bytes: + if mid is None or payload is None: + raise MIoTMipsError('invalid mid or payload') + pack_msg: bytes = b'' + # mid + pack_msg += struct.pack(' str: + return f'{self.mid}, {self.msg_from}, {self.ret_topic}, {self.payload}' + + +class MipsCmdType(Enum): + """MIoT Pub/Sub command type.""" + CONNECT = 0 + DISCONNECT = auto() + DEINIT = auto() + SUB = auto() + UNSUB = auto() + CALL_API = auto() + REG_BROADCAST = auto() + UNREG_BROADCAST = auto() + + REG_MIPS_STATE = auto() + UNREG_MIPS_STATE = auto() + REG_DEVICE_STATE = auto() + UNREG_DEVICE_STATE = auto() + + +@dataclass +class MipsCmd: + """MIoT Pub/Sub command.""" + type_: MipsCmdType + data: any + + def __init__(self, type_: MipsCmdType, data: any) -> None: + self.type_ = type_ + self.data = data + + +@dataclass +class MipsRequest: + """MIoT Pub/Sub request.""" + mid: int = None + on_reply: Callable[[str, any], None] = None + on_reply_ctx: any = None + timer: TimeoutHandle = None + + +@dataclass +class MipsRequestData: + """MIoT Pub/Sub request data.""" + topic: str = None + payload: str = None + on_reply: Callable[[str, any], None] = None + on_reply_ctx: any = None + timeout_ms: int = None + + +@dataclass +class MipsSendBroadcastData: + """MIoT Pub/Sub send broadcast data.""" + topic: str = None + payload: str = None + + +@dataclass +class MipsIncomingApiCall: + """MIoT Pub/Sub incoming API call.""" + mid: int = None + ret_topic: str = None + timer: TimeoutHandle = None + + +@dataclass +class MipsApi: + """MIoT Pub/Sub API.""" + topic: str = None + """ + param1: session + param2: payload + param3: handler_ctx + """ + handler: Callable[[MipsIncomingApiCall, str, any], None] = None + handler_ctx: any = None + + +class MipsRegApi(MipsApi): + """.MIoT Pub/Sub register API.""" + + +@dataclass +class MipsReplyData: + """MIoT Pub/Sub reply data.""" + session: MipsIncomingApiCall = None + payload: str = None + + +@dataclass +class MipsBroadcast: + """MIoT Pub/Sub broadcast.""" + topic: str = None + """ + param 1: msg topic + param 2: msg payload + param 3: handle_ctx + """ + handler: Callable[[str, str, any], None] = None + handler_ctx: any = None + + def __str__(self) -> str: + return f'{self.topic}, {id(self.handler)}, {id(self.handler_ctx)}' + + +class MipsRegBroadcast(MipsBroadcast): + """MIoT Pub/Sub register broadcast.""" + + +@dataclass +class MipsState: + """MIoT Pub/Sub state.""" + key: str = None + """ + str: key + bool: mips connect state + any: ctx + """ + handler: Callable[[str, bool], asyncio.Future] = None + + +class MipsRegState(MipsState): + """MIoT Pub/Sub register state.""" + + +class MIoTDeviceState(Enum): + """MIoT device state define.""" + DISABLE = 0 + OFFLINE = auto() + ONLINE = auto() + + +@dataclass +class MipsDeviceState: + """MIoT Pub/Sub device state.""" + did: str = None + """handler + str: did + MIoTDeviceState: online/offline/disable + any: ctx + """ + handler: Callable[[str, MIoTDeviceState, any], None] = None + handler_ctx: any = None + + +class MipsRegDeviceState(MipsDeviceState): + """MIoT Pub/Sub register device state.""" + + +class MipsClient(ABC): + """MIoT Pub/Sub client.""" + # pylint: disable=unused-argument + MQTT_INTERVAL_MS = 1000 + MIPS_QOS: int = 2 + UINT32_MAX: int = 0xFFFFFFFF + MIPS_RECONNECT_INTERVAL_MIN: int = 30000 + MIPS_RECONNECT_INTERVAL_MAX: int = 600000 + MIPS_SUB_PATCH: int = 300 + MIPS_SUB_INTERVAL: int = 1000 + main_loop: asyncio.AbstractEventLoop + _logger: logging.Logger + _client_id: str + _host: str + _port: int + _username: str + _password: str + _ca_file: str + _cert_file: str + _key_file: str + + _mqtt_logger: logging.Logger + _mqtt: Client + _mqtt_fd: int + _mqtt_timer: TimeoutHandle + _mqtt_state: bool + + _event_connect: asyncio.Event + _event_disconnect: asyncio.Event + _mev: MIoTEventLoop + _mips_thread: threading.Thread + _mips_queue: queue.Queue + _cmd_event_fd: os.eventfd + _mips_reconnect_tag: bool + _mips_reconnect_interval: int + _mips_reconnect_timer: Optional[TimeoutHandle] + _mips_state_sub_map: dict[str, MipsState] + _mips_sub_pending_map: dict[str, int] + _mips_sub_pending_timer: Optional[TimeoutHandle] + + _on_mips_cmd: Callable[[MipsCmd], None] + _on_mips_message: Callable[[str, bytes], None] + _on_mips_connect: Callable[[int, dict], None] + _on_mips_disconnect: Callable[[int, dict], None] + + def __init__( + self, client_id: str, host: str, port: int, + username: str = None, password: str = None, + ca_file: str = None, cert_file: str = None, key_file: str = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + # MUST run with running loop + self.main_loop = loop or asyncio.get_running_loop() + self._logger = None + self._client_id = client_id + self._host = host + self._port = port + self._username = username + self._password = password + self._ca_file = ca_file + self._cert_file = cert_file + self._key_file = key_file + + self._mqtt_logger = None + self._mqtt_fd = -1 + self._mqtt_timer = None + self._mqtt_state = False + # mqtt init for API_VERSION2, + # callback_api_version=CallbackAPIVersion.VERSION2, + self._mqtt = Client(client_id=self._client_id, protocol=MQTTv5) + self._mqtt.enable_logger(logger=self._mqtt_logger) + + # Mips init + self._event_connect = asyncio.Event() + self._event_disconnect = asyncio.Event() + self._mips_reconnect_tag = False + self._mips_reconnect_interval = 0 + self._mips_reconnect_timer = None + self._mips_state_sub_map = {} + self._mips_sub_pending_map = {} + self._mips_sub_pending_timer = None + self._mev = MIoTEventLoop() + self._mips_queue = queue.Queue() + self._cmd_event_fd = os.eventfd(0, os.O_NONBLOCK) + self.mev_set_read_handler( + self._cmd_event_fd, self.__mips_cmd_read_handler, None) + self._mips_thread = threading.Thread(target=self.__mips_loop_thread) + self._mips_thread.daemon = True + self._mips_thread.name = self._client_id + self._mips_thread.start() + + self._on_mips_cmd = None + self._on_mips_message = None + self._on_mips_connect = None + self._on_mips_disconnect = None + + @property + def client_id(self) -> str: + return self._client_id + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + @final + @property + def mips_state(self) -> bool: + """mips connect state. + + Returns: + bool: True: connected, False: disconnected + """ + return self._mqtt and self._mqtt.is_connected() + + @final + def mips_deinit(self) -> None: + self._mips_send_cmd(type_=MipsCmdType.DEINIT, data=None) + self._mips_thread.join() + self._mips_thread = None + + self._logger = None + self._client_id = None + self._host = None + self._port = None + self._username = None + self._password = None + self._ca_file = None + self._cert_file = None + self._key_file = None + self._mqtt_logger = None + self._mips_state_sub_map = None + self._mips_sub_pending_map = None + self._mips_sub_pending_timer = None + + self._event_connect = None + self._event_disconnect = None + + def update_mqtt_password(self, password: str) -> None: + self._password = password + self._mqtt.username_pw_set( + username=self._username, password=self._password) + + def log_debug(self, msg, *args, **kwargs) -> None: + if self._logger: + self._logger.debug(f'{self._client_id}, '+msg, *args, **kwargs) + + def log_info(self, msg, *args, **kwargs) -> None: + if self._logger: + self._logger.info(f'{self._client_id}, '+msg, *args, **kwargs) + + def log_error(self, msg, *args, **kwargs) -> None: + if self._logger: + self._logger.error(f'{self._client_id}, '+msg, *args, **kwargs) + + def enable_logger(self, logger: Optional[logging.Logger] = None) -> None: + self._logger = logger + + def enable_mqtt_logger( + self, logger: Optional[logging.Logger] = None + ) -> None: + if logger: + self._mqtt.enable_logger(logger=logger) + else: + self._mqtt.disable_logger() + + @final + def mips_connect(self) -> None: + """mips connect.""" + return self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) + + @final + async def mips_connect_async(self) -> None: + """mips connect async.""" + self._mips_send_cmd(type_=MipsCmdType.CONNECT, data=None) + return await self._event_connect.wait() + + @final + def mips_disconnect(self) -> None: + """mips disconnect.""" + return self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) + + @final + async def mips_disconnect_async(self) -> None: + """mips disconnect async.""" + self._mips_send_cmd(type_=MipsCmdType.DISCONNECT, data=None) + return await self._event_disconnect.wait() + + @final + def sub_mips_state( + self, key: str, handler: Callable[[str, bool], asyncio.Future] + ) -> bool: + """Subscribe mips state. + NOTICE: callback to main loop thread + """ + if isinstance(key, str) is False or handler is None: + raise MIoTMipsError('invalid params') + return self._mips_send_cmd( + type_=MipsCmdType.REG_MIPS_STATE, + data=MipsRegState(key=key, handler=handler)) + + @final + def unsub_mips_state(self, key: str) -> bool: + """Unsubscribe mips state.""" + if isinstance(key, str) is False: + raise MIoTMipsError('invalid params') + return self._mips_send_cmd( + type_=MipsCmdType.UNREG_MIPS_STATE, data=MipsRegState(key=key)) + + @final + def mev_set_timeout( + self, timeout_ms: int, handler: Callable[[any], None], + handler_ctx: any = None + ) -> Optional[TimeoutHandle]: + """set timeout. + NOTICE: Internal function, only mips threads are allowed to call + """ + if self._mev is None: + return None + return self._mev.set_timeout( + timeout_ms=timeout_ms, handler=handler, handler_ctx=handler_ctx) + + @final + def mev_clear_timeout(self, handle: TimeoutHandle) -> None: + """clear timeout. + NOTICE: Internal function, only mips threads are allowed to call + """ + if self._mev is None: + return + self._mev.clear_timeout(handle) + + @final + def mev_set_read_handler( + self, fd: int, handler: Callable[[any], None], handler_ctx: any + ) -> bool: + """set read handler. + NOTICE: Internal function, only mips threads are allowed to call + """ + if self._mev is None: + return False + return self._mev.set_read_handler( + fd=fd, handler=handler, handler_ctx=handler_ctx) + + @final + def mev_set_write_handler( + self, fd: int, handler: Callable[[any], None], handler_ctx: any + ) -> bool: + """set write handler. + NOTICE: Internal function, only mips threads are allowed to call + """ + if self._mev is None: + return False + return self._mev.set_write_handler( + fd=fd, handler=handler, handler_ctx=handler_ctx) + + @property + def on_mips_cmd(self) -> Callable[[MipsCmd], None]: + return self._on_mips_cmd + + @on_mips_cmd.setter + def on_mips_cmd(self, handler: Callable[[MipsCmd], None]) -> None: + """MUST set after __init__ done. + NOTICE thread safe, this function will be called at the **mips** thread + """ + self._on_mips_cmd = handler + + @property + def on_mips_message(self) -> Callable[[str, bytes], None]: + return self._on_mips_message + + @on_mips_message.setter + def on_mips_message(self, handler: Callable[[str, bytes], None]) -> None: + """MUST set after __init__ done. + NOTICE thread safe, this function will be called at the **mips** thread + """ + self._on_mips_message = handler + + @property + def on_mips_connect(self) -> Callable[[int, dict], None]: + return self._on_mips_connect + + @on_mips_connect.setter + def on_mips_connect(self, handler: Callable[[int, dict], None]) -> None: + """MUST set after __init__ done. + NOTICE thread safe, this function will be called at the + **main loop** thread + """ + self._on_mips_connect = handler + + @property + def on_mips_disconnect(self) -> Callable[[int, dict], None]: + return self._on_mips_disconnect + + @on_mips_disconnect.setter + def on_mips_disconnect(self, handler: Callable[[int, dict], None]) -> None: + """MUST set after __init__ done. + NOTICE thread safe, this function will be called at the + **main loop** thread + """ + self._on_mips_disconnect = handler + + @abstractmethod + def sub_prop( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, piid: int = None, handler_ctx: any = None + ) -> bool: ... + + @abstractmethod + def unsub_prop( + self, did: str, siid: int = None, piid: int = None + ) -> bool: ... + + @abstractmethod + def sub_event( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, eiid: int = None, handler_ctx: any = None + ) -> bool: ... + + @abstractmethod + def unsub_event( + self, did: str, siid: int = None, eiid: int = None + ) -> bool: ... + + @abstractmethod + async def get_dev_list_async( + self, payload: str = None, timeout_ms: int = 10000 + ) -> dict[str, dict]: ... + + @abstractmethod + async def get_prop_async( + self, did: str, siid: int, piid: int, timeout_ms: int = 10000 + ) -> any: ... + + @abstractmethod + async def set_prop_async( + self, did: str, siid: int, piid: int, value: any, + timeout_ms: int = 10000 + ) -> bool: ... + + @abstractmethod + async def action_async( + self, did: str, siid: int, aiid: int, in_list: list, + timeout_ms: int = 10000 + ) -> tuple[bool, list]: ... + + @final + def _mips_sub_internal(self, topic: str) -> None: + """mips subscribe. + NOTICE: Internal function, only mips threads are allowed to call + """ + self.__thread_check() + if not self._mqtt or not self._mqtt.is_connected(): + return + try: + if topic not in self._mips_sub_pending_map: + self._mips_sub_pending_map[topic] = 0 + if not self._mips_sub_pending_timer: + self._mips_sub_pending_timer = self.mev_set_timeout( + 10, self.__mips_sub_internal_pending_handler, topic) + except Exception as err: # pylint: disable=broad-exception-caught + # Catch all exception + self.log_error(f'mips sub internal error, {topic}. {err}') + + @final + def _mips_unsub_internal(self, topic: str) -> None: + """mips unsubscribe. + NOTICE: Internal function, only mips threads are allowed to call + """ + self.__thread_check() + if not self._mqtt or not self._mqtt.is_connected(): + return + try: + result, mid = self._mqtt.unsubscribe(topic=topic) + if result == MQTT_ERR_SUCCESS: + self.log_debug( + f'mips unsub internal success, {result}, {mid}, {topic}') + return + self.log_error( + f'mips unsub internal error, {result}, {mid}, {topic}') + except Exception as err: # pylint: disable=broad-exception-caught + # Catch all exception + self.log_error(f'mips unsub internal error, {topic}, {err}') + + @final + def _mips_publish_internal( + self, topic: str, payload: str | bytes, + wait_for_publish: bool = False, timeout_ms: int = 10000 + ) -> bool: + """mips publish message. + NOTICE: Internal function, only mips threads are allowed to call + + """ + self.__thread_check() + if not self._mqtt or not self._mqtt.is_connected(): + return False + try: + handle = self._mqtt.publish( + topic=topic, payload=payload, qos=self.MIPS_QOS) + # self.log_debug(f'_mips_publish_internal, {topic}, {payload}') + if wait_for_publish is True: + handle.wait_for_publish(timeout_ms/1000.0) + return True + except Exception as err: # pylint: disable=broad-exception-caught + # Catch other exception + self.log_error(f'mips publish internal error, {err}') + return False + + @final + def _mips_send_cmd(self, type_: MipsCmdType, data: any) -> bool: + if self._mips_queue is None or self._cmd_event_fd is None: + raise MIoTMipsError('send mips cmd disable') + # Put data to queue + self._mips_queue.put(MipsCmd(type_=type_, data=data)) + # Write event fd + os.eventfd_write(self._cmd_event_fd, 1) + # self.log_debug(f'send mips cmd, {type}, {data}') + return True + + def __thread_check(self) -> None: + if threading.current_thread() is not self._mips_thread: + raise MIoTMipsError('illegal call') + + def __mips_cmd_read_handler(self, ctx: any) -> None: + fd_value = os.eventfd_read(self._cmd_event_fd) + if fd_value == 0: + return + while self._mips_queue.empty() is False: + mips_cmd: MipsCmd = self._mips_queue.get(block=False) + if mips_cmd.type_ == MipsCmdType.CONNECT: + self._mips_reconnect_tag = True + self.__mips_try_reconnect(immediately=True) + elif mips_cmd.type_ == MipsCmdType.DISCONNECT: + self._mips_reconnect_tag = False + self.__mips_disconnect() + elif mips_cmd.type_ == MipsCmdType.DEINIT: + self.log_info('mips client recv deinit cmd') + self.__mips_disconnect() + # Close cmd event fd + if self._cmd_event_fd: + self.mev_set_read_handler( + self._cmd_event_fd, None, None) + os.close(self._cmd_event_fd) + self._cmd_event_fd = None + if self._mips_queue: + self._mips_queue = None + # ev loop stop + if self._mev: + self._mev.loop_stop() + self._mev = None + break + elif mips_cmd.type_ == MipsCmdType.REG_MIPS_STATE: + state: MipsState = mips_cmd.data + self._mips_state_sub_map[state.key] = state + self.log_debug(f'mips register mips state, {state.key}') + elif mips_cmd.type_ == MipsCmdType.UNREG_MIPS_STATE: + state: MipsState = mips_cmd.data + del self._mips_state_sub_map[state.key] + self.log_debug(f'mips unregister mips state, {state.key}') + else: + if self._on_mips_cmd: + self._on_mips_cmd(mips_cmd=mips_cmd) + + def __mqtt_read_handler(self, ctx: any) -> None: + self.__mqtt_loop_handler(ctx=ctx) + + def __mqtt_write_handler(self, ctx: any) -> None: + self.mev_set_write_handler(self._mqtt_fd, None, None) + self.__mqtt_loop_handler(ctx=ctx) + + def __mqtt_timer_handler(self, ctx: any) -> None: + self.__mqtt_loop_handler(ctx=ctx) + if self._mqtt: + self._mqtt_timer = self.mev_set_timeout( + self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + + def __mqtt_loop_handler(self, ctx: any) -> None: + try: + if self._mqtt: + self._mqtt.loop_read() + if self._mqtt: + self._mqtt.loop_write() + if self._mqtt: + self._mqtt.loop_misc() + if self._mqtt and self._mqtt.want_write(): + self.mev_set_write_handler( + self._mqtt_fd, self.__mqtt_write_handler, None) + except Exception as err: # pylint: disable=broad-exception-caught + # Catch all exception + self.log_error(f'__mqtt_loop_handler, {err}') + raise err + + def __mips_loop_thread(self) -> None: + self.log_info('mips_loop_thread start') + # Set mqtt config + if self._username: + self._mqtt.username_pw_set( + username=self._username, password=self._password) + if ( + self._ca_file + and self._cert_file + and self._key_file + ): + self._mqtt.tls_set( + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ca_certs=self._ca_file, + certfile=self._cert_file, + keyfile=self._key_file) + else: + self._mqtt.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT) + self._mqtt.tls_insecure_set(True) + self._mqtt.on_connect = self.__on_connect + self._mqtt.on_connect_fail = self.__on_connect_failed + self._mqtt.on_disconnect = self.__on_disconnect + self._mqtt.on_message = self.__on_message + # Run event loop + self._mev.loop_forever() + self.log_info('mips_loop_thread exit!') + + def __on_connect(self, client, user_data, flags, rc, props) -> None: + if not self._mqtt.is_connected(): + return + self.log_info(f'mips connect, {flags}, {rc}, {props}') + self._mqtt_state = True + if self._on_mips_connect: + self.mev_set_timeout( + timeout_ms=0, + handler=lambda ctx: + self._on_mips_connect(rc, props)) + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, True)) + # Resolve future + self._event_connect.set() + self._event_disconnect.clear() + + def __on_connect_failed(self, client, user_data, flags, rc) -> None: + self.log_error(f'mips connect failed, {flags}, {rc}') + # Try to reconnect + self.__mips_try_reconnect() + + def __on_disconnect(self, client, user_data, rc, props) -> None: + if self._mqtt_state: + self.log_error(f'mips disconnect, {rc}, {props}') + self._mqtt_state = False + if self._mqtt_timer: + self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer = None + if self._mqtt_fd != -1: + self.mev_set_read_handler(self._mqtt_fd, None, None) + self.mev_set_write_handler(self._mqtt_fd, None, None) + self._mqtt_fd = -1 + # Clear retry sub + if self._mips_sub_pending_timer: + self.mev_clear_timeout(self._mips_sub_pending_timer) + self._mips_sub_pending_timer = None + self._mips_sub_pending_map = {} + if self._on_mips_disconnect: + self.mev_set_timeout( + timeout_ms=0, + handler=lambda ctx: + self._on_mips_disconnect(rc, props)) + # Call state sub handler + for item in self._mips_state_sub_map.values(): + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + item.handler(item.key, False)) + + # Try to reconnect + self.__mips_try_reconnect() + # Set event + self._event_disconnect.set() + self._event_connect.clear() + + def __on_message(self, client, user_data, msg) -> None: + self._on_mips_message(topic=msg.topic, payload=msg.payload) + + def __mips_try_reconnect(self, immediately: bool = False) -> None: + if self._mips_reconnect_timer: + self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer = None + if not self._mips_reconnect_tag: + return + interval: int = 0 + if not immediately: + interval = self.__get_next_reconnect_time() + self.log_error( + 'mips try reconnect after %sms', interval) + self._mips_reconnect_timer = self.mev_set_timeout( + interval, self.__mips_connect, None) + + def __mips_sub_internal_pending_handler(self, ctx: any) -> None: + subbed_count = 1 + for topic in list(self._mips_sub_pending_map.keys()): + if subbed_count > self.MIPS_SUB_PATCH: + break + count = self._mips_sub_pending_map[topic] + if count > 3: + self._mips_sub_pending_map.pop(topic) + self.log_error(f'retry mips sub internal error, {topic}') + continue + subbed_count += 1 + result, mid = self._mqtt.subscribe(topic, qos=self.MIPS_QOS) + if result == MQTT_ERR_SUCCESS: + self._mips_sub_pending_map.pop(topic) + self.log_debug(f'mips sub internal success, {topic}') + continue + self._mips_sub_pending_map[topic] = count+1 + self.log_error( + f'retry mips sub internal, {count}, {topic}, {result}, {mid}') + + if len(self._mips_sub_pending_map): + self._mips_sub_pending_timer = self.mev_set_timeout( + self.MIPS_SUB_INTERVAL, + self.__mips_sub_internal_pending_handler, None) + else: + self._mips_sub_pending_timer = None + + def __mips_connect(self, ctx: any = None) -> None: + result = MQTT_ERR_UNKNOWN + if self._mips_reconnect_timer: + self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer = None + try: + # Try clean mqtt fd before mqtt connect + if self._mqtt_timer: + self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer = None + if self._mqtt_fd != -1: + self.mev_set_read_handler(self._mqtt_fd, None, None) + self.mev_set_write_handler(self._mqtt_fd, None, None) + self._mqtt_fd = -1 + result = self._mqtt.connect( + host=self._host, port=self._port, + clean_start=True, keepalive=MIHOME_MQTT_KEEPALIVE) + self.log_info(f'__mips_connect success, {result}') + except (TimeoutError, OSError) as error: + self.log_error('__mips_connect, connect error, %s', error) + + if result == MQTT_ERR_SUCCESS: + self._mqtt_fd = self._mqtt.socket() + self.log_debug(f'__mips_connect, _mqtt_fd, {self._mqtt_fd}') + self.mev_set_read_handler( + self._mqtt_fd, self.__mqtt_read_handler, None) + if self._mqtt.want_write(): + self.mev_set_write_handler( + self._mqtt_fd, self.__mqtt_write_handler, None) + self._mqtt_timer = self.mev_set_timeout( + self.MQTT_INTERVAL_MS, self.__mqtt_timer_handler, None) + else: + self.log_error(f'__mips_connect error result, {result}') + self.__mips_try_reconnect() + + def __mips_disconnect(self) -> None: + if self._mips_reconnect_timer: + self.mev_clear_timeout(self._mips_reconnect_timer) + self._mips_reconnect_timer = None + if self._mqtt_timer: + self.mev_clear_timeout(self._mqtt_timer) + self._mqtt_timer = None + if self._mqtt_fd != -1: + self.mev_set_read_handler(self._mqtt_fd, None, None) + self.mev_set_write_handler(self._mqtt_fd, None, None) + self._mqtt_fd = -1 + self._mqtt.disconnect() + + def __get_next_reconnect_time(self) -> int: + if self._mips_reconnect_interval == 0: + self._mips_reconnect_interval = self.MIPS_RECONNECT_INTERVAL_MIN + else: + self._mips_reconnect_interval = min( + self._mips_reconnect_interval*2, + self.MIPS_RECONNECT_INTERVAL_MAX) + return self._mips_reconnect_interval + + +class MipsCloudClient(MipsClient): + """MIoT Pub/Sub Cloud Client.""" + # pylint: disable=unused-argument + _msg_matcher: MIoTMatcher + + def __init__( + self, uuid: str, cloud_server: str, app_id: str, + token: str, port: int = 8883, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._msg_matcher = MIoTMatcher() + super().__init__( + client_id=f'ha.{uuid}', host=f'{cloud_server}-ha.mqtt.io.mi.com', + port=port, username=app_id, password=token, loop=loop) + + self.on_mips_cmd = self.__on_mips_cmd_handler + self.on_mips_message = self.__on_mips_message_handler + self.on_mips_connect = self.__on_mips_connect_handler + self.on_mips_disconnect = self.__on_mips_disconnect_handler + + def deinit(self) -> None: + self.mips_deinit() + self._msg_matcher = None + self.on_mips_cmd = None + self.on_mips_message = None + self.on_mips_connect = None + + @final + def connect(self) -> None: + self.mips_connect() + + @final + async def connect_async(self) -> None: + await self.mips_connect_async() + + @final + def disconnect(self) -> None: + self.mips_disconnect() + self._msg_matcher = MIoTMatcher() + + @final + async def disconnect_async(self) -> None: + await self.mips_disconnect_async() + self._msg_matcher = MIoTMatcher() + + def update_access_token(self, access_token: str) -> bool: + if not isinstance(access_token, str): + raise MIoTMipsError('invalid token') + return self.update_mqtt_password(password=access_token) + + @final + def sub_prop( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, piid: int = None, handler_ctx: any = None + ) -> bool: + if not isinstance(did, str) or handler is None: + raise MIoTMipsError('invalid params') + + topic: str = ( + f'device/{did}/up/properties_changed/' + f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') + + def on_prop_msg(topic: str, payload: str, ctx: any) -> bool: + try: + msg: dict = json.loads(payload) + except json.JSONDecodeError: + self.log_error( + f'on_prop_msg, invalid msg, {topic}, {payload}') + return + if ( + not isinstance(msg.get('params', None), dict) + or 'siid' not in msg['params'] + or 'piid' not in msg['params'] + or 'value' not in msg['params'] + ): + self.log_error( + f'on_prop_msg, invalid msg, {topic}, {payload}') + return + if handler: + self.log_debug('on properties_changed, %s', payload) + handler(msg['params'], ctx) + return self.__reg_broadcast( + topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) + + @final + def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + if not isinstance(did, str): + raise MIoTMipsError('invalid params') + topic: str = ( + f'device/{did}/up/properties_changed/' + f'{"#" if siid is None or piid is None else f"{siid}/{piid}"}') + return self.__unreg_broadcast(topic=topic) + + @final + def sub_event( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, eiid: int = None, handler_ctx: any = None + ) -> bool: + if not isinstance(did, str) or handler is None: + raise MIoTMipsError('invalid params') + # Spelling error: event_occured + topic: str = ( + f'device/{did}/up/event_occured/' + f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') + + def on_event_msg(topic: str, payload: str, ctx: any) -> bool: + try: + msg: dict = json.loads(payload) + except json.JSONDecodeError: + self.log_error( + f'on_event_msg, invalid msg, {topic}, {payload}') + return + if ( + not isinstance(msg.get('params', None), dict) + or 'siid' not in msg['params'] + or 'eiid' not in msg['params'] + or 'arguments' not in msg['params'] + ): + self.log_error( + f'on_event_msg, invalid msg, {topic}, {payload}') + return + if handler: + self.log_debug('on on_event_msg, %s', payload) + msg['params']['from'] = 'cloud' + handler(msg['params'], ctx) + return self.__reg_broadcast( + topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) + + @final + def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + if not isinstance(did, str): + raise MIoTMipsError('invalid params') + # Spelling error: event_occured + topic: str = ( + f'device/{did}/up/event_occured/' + f'{"#" if siid is None or eiid is None else f"{siid}/{eiid}"}') + return self.__unreg_broadcast(topic=topic) + + @final + def sub_device_state( + self, did: str, handler: Callable[[str, MIoTDeviceState, any], None], + handler_ctx: any = None + ) -> bool: + """subscribe online state.""" + if not isinstance(did, str) or handler is None: + raise MIoTMipsError('invalid params') + topic: str = f'device/{did}/state/#' + + def on_state_msg(topic: str, payload: str, ctx: any) -> None: + msg: dict = json.loads(payload) + # {"device_id":"xxxx","device_name":"米家智能插座3 ","event":"online", + # "model": "cuco.plug.v3","timestamp":1709001070828,"uid":xxxx} + if msg is None or 'device_id' not in msg or 'event' not in msg: + self.log_error(f'on_state_msg, recv unknown msg, {payload}') + return + if msg['device_id'] != did: + self.log_error( + f'on_state_msg, err msg, {did}!={msg["device_id"]}') + return + if handler: + self.log_debug('cloud, device state changed, %s', payload) + handler( + did, MIoTDeviceState.ONLINE if msg['event'] == 'online' + else MIoTDeviceState.OFFLINE, ctx) + return self.__reg_broadcast( + topic=topic, handler=on_state_msg, handler_ctx=handler_ctx) + + @final + def unsub_device_state(self, did: str) -> bool: + if not isinstance(did, str): + raise MIoTMipsError('invalid params') + topic: str = f'device/{did}/state/#' + return self.__unreg_broadcast(topic=topic) + + async def get_dev_list_async( + self, payload: str = None, timeout_ms: int = 10000 + ) -> dict[str, dict]: + raise NotImplementedError('please call in http client') + + async def get_prop_async( + self, did: str, siid: int, piid: int, timeout_ms: int = 10000 + ) -> any: + raise NotImplementedError('please call in http client') + + async def set_prop_async( + self, did: str, siid: int, piid: int, value: any, + timeout_ms: int = 10000 + ) -> bool: + raise NotImplementedError('please call in http client') + + async def action_async( + self, did: str, siid: int, aiid: int, in_list: list, + timeout_ms: int = 10000 + ) -> tuple[bool, list]: + raise NotImplementedError('please call in http client') + + def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: + """ + NOTICE thread safe, this function will be called at the **mips** thread + """ + if mips_cmd.type_ == MipsCmdType.REG_BROADCAST: + reg_bc: MipsRegBroadcast = mips_cmd.data + if not self._msg_matcher.get(topic=reg_bc.topic): + sub_bc: MipsBroadcast = MipsBroadcast( + topic=reg_bc.topic, handler=reg_bc.handler, + handler_ctx=reg_bc.handler_ctx) + self._msg_matcher[reg_bc.topic] = sub_bc + self._mips_sub_internal(topic=reg_bc.topic) + else: + self.log_debug(f'mips cloud re-reg broadcast, {reg_bc.topic}') + elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: + unreg_bc: MipsRegBroadcast = mips_cmd.data + if self._msg_matcher.get(topic=unreg_bc.topic): + del self._msg_matcher[unreg_bc.topic] + self._mips_unsub_internal(topic=unreg_bc.topic) + + def __reg_broadcast( + self, topic: str, handler: Callable[[str, str, any], None], + handler_ctx: any = None + ) -> bool: + return self._mips_send_cmd( + type_=MipsCmdType.REG_BROADCAST, + data=MipsRegBroadcast( + topic=topic, handler=handler, handler_ctx=handler_ctx)) + + def __unreg_broadcast(self, topic: str) -> bool: + return self._mips_send_cmd( + type_=MipsCmdType.UNREG_BROADCAST, + data=MipsRegBroadcast(topic=topic)) + + def __on_mips_connect_handler(self, rc, props) -> None: + """sub topic.""" + for topic, _ in list( + self._msg_matcher.iter_all_nodes()): + self._mips_sub_internal(topic=topic) + + def __on_mips_disconnect_handler(self, rc, props) -> None: + """unsub topic.""" + pass + + def __on_mips_message_handler(self, topic: str, payload) -> None: + """ + NOTICE thread safe, this function will be called at the **mips** thread + """ + # broadcast + bc_list: list[MipsBroadcast] = list( + self._msg_matcher.iter_match(topic)) + if not bc_list: + return + # self.log_debug(f"on broadcast, {topic}, {payload}") + for item in bc_list or []: + if item.handler is None: + continue + # NOTICE: call threadsafe + self.main_loop.call_soon_threadsafe( + item.handler, topic, payload, item.handler_ctx) + + +class MipsLocalClient(MipsClient): + """MIoT Pub/Sub Local Client.""" + # pylint: disable=unused-argument + MIPS_RECONNECT_INTERVAL_MIN: int = 6000 + MIPS_RECONNECT_INTERVAL_MAX: int = 60000 + MIPS_SUB_PATCH: int = 1000 + MIPS_SUB_INTERVAL: int = 100 + _did: str + _group_id: str + _home_name: str + _mips_seed_id: int + _reply_topic: str + _dev_list_change_topic: str + _request_map: dict[str, MipsRequest] + _msg_matcher: MIoTMatcher + _device_state_sub_map: dict[str, MipsDeviceState] + _get_prop_queue: dict[str, list] + _get_prop_timer: asyncio.TimerHandle + _on_dev_list_changed: Callable[[any, list[str]], asyncio.Future] + + def __init__( + self, did: str, host: str, group_id: str, + ca_file: str, cert_file: str, key_file: str, + port: int = 8883, home_name: str = '', + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._did = did + self._group_id = group_id + self._home_name = home_name + self._mips_seed_id = random.randint(0, self.UINT32_MAX) + self._reply_topic = f'{did}/reply' + self._dev_list_change_topic = f'{did}/appMsg/devListChange' + self._request_map = {} + self._msg_matcher = MIoTMatcher() + self._device_state_sub_map = {} + self._get_prop_queue = {} + self._get_prop_timer = None + self._on_dev_list_changed = None + + super().__init__( + client_id=did, host=host, port=port, + ca_file=ca_file, cert_file=cert_file, key_file=key_file, loop=loop) + # MIPS local thread name use group_id + self._mips_thread.name = self._group_id + + self.on_mips_cmd = self.__on_mips_cmd_handler + self.on_mips_message = self.__on_mips_message_handler + self.on_mips_connect = self.__on_mips_connect_handler + + @property + def group_id(self) -> str: + return self._group_id + + def deinit(self) -> None: + self.mips_deinit() + self._did = None + self._mips_seed_id = None + self._reply_topic = None + self._dev_list_change_topic = None + self._request_map = None + self._msg_matcher = None + self._device_state_sub_map = None + self._get_prop_queue = None + self._get_prop_timer = None + self._on_dev_list_changed = None + + self.on_mips_cmd = None + self.on_mips_message = None + self.on_mips_connect = None + + def log_debug(self, msg, *args, **kwargs) -> None: + if self._logger: + self._logger.debug(f'{self._home_name}, '+msg, *args, **kwargs) + + def log_info(self, msg, *args, **kwargs) -> None: + if self._logger: + self._logger.info(f'{self._home_name}, '+msg, *args, **kwargs) + + def log_error(self, msg, *args, **kwargs) -> None: + if self._logger: + self._logger.error(f'{self._home_name}, '+msg, *args, **kwargs) + + @final + def connect(self) -> None: + self.mips_connect() + + @final + async def connect_async(self) -> None: + await self.mips_connect_async() + + @final + def disconnect(self) -> None: + self.mips_disconnect() + self._request_map = {} + self._msg_matcher = MIoTMatcher() + self._device_state_sub_map = {} + + @final + async def disconnect_async(self) -> None: + await self.mips_disconnect_async() + self._request_map = {} + self._msg_matcher = MIoTMatcher() + self._device_state_sub_map = {} + + @final + def sub_prop( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, piid: int = None, handler_ctx: any = None + ) -> bool: + topic: str = ( + f'appMsg/notify/iot/{did}/property/' + f'{"#" if siid is None or piid is None else f"{siid}.{piid}"}') + + def on_prop_msg(topic: str, payload: str, ctx: any): + msg: dict = json.loads(payload) + if ( + msg is None + or 'did' not in msg + or 'siid' not in msg + or 'piid' not in msg + or 'value' not in msg + ): + # self.log_error(f'on_prop_msg, recv unknown msg, {payload}') + return + if handler: + self.log_debug('local, on properties_changed, %s', payload) + handler(msg, ctx) + return self.__reg_broadcast( + topic=topic, handler=on_prop_msg, handler_ctx=handler_ctx) + + @final + def unsub_prop(self, did: str, siid: int = None, piid: int = None) -> bool: + topic: str = ( + f'appMsg/notify/iot/{did}/property/' + f'{"#" if siid is None or piid is None else f"{siid}.{piid}"}') + return self.__unreg_broadcast(topic=topic) + + @final + def sub_event( + self, did: str, handler: Callable[[dict, any], None], + siid: int = None, eiid: int = None, handler_ctx: any = None + ) -> bool: + topic: str = ( + f'appMsg/notify/iot/{did}/event/' + f'{"#" if siid is None or eiid is None else f"{siid}.{eiid}"}') + + def on_event_msg(topic: str, payload: str, ctx: any): + msg: dict = json.loads(payload) + if ( + msg is None + or 'did' not in msg + or 'siid' not in msg + or 'eiid' not in msg + or 'arguments' not in msg + ): + # self.log_error(f'on_event_msg, recv unknown msg, {payload}') + return + if handler: + self.log_debug('local, on event_occurred, %s', payload) + handler(msg, ctx) + return self.__reg_broadcast( + topic=topic, handler=on_event_msg, handler_ctx=handler_ctx) + + @final + def unsub_event(self, did: str, siid: int = None, eiid: int = None) -> bool: + topic: str = ( + f'appMsg/notify/iot/{did}/event/' + f'{"#" if siid is None or eiid is None else f"{siid}.{eiid}"}') + return self.__unreg_broadcast(topic=topic) + + @final + async def get_prop_safe_async( + self, did: str, siid: int, piid: int, timeout_ms: int = 10000 + ) -> any: + self._get_prop_queue.setdefault(did, []) + fut: asyncio.Future = self.main_loop.create_future() + self._get_prop_queue[did].append({ + 'param': json.dumps({ + 'did': did, + 'siid': siid, + 'piid': piid + }), + 'fut': fut, + 'timeout_ms': timeout_ms + }) + if self._get_prop_timer is None: + self._get_prop_timer = self.main_loop.create_task( + self.__get_prop_timer_handle()) + return await fut + + @final + async def get_prop_async( + self, did: str, siid: int, piid: int, timeout_ms: int = 10000 + ) -> any: + result_obj = await self.__request_async( + topic='proxy/get', + payload=json.dumps({ + 'did': did, + 'siid': siid, + 'piid': piid + }), + timeout_ms=timeout_ms) + if not isinstance(result_obj, dict) or 'value' not in result_obj: + return None + return result_obj['value'] + + @final + async def set_prop_async( + self, did: str, siid: int, piid: int, value: any, + timeout_ms: int = 10000 + ) -> dict: + payload_obj: dict = { + 'did': did, + 'rpc': { + 'id': self.__gen_mips_id, + 'method': 'set_properties', + 'params': [{ + 'did': did, + 'siid': siid, + 'piid': piid, + 'value': value + }] + } + } + result_obj = await self.__request_async( + topic='proxy/rpcReq', + payload=json.dumps(payload_obj), + timeout_ms=timeout_ms) + if result_obj: + if ( + 'result' in result_obj + and len(result_obj['result']) == 1 + and 'did' in result_obj['result'][0] + and result_obj['result'][0]['did'] == did + and 'code' in result_obj['result'][0] + ): + return result_obj['result'][0] + if 'error' in result_obj: + return result_obj['error'] + return { + 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, + 'message': 'Invalid result'} + + @final + async def action_async( + self, did: str, siid: int, aiid: int, in_list: list, + timeout_ms: int = 10000 + ) -> dict: + payload_obj: dict = { + 'did': did, + 'rpc': { + 'id': self.__gen_mips_id, + 'method': 'action', + 'params': { + 'did': did, + 'siid': siid, + 'aiid': aiid, + 'in': in_list + } + } + } + result_obj = await self.__request_async( + topic='proxy/rpcReq', payload=json.dumps(payload_obj), + timeout_ms=timeout_ms) + if result_obj: + if 'result' in result_obj and 'code' in result_obj['result']: + return result_obj['result'] + if 'error' in result_obj: + return result_obj['error'] + return { + 'code': MIoTErrorCode.CODE_INTERNAL_ERROR.value, + 'message': 'Invalid result'} + + @final + async def get_dev_list_async( + self, payload: str = None, timeout_ms: int = 10000 + ) -> dict[str, dict]: + result_obj = await self.__request_async( + topic='proxy/getDevList', payload=payload or '{}', + timeout_ms=timeout_ms) + if not result_obj or 'devList' not in result_obj: + return None + device_list = {} + for did, info in result_obj['devList'].items(): + name: str = info.get('name', None) + urn: str = info.get('urn', None) + model: str = info.get('model', None) + if name is None or urn is None or model is None: + self.log_error(f'invalid device info, {did}, {info}') + continue + device_list[did] = { + 'did': did, + 'name': name, + 'urn': urn, + 'model': model, + 'online': info.get('online', False), + 'icon': info.get('icon', None), + 'fw_version': None, + 'home_id': '', + 'home_name': '', + 'room_id': info.get('roomId', ''), + 'room_name': info.get('roomName', ''), + 'specv2_access': info.get('specV2Access', False), + 'push_available': info.get('pushAvailable', False), + 'manufacturer': model.split('.')[0], + } + return device_list + + @final + async def get_action_group_list_async( + self, timeout_ms: int = 10000 + ) -> list[str]: + result_obj = await self.__request_async( + topic='proxy/getMijiaActionGroupList', + payload='{}', + timeout_ms=timeout_ms) + if not result_obj or 'result' not in result_obj: + return None + return result_obj['result'] + + @final + async def exec_action_group_list_async( + self, ag_id: str, timeout_ms: int = 10000 + ) -> dict: + result_obj = await self.__request_async( + topic='proxy/execMijiaActionGroup', + payload=f'{{"id":"{ag_id}"}}', + timeout_ms=timeout_ms) + if result_obj: + if 'result' in result_obj: + return result_obj['result'] + if 'error' in result_obj: + return result_obj['error'] + return { + 'code': MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value, + 'message': 'invalid result'} + + @final + @property + def on_dev_list_changed(self) -> Callable[[any, list[str]], asyncio.Future]: + return self._on_dev_list_changed + + @final + @on_dev_list_changed.setter + def on_dev_list_changed( + self, func: Callable[[any, list[str]], asyncio.Future] + ) -> None: + """run in main loop.""" + self._on_dev_list_changed = func + + @final + def __on_mips_cmd_handler(self, mips_cmd: MipsCmd) -> None: + if mips_cmd.type_ == MipsCmdType.CALL_API: + req_data: MipsRequestData = mips_cmd.data + req = MipsRequest() + req.mid = self.__gen_mips_id + req.on_reply = req_data.on_reply + req.on_reply_ctx = req_data.on_reply_ctx + pub_topic: str = f'master/{req_data.topic}' + result = self.__mips_publish( + topic=pub_topic, payload=req_data.payload, mid=req.mid, + ret_topic=self._reply_topic) + self.log_debug( + f'mips local call api, {result}, {req.mid}, {pub_topic}, ' + f'{req_data.payload}') + + def on_request_timeout(req: MipsRequest): + self.log_error( + f'on mips request timeout, {req.mid}, {pub_topic}' + f', {req_data.payload}') + self._request_map.pop(str(req.mid), None) + req.on_reply( + '{"error":{"code":-10006, "message":"timeout"}}', + req.on_reply_ctx) + req.timer = self.mev_set_timeout( + req_data.timeout_ms, on_request_timeout, req) + self._request_map[str(req.mid)] = req + elif mips_cmd.type_ == MipsCmdType.REG_BROADCAST: + reg_bc: MipsRegBroadcast = mips_cmd.data + sub_topic: str = f'{self._did}/{reg_bc.topic}' + if not self._msg_matcher.get(sub_topic): + sub_bc: MipsBroadcast = MipsBroadcast( + topic=sub_topic, handler=reg_bc.handler, + handler_ctx=reg_bc.handler_ctx) + self._msg_matcher[sub_topic] = sub_bc + self._mips_sub_internal(topic=f'master/{reg_bc.topic}') + else: + self.log_debug(f'mips re-reg broadcast, {sub_topic}') + elif mips_cmd.type_ == MipsCmdType.UNREG_BROADCAST: + unreg_bc: MipsRegBroadcast = mips_cmd.data + # Central hub gateway needs to add prefix + unsub_topic: str = f'{self._did}/{unreg_bc.topic}' + if self._msg_matcher.get(unsub_topic): + del self._msg_matcher[unsub_topic] + self._mips_unsub_internal( + topic=re.sub(f'^{self._did}', 'master', unsub_topic)) + elif mips_cmd.type_ == MipsCmdType.REG_DEVICE_STATE: + reg_dev_state: MipsRegDeviceState = mips_cmd.data + self._device_state_sub_map[reg_dev_state.did] = reg_dev_state + self.log_debug( + f'mips local reg device state, {reg_dev_state.did}') + elif mips_cmd.type_ == MipsCmdType.UNREG_DEVICE_STATE: + unreg_dev_state: MipsRegDeviceState = mips_cmd.data + del self._device_state_sub_map[unreg_dev_state.did] + self.log_debug( + f'mips local unreg device state, {unreg_dev_state.did}') + else: + self.log_error( + f'mips local recv unknown cmd, {mips_cmd.type_}, ' + f'{mips_cmd.data}') + + def __on_mips_connect_handler(self, rc, props) -> None: + self.log_debug('__on_mips_connect_handler') + # Sub did/#, include reply topic + self._mips_sub_internal(f'{self._did}/#') + # Sub device list change + self._mips_sub_internal('master/appMsg/devListChange') + # Do not need to subscribe api topics, for they are covered by did/# + # Sub api topic. + # Sub broadcast topic + for topic, _ in list(self._msg_matcher.iter_all_nodes()): + self._mips_sub_internal( + topic=re.sub(f'^{self._did}', 'master', topic)) + + @final + def __on_mips_message_handler(self, topic: str, payload: bytes) -> None: + mips_msg: MipsMessage = MipsMessage.unpack(payload) + # self.log_debug( + # f"mips local client, on_message, {topic} -> {mips_msg}") + # Reply + if topic == self._reply_topic: + self.log_debug(f'on request reply, {mips_msg}') + req: MipsRequest = self._request_map.pop(str(mips_msg.mid), None) + if req: + # Cancel timer + self.mev_clear_timeout(req.timer) + if req.on_reply: + self.main_loop.call_soon_threadsafe( + req.on_reply, mips_msg.payload or '{}', + req.on_reply_ctx) + return + # Broadcast + bc_list: list[MipsBroadcast] = list(self._msg_matcher.iter_match( + topic=topic)) + if bc_list: + self.log_debug(f'on broadcast, {topic}, {mips_msg}') + for item in bc_list or []: + if item.handler is None: + continue + self.main_loop.call_soon_threadsafe( + item.handler, topic[topic.find('/')+1:], + mips_msg.payload or '{}', item.handler_ctx) + return + # Device list change + if topic == self._dev_list_change_topic: + payload_obj: dict = json.loads(mips_msg.payload) + dev_list = payload_obj.get('devList', None) + if not isinstance(dev_list, list) or not dev_list: + _LOGGER.error( + 'unknown devListChange msg, %s', mips_msg.payload) + return + if self._on_dev_list_changed: + self.main_loop.call_soon_threadsafe( + self.main_loop.create_task, + self._on_dev_list_changed(self, payload_obj['devList'])) + return + + self.log_debug( + f'mips local client, recv unknown msg, {topic} -> {mips_msg}') + + @property + def __gen_mips_id(self) -> int: + mips_id: int = self._mips_seed_id + self._mips_seed_id = int((self._mips_seed_id+1) % self.UINT32_MAX) + return mips_id + + def __mips_publish( + self, topic: str, payload: str | bytes, mid: int = None, + ret_topic: str = None, wait_for_publish: bool = False, + timeout_ms: int = 10000 + ) -> bool: + mips_msg: bytes = MipsMessage.pack( + mid=mid or self.__gen_mips_id, payload=payload, + msg_from='local', ret_topic=ret_topic) + return self._mips_publish_internal( + topic=topic.strip(), payload=mips_msg, + wait_for_publish=wait_for_publish, timeout_ms=timeout_ms) + + def __request( + self, topic: str, payload: str, + on_reply: Callable[[str, any], None], + on_reply_ctx: any = None, timeout_ms: int = 10000 + ) -> bool: + if topic is None or payload is None or on_reply is None: + raise MIoTMipsError('invalid params') + req_data: MipsRequestData = MipsRequestData() + req_data.topic = topic + req_data.payload = payload + req_data.on_reply = on_reply + req_data.on_reply_ctx = on_reply_ctx + req_data.timeout_ms = timeout_ms + return self._mips_send_cmd(type_=MipsCmdType.CALL_API, data=req_data) + + def __reg_broadcast( + self, topic: str, handler: Callable[[str, str, any], None], + handler_ctx: any + ) -> bool: + return self._mips_send_cmd( + type_=MipsCmdType.REG_BROADCAST, + data=MipsRegBroadcast( + topic=topic, handler=handler, handler_ctx=handler_ctx)) + + def __unreg_broadcast(self, topic) -> bool: + return self._mips_send_cmd( + type_=MipsCmdType.UNREG_BROADCAST, + data=MipsRegBroadcast(topic=topic)) + + @final + async def __request_async( + self, topic: str, payload: str, timeout_ms: int = 10000 + ) -> dict: + fut_handler: asyncio.Future = self.main_loop.create_future() + + def on_msg_reply(payload: str, ctx: any): + fut: asyncio.Future = ctx + if fut: + self.main_loop.call_soon_threadsafe(fut.set_result, payload) + if not self.__request( + topic=topic, + payload=payload, + on_reply=on_msg_reply, + on_reply_ctx=fut_handler, + timeout_ms=timeout_ms): + # Request error + fut_handler.set_result('internal request error') + + result = await fut_handler + try: + return json.loads(result) + except json.JSONDecodeError: + return { + 'code': MIoTErrorCode.CODE_MIPS_INVALID_RESULT.value, + 'message': f'Error: {result}'} + + async def __get_prop_timer_handle(self) -> None: + for did in list(self._get_prop_queue.keys()): + item = self._get_prop_queue[did].pop() + _LOGGER.debug('get prop, %s, %s', did, item) + result_obj = await self.__request_async( + topic='proxy/get', + payload=item['param'], + timeout_ms=item['timeout_ms']) + if result_obj is None or 'value' not in result_obj: + item['fut'].set_result(None) + else: + item['fut'].set_result(result_obj['value']) + + if not self._get_prop_queue[did]: + self._get_prop_queue.pop(did, None) + + if self._get_prop_queue: + self._get_prop_timer = self.main_loop.call_later( + 0.1, lambda: self.main_loop.create_task( + self.__get_prop_timer_handle())) + else: + self._get_prop_timer = None diff --git a/custom_components/xiaomi_home/miot/miot_network.py b/custom_components/xiaomi_home/miot/miot_network.py new file mode 100644 index 0000000..a4606eb --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_network.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT network utilities. +""" +import asyncio +import logging +import platform +import socket +from dataclasses import dataclass +from enum import Enum, auto +import subprocess +from typing import Callable, Optional +import psutil +import ipaddress + +_LOGGER = logging.getLogger(__name__) + + +class InterfaceStatus(Enum): + """Interface status.""" + ADD = 0 + UPDATE = auto() + REMOVE = auto() + + +@dataclass +class NetworkInfo: + """Network information.""" + name: str + ip: str + netmask: str + net_seg: str + + +class MIoTNetwork: + """MIoT network utilities.""" + PING_ADDRESS_LIST = [ + '1.2.4.8', # CNNIC sDNS + '8.8.8.8', # Google Public DNS + '233.5.5.5', # AliDNS + '1.1.1.1', # Cloudflare DNS + '114.114.114.114', # 114 DNS + '208.67.222.222', # OpenDNS + '9.9.9.9', # Quad9 DNS + ] + _main_loop: asyncio.AbstractEventLoop + + _refresh_interval: int + _refresh_task: asyncio.Task + _refresh_timer: asyncio.TimerHandle + + _network_status: bool + _network_info: dict[str, NetworkInfo] + + _sub_list_network_status: dict[str, Callable[[bool], asyncio.Future]] + _sub_list_network_info: dict[str, Callable[[ + InterfaceStatus, NetworkInfo], asyncio.Future]] + + _ping_address_priority: int + + _done_event: asyncio.Event + + def __init__( + self, loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_running_loop() + + self._refresh_interval = None + self._refresh_task = None + self._refresh_timer = None + + self._network_status = False + self._network_info = {} + + self._sub_list_network_status = {} + self._sub_list_network_info = {} + + self._ping_address_priority = 0 + + self._done_event = asyncio.Event() + + @property + def network_status(self) -> bool: + return self._network_status + + @property + def network_info(self) -> dict[str, NetworkInfo]: + return self._network_info + + async def deinit_async(self) -> None: + if self._refresh_task: + self._refresh_task.cancel() + self._refresh_task = None + if self._refresh_timer: + self._refresh_timer.cancel() + self._refresh_timer = None + + self._refresh_interval = None + self._network_status = False + self._network_info.clear() + self._sub_list_network_status.clear() + self._sub_list_network_info.clear() + self._done_event.clear() + + def sub_network_status( + self, key: str, handler: Callable[[bool], asyncio.Future] + ) -> None: + self._sub_list_network_status[key] = handler + + def unsub_network_status(self, key: str) -> None: + self._sub_list_network_status.pop(key, None) + + def sub_network_info( + self, key: str, + handler: Callable[[InterfaceStatus, NetworkInfo], asyncio.Future] + ) -> None: + self._sub_list_network_info[key] = handler + + def unsub_network_info(self, key: str) -> None: + self._sub_list_network_info.pop(key, None) + + async def init_async(self, refresh_interval: int = 30) -> bool: + self._refresh_interval = refresh_interval + self.__refresh_timer_handler() + # MUST get network info before starting + return await self._done_event.wait() + + async def refresh_async(self) -> None: + self.__refresh_timer_handler() + + async def get_network_status_async(self, timeout: int = 6) -> bool: + return await self._main_loop.run_in_executor( + None, self.__get_network_status, False, timeout) + + async def get_network_info_async(self) -> dict[str, NetworkInfo]: + return await self._main_loop.run_in_executor( + None, self.__get_network_info) + + def __calc_network_address(self, ip: str, netmask: str) -> str: + return str(ipaddress.IPv4Network( + f'{ip}/{netmask}', strict=False).network_address) + + def __ping( + self, address: Optional[str] = None, timeout: int = 6 + ) -> bool: + param = '-n' if platform.system().lower() == 'windows' else '-c' + command = ['ping', param, '1', address] + try: + output = subprocess.run( + command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, timeout=timeout) + return output.returncode == 0 + except Exception: # pylint: disable=broad-exception-caught + return False + + def __get_network_status( + self, with_retry: bool = True, timeout: int = 6 + ) -> bool: + if self._ping_address_priority >= len(self.PING_ADDRESS_LIST): + self._ping_address_priority = 0 + + if self.__ping( + self.PING_ADDRESS_LIST[self._ping_address_priority], timeout): + return True + if not with_retry: + return False + for index in range(len(self.PING_ADDRESS_LIST)): + if index == self._ping_address_priority: + continue + if self.__ping(self.PING_ADDRESS_LIST[index], timeout): + self._ping_address_priority = index + return True + return False + + def __get_network_info(self) -> dict[str, NetworkInfo]: + interfaces = psutil.net_if_addrs() + results: dict[str, NetworkInfo] = {} + for name, addresses in interfaces.items(): + # Skip hassio and docker* interface + if name == 'hassio' or name.startswith('docker'): + continue + for address in addresses: + if ( + address.family != socket.AF_INET + or not address.address + or not address.netmask + ): + continue + # skip lo interface + if address.address == '127.0.0.1': + continue + results[name] = NetworkInfo( + name=name, + ip=address.address, + netmask=address.netmask, + net_seg=self.__calc_network_address( + address.address, address.netmask)) + return results + + def __call_network_info_change( + self, status: InterfaceStatus, info: NetworkInfo + ) -> None: + for handler in self._sub_list_network_info.values(): + self._main_loop.create_task(handler(status, info)) + + async def __update_status_and_info_async(self, timeout: int = 6) -> None: + try: + status: bool = await self._main_loop.run_in_executor( + None, self.__get_network_status, timeout) + infos = await self._main_loop.run_in_executor( + None, self.__get_network_info) + + if self._network_status != status: + for handler in self._sub_list_network_status.values(): + self._main_loop.create_task(handler(status)) + self._network_status = status + + for name in list(self._network_info.keys()): + info = infos.pop(name, None) + if info: + # Update + if ( + info.ip != self._network_info[name].ip + or info.netmask != self._network_info[name].netmask + ): + self._network_info[name] = info + self.__call_network_info_change( + InterfaceStatus.UPDATE, info) + else: + # Remove + self.__call_network_info_change( + InterfaceStatus.REMOVE, + self._network_info.pop(name, None)) + # Add + for name, info in infos.items(): + self._network_info[name] = info + self.__call_network_info_change(InterfaceStatus.ADD, info) + + if not self._done_event.is_set(): + self._done_event.set() + except asyncio.CancelledError: + _LOGGER.error('update_status_and_info task was cancelled') + + def __refresh_timer_handler(self) -> None: + if self._refresh_timer: + self._refresh_timer.cancel() + self._refresh_timer = None + if self._refresh_task is None or self._refresh_task.done(): + self._refresh_task = self._main_loop.create_task( + self.__update_status_and_info_async()) + self._refresh_timer = self._main_loop.call_later( + self._refresh_interval, self.__refresh_timer_handler) diff --git a/custom_components/xiaomi_home/miot/miot_spec.py b/custom_components/xiaomi_home/miot/miot_spec.py new file mode 100644 index 0000000..d72e0b9 --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_spec.py @@ -0,0 +1,1029 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT-Spec-V2 parser. +""" +import asyncio +import json +import platform +import time +from typing import Optional +from urllib.parse import urlencode +from urllib.request import Request, urlopen +import logging + +from .const import DEFAULT_INTEGRATION_LANGUAGE, SPEC_STD_LIB_EFFECTIVE_TIME +from .miot_error import MIoTSpecError +from .miot_storage import ( + MIoTStorage, + SpecBoolTranslation, + SpecFilter, + SpecMultiLang) + +_LOGGER = logging.getLogger(__name__) + + +class MIoTSpecBase: + """MIoT SPEC base class.""" + iid: int + type_: str + description: str + description_trans: Optional[str] + proprietary: bool + need_filter: bool + name: Optional[str] + + # External params + platform: str + device_class: any + icon: str + external_unit: any + + spec_id: str + + def __init__(self, spec: dict) -> None: + self.iid = spec['iid'] + self.type_ = spec['type'] + self.description = spec['description'] + + self.description_trans = spec.get('description_trans', None) + self.proprietary = spec.get('proprietary', False) + self.need_filter = spec.get('need_filter', False) + self.name = spec.get('name', None) + + self.platform = None + self.device_class = None + self.icon = None + self.external_unit = None + + self.spec_id = hash(f'{self.type_}.{self.iid}') + + def __hash__(self) -> int: + return self.spec_id + + def __eq__(self, value: object) -> bool: + return self.spec_id == value.spec_id + + +class MIoTSpecProperty(MIoTSpecBase): + """MIoT SPEC property class.""" + format_: str + precision: int + unit: str + + value_range: list + value_list: list[dict] + + _access: list + _writable: bool + _readable: bool + _notifiable: bool + + service: MIoTSpecBase + + def __init__( + self, spec: dict, service: MIoTSpecBase = None, + format_: str = None, access: list = None, + unit: str = None, value_range: list = None, + value_list: list[dict] = None, precision: int = 0 + ) -> None: + super().__init__(spec=spec) + self.service = service + self.format_ = format_ + self.access = access + self.unit = unit + self.value_range = value_range + self.value_list = value_list + self.precision = precision + + self.spec_id = hash( + f'p.{self.name}.{self.service.iid}.{self.iid}') + + @property + def access(self) -> list: + return self._access + + @access.setter + def access(self, value: list) -> None: + self._access = value + if isinstance(value, list): + self._writable = 'write' in value + self._readable = 'read' in value + self._notifiable = 'notify' in value + + @property + def writable(self) -> bool: + return self._writable + + @property + def readable(self) -> bool: + return self._readable + + @property + def notifiable(self): + return self._notifiable + + def value_format(self, value: any) -> any: + if value is None: + return None + if self.format_ == 'int': + return int(value) + if self.format_ == 'float': + return round(value, self.precision) + if self.format_ == 'bool': + return bool(value in [True, 1, 'true', '1']) + return value + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'need_filter': self.need_filter, + 'format': self.format_, + 'access': self._access, + 'unit': self.unit, + 'value_range': self.value_range, + 'value_list': self.value_list, + 'precision': self.precision + } + + +class MIoTSpecEvent(MIoTSpecBase): + """MIoT SPEC event class.""" + argument: list[MIoTSpecProperty] + service: MIoTSpecBase + + def __init__( + self, spec: dict, service: MIoTSpecBase = None, + argument: list[MIoTSpecProperty] = None + ) -> None: + super().__init__(spec=spec) + self.argument = argument + self.service = service + + self.spec_id = hash( + f'e.{self.name}.{self.service.iid}.{self.iid}') + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'need_filter': self.need_filter, + 'argument': [prop.iid for prop in self.argument], + } + + +class MIoTSpecAction(MIoTSpecBase): + """MIoT SPEC action class.""" + in_: list[MIoTSpecProperty] + out: list[MIoTSpecProperty] + service: MIoTSpecBase + + def __init__( + self, spec: dict, service: MIoTSpecBase = None, + in_: list[MIoTSpecProperty] = None, + out: list[MIoTSpecProperty] = None + ) -> None: + super().__init__(spec=spec) + self.in_ = in_ + self.out = out + self.service = service + + self.spec_id = hash( + f'a.{self.name}.{self.service.iid}.{self.iid}') + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'need_filter': self.need_filter, + 'in': [prop.iid for prop in self.in_], + 'out': [prop.iid for prop in self.out] + } + + +class MIoTSpecService(MIoTSpecBase): + """MIoT SPEC service class.""" + properties: list[MIoTSpecProperty] + events: list[MIoTSpecEvent] + actions: list[MIoTSpecAction] + + def __init__(self, spec: dict) -> None: + super().__init__(spec=spec) + self.properties = [] + self.events = [] + self.actions = [] + + def dump(self) -> dict: + return { + 'type': self.type_, + 'name': self.name, + 'iid': self.iid, + 'description': self.description, + 'description_trans': self.description_trans, + 'proprietary': self.proprietary, + 'properties': [prop.dump() for prop in self.properties], + 'need_filter': self.need_filter, + 'events': [event.dump() for event in self.events], + 'actions': [action.dump() for action in self.actions], + } + + +# @dataclass +class MIoTSpecInstance: + """MIoT SPEC instance class.""" + urn: str + name: str + # urn_name: str + description: str + description_trans: str + services: list[MIoTSpecService] + + # External params + platform: str + device_class: any + icon: str + + def __init__( + self, urn: str = None, name: str = None, + description: str = None, description_trans: str = None + ) -> None: + self.urn = urn + self.name = name + self.description = description + self.description_trans = description_trans + self.services = [] + + def load(self, specs: dict) -> 'MIoTSpecInstance': + self.urn = specs['urn'] + self.name = specs['name'] + self.description = specs['description'] + self.description_trans = specs['description_trans'] + self.services = [] + for service in specs['services']: + spec_service = MIoTSpecService(spec=service) + for prop in service['properties']: + spec_prop = MIoTSpecProperty( + spec=prop, + service=spec_service, + format_=prop['format'], + access=prop['access'], + unit=prop['unit'], + value_range=prop['value_range'], + value_list=prop['value_list'], + precision=prop.get('precision', 0)) + spec_service.properties.append(spec_prop) + for event in service['events']: + spec_event = MIoTSpecEvent( + spec=event, service=spec_service) + arg_list: list[MIoTSpecProperty] = [] + for piid in event['argument']: + for prop in spec_service.properties: + if prop.iid == piid: + arg_list.append(prop) + break + spec_event.argument = arg_list + spec_service.events.append(spec_event) + for action in service['actions']: + spec_action = MIoTSpecAction( + spec=action, service=spec_service, in_=action['in']) + in_list: list[MIoTSpecProperty] = [] + for piid in action['in']: + for prop in spec_service.properties: + if prop.iid == piid: + in_list.append(prop) + break + spec_action.in_ = in_list + out_list: list[MIoTSpecProperty] = [] + for piid in action['out']: + for prop in spec_service.properties: + if prop.iid == piid: + out_list.append(prop) + break + spec_action.out = out_list + spec_service.actions.append(spec_action) + self.services.append(spec_service) + return self + + def dump(self) -> dict: + return { + 'urn': self.urn, + 'name': self.name, + 'description': self.description, + 'description_trans': self.description_trans, + 'services': [service.dump() for service in self.services] + } + + +class SpecStdLib: + """MIoT-Spec-V2 standard library.""" + _lang: str + _spec_std_lib: Optional[dict[str, dict[str, dict[str, str]]]] + + def __init__(self, lang: str) -> None: + self._lang = lang + self._spec_std_lib = None + + def init(self, std_lib: dict[str, dict[str, str]]) -> None: + if ( + not isinstance(std_lib, dict) + or 'devices' not in std_lib + or 'services' not in std_lib + or 'properties' not in std_lib + or 'events' not in std_lib + or 'actions' not in std_lib + or 'values' not in std_lib + ): + return + self._spec_std_lib = std_lib + + def deinit(self) -> None: + self._spec_std_lib = None + + def device_translate(self, key: str) -> Optional[str]: + if not self._spec_std_lib or key not in self._spec_std_lib['devices']: + return None + if self._lang not in self._spec_std_lib['devices'][key]: + return self._spec_std_lib['devices'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None) + return self._spec_std_lib['devices'][key][self._lang] + + def service_translate(self, key: str) -> Optional[str]: + if not self._spec_std_lib or key not in self._spec_std_lib['services']: + return None + if self._lang not in self._spec_std_lib['services'][key]: + return self._spec_std_lib['services'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None) + return self._spec_std_lib['services'][key][self._lang] + + def property_translate(self, key: str) -> Optional[str]: + if ( + not self._spec_std_lib + or key not in self._spec_std_lib['properties'] + ): + return None + if self._lang not in self._spec_std_lib['properties'][key]: + return self._spec_std_lib['properties'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None) + return self._spec_std_lib['properties'][key][self._lang] + + def event_translate(self, key: str) -> Optional[str]: + if not self._spec_std_lib or key not in self._spec_std_lib['events']: + return None + if self._lang not in self._spec_std_lib['events'][key]: + return self._spec_std_lib['events'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None) + return self._spec_std_lib['events'][key][self._lang] + + def action_translate(self, key: str) -> Optional[str]: + if not self._spec_std_lib or key not in self._spec_std_lib['actions']: + return None + if self._lang not in self._spec_std_lib['actions'][key]: + return self._spec_std_lib['actions'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None) + return self._spec_std_lib['actions'][key][self._lang] + + def value_translate(self, key: str) -> Optional[str]: + if not self._spec_std_lib or key not in self._spec_std_lib['values']: + return None + if self._lang not in self._spec_std_lib['values'][key]: + return self._spec_std_lib['values'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None) + return self._spec_std_lib['values'][key][self._lang] + + def dump(self) -> dict[str, dict[str, str]]: + return self._spec_std_lib + + +class MIoTSpecParser: + """MIoT SPEC parser.""" + VERSION: int = 1 + DOMAIN: str = 'miot_specs' + _lang: str + _storage: MIoTStorage + _main_loop: asyncio.AbstractEventLoop + + _init_done: bool + _ram_cache: dict + + _std_lib: SpecStdLib + _bool_trans: SpecBoolTranslation + _multi_lang: SpecMultiLang + _spec_filter: SpecFilter + + def __init__( + self, lang: str = DEFAULT_INTEGRATION_LANGUAGE, + storage: MIoTStorage = None, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._lang = lang + self._storage = storage + self._main_loop = loop or asyncio.get_running_loop() + + self._init_done = False + self._ram_cache = {} + + self._std_lib = SpecStdLib(lang=self._lang) + self._bool_trans = SpecBoolTranslation( + lang=self._lang, loop=self._main_loop) + self._multi_lang = SpecMultiLang(lang=self._lang, loop=self._main_loop) + self._spec_filter = SpecFilter(loop=self._main_loop) + + async def init_async(self) -> None: + if self._init_done is True: + return + await self._bool_trans.init_async() + await self._multi_lang.init_async() + await self._spec_filter.init_async() + std_lib_cache: dict = None + if self._storage: + std_lib_cache: dict = await self._storage.load_async( + domain=self.DOMAIN, name='spec_std_lib', type_=dict) + if ( + isinstance(std_lib_cache, dict) + and 'data' in std_lib_cache + and 'ts' in std_lib_cache + and isinstance(std_lib_cache['ts'], int) + and int(time.time()) - std_lib_cache['ts'] < + SPEC_STD_LIB_EFFECTIVE_TIME + ): + # Use the cache if the update time is less than 14 day + _LOGGER.debug( + 'use local spec std cache, ts->%s', std_lib_cache['ts']) + self._std_lib.init(std_lib_cache['data']) + self._init_done = True + return + # Update spec std lib + spec_lib_new = await self.__request_spec_std_lib_async() + if spec_lib_new: + self._std_lib.init(spec_lib_new) + if self._storage: + if not await self._storage.save_async( + domain=self.DOMAIN, name='spec_std_lib', + data={ + 'data': self._std_lib.dump(), + 'ts': int(time.time()) + } + ): + _LOGGER.error('save spec std lib failed') + else: + if std_lib_cache: + self._std_lib.init(std_lib_cache['data']) + _LOGGER.error('get spec std lib failed, use local cache') + else: + _LOGGER.error('get spec std lib failed') + self._init_done = True + + async def deinit_async(self) -> None: + self._init_done = False + self._std_lib.deinit() + await self._bool_trans.deinit_async() + await self._multi_lang.deinit_async() + await self._spec_filter.deinit_async() + self._ram_cache.clear() + + async def parse( + self, urn: str, skip_cache: bool = False, + ) -> MIoTSpecInstance: + """MUST await init first !!!""" + if not skip_cache: + cache_result = await self.__cache_get(urn=urn) + if isinstance(cache_result, dict): + _LOGGER.debug('get from cache, %s', urn) + return MIoTSpecInstance().load(specs=cache_result) + # Retry three times + for index in range(3): + try: + return await self.__parse(urn=urn) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'parse error, retry, %d, %s, %s', index, urn, err) + return None + + async def refresh_async(self, urn_list: list[str]) -> int: + """MUST await init first !!!""" + if not urn_list: + return False + spec_std_new: dict = await self.__request_spec_std_lib_async() + if spec_std_new: + self._std_lib.init(spec_std_new) + if self._storage: + if not await self._storage.save_async( + domain=self.DOMAIN, name='spec_std_lib', + data={ + 'data': self._std_lib.dump(), + 'ts': int(time.time()) + } + ): + _LOGGER.error('save spec std lib failed') + else: + raise MIoTSpecError('get spec std lib failed') + success_count = 0 + for index in range(0, len(urn_list), 5): + batch = urn_list[index:index+5] + task_list = [self._main_loop.create_task( + self.parse(urn=urn, skip_cache=True)) for urn in batch] + results = await asyncio.gather(*task_list) + success_count += sum(1 for result in results if result is not None) + return success_count + + def __http_get( + self, url: str, params: dict = None, headers: dict = None + ) -> dict: + if params: + encoded_params = urlencode(params) + full_url = f'{url}?{encoded_params}' + else: + full_url = url + request = Request(full_url, method='GET', headers=headers or {}) + content: bytes = None + with urlopen(request) as response: + content = response.read() + return ( + json.loads(str(content, 'utf-8')) + if content is not None else None) + + async def __http_get_async( + self, url: str, params: dict = None, headers: dict = None + ) -> dict: + return await self._main_loop.run_in_executor( + None, self.__http_get, url, params, headers) + + async def __cache_get(self, urn: str) -> Optional[dict]: + if self._storage is not None: + if platform.system() == 'Windows': + urn = urn.replace(':', '_') + return await self._storage.load_async( + domain=self.DOMAIN, name=f'{urn}_{self._lang}', type_=dict) + return self._ram_cache.get(urn, None) + + async def __cache_set(self, urn: str, data: dict) -> bool: + if self._storage is not None: + if platform.system() == 'Windows': + urn = urn.replace(':', '_') + return await self._storage.save_async( + domain=self.DOMAIN, name=f'{urn}_{self._lang}', data=data) + self._ram_cache[urn] = data + return True + + def __spec_format2dtype(self, format_: str) -> str: + # 'string'|'bool'|'uint8'|'uint16'|'uint32'| + # 'int8'|'int16'|'int32'|'int64'|'float' + return {'string': 'str', 'bool': 'bool', 'float': 'float'}.get( + format_, 'int') + + async def __request_spec_std_lib_async(self) -> Optional[SpecStdLib]: + std_libs: dict = None + for index in range(3): + try: + tasks: list = [] + # Get std lib + for name in [ + 'device', 'service', 'property', 'event', 'action']: + tasks.append(self.__get_template_list( + 'https://miot-spec.org/miot-spec-v2/template/list/' + + name)) + tasks.append(self.__get_property_value()) + # Async request + results = await asyncio.gather(*tasks) + if None in results: + raise MIoTSpecError('init failed, None in result') + std_libs = { + 'devices': results[0], + 'services': results[1], + 'properties': results[2], + 'events': results[3], + 'actions': results[4], + 'values': results[5], + } + # Get external std lib, Power by LM + tasks.clear() + for name in [ + 'device', 'service', 'property', 'event', 'action', + 'property_value']: + tasks.append(self.__http_get_async( + 'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/' + f'xiaomi-home/std_ex_{name}.json')) + results = await asyncio.gather(*tasks) + if results[0]: + for key, value in results[0].items(): + if key in std_libs['devices']: + std_libs['devices'][key].update(value) + else: + std_libs['devices'][key] = value + else: + _LOGGER.error('get external std lib failed, devices') + if results[1]: + for key, value in results[1].items(): + if key in std_libs['services']: + std_libs['services'][key].update(value) + else: + std_libs['services'][key] = value + else: + _LOGGER.error('get external std lib failed, services') + if results[2]: + for key, value in results[2].items(): + if key in std_libs['properties']: + std_libs['properties'][key].update(value) + else: + std_libs['properties'][key] = value + else: + _LOGGER.error('get external std lib failed, properties') + if results[3]: + for key, value in results[3].items(): + if key in std_libs['events']: + std_libs['events'][key].update(value) + else: + std_libs['events'][key] = value + else: + _LOGGER.error('get external std lib failed, events') + if results[4]: + for key, value in results[4].items(): + if key in std_libs['actions']: + std_libs['actions'][key].update(value) + else: + std_libs['actions'][key] = value + else: + _LOGGER.error('get external std lib failed, actions') + if results[5]: + for key, value in results[5].items(): + if key in std_libs['values']: + std_libs['values'][key].update(value) + else: + std_libs['values'][key] = value + else: + _LOGGER.error( + 'get external std lib failed, values') + return std_libs + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error( + 'update spec std lib error, retry, %d, %s', index, err) + return None + + async def __get_property_value(self) -> dict: + reply = await self.__http_get_async( + url='https://miot-spec.org/miot-spec-v2' + '/normalization/list/property_value') + if reply is None or 'result' not in reply: + raise MIoTSpecError('get property value failed') + result = {} + for item in reply['result']: + if ( + not isinstance(item, dict) + or 'normalization' not in item + or 'description' not in item + or 'proName' not in item + or 'urn' not in item + ): + continue + result[ + f'{item["urn"]}|{item["proName"]}|{item["normalization"]}' + ] = { + 'zh-Hans': item['description'], + 'en': item['normalization'] + } + return result + + async def __get_template_list(self, url: str) -> dict: + reply = await self.__http_get_async(url=url) + if reply is None or 'result' not in reply: + raise MIoTSpecError(f'get service failed, {url}') + result: dict = {} + for item in reply['result']: + if ( + not isinstance(item, dict) + or 'type' not in item + or 'description' not in item + ): + continue + if 'zh_cn' in item['description']: + item['description']['zh-Hans'] = item['description'].pop( + 'zh_cn') + if 'zh_hk' in item['description']: + item['description']['zh-Hant'] = item['description'].pop( + 'zh_hk') + item['description'].pop('zh_tw', None) + elif 'zh_tw' in item['description']: + item['description']['zh-Hant'] = item['description'].pop( + 'zh_tw') + result[item['type']] = item['description'] + return result + + async def __get_instance(self, urn: str) -> dict: + return await self.__http_get_async( + url='https://miot-spec.org/miot-spec-v2/instance', + params={'type': urn}) + + async def __get_translation(self, urn: str) -> dict: + return await self.__http_get_async( + url='https://miot-spec.org/instance/v2/multiLanguage', + params={'urn': urn}) + + async def __parse(self, urn: str) -> MIoTSpecInstance: + _LOGGER.debug('parse urn, %s', urn) + # Load spec instance + instance: dict = await self.__get_instance(urn=urn) + if ( + not isinstance(instance, dict) + or 'type' not in instance + or 'description' not in instance + or 'services' not in instance + ): + raise MIoTSpecError(f'invalid urn instance, {urn}') + translation: dict = {} + try: + # Load multiple language configuration. + res_trans = await self.__get_translation(urn=urn) + if ( + not isinstance(res_trans, dict) + or 'data' not in res_trans + or not isinstance(res_trans['data'], dict) + ): + raise MIoTSpecError('invalid translation data') + urn_strs: list[str] = urn.split(':') + urn_key: str = ':'.join(urn_strs[:6]) + trans_data: dict[str, str] = None + if self._lang == 'zh-Hans': + # Simplified Chinese + trans_data = res_trans['data'].get('zh_cn', {}) + elif self._lang == 'zh-Hant': + # Traditional Chinese, zh_hk or zh_tw + trans_data = res_trans['data'].get('zh_hk', {}) + if not trans_data: + trans_data = res_trans['data'].get('zh_tw', {}) + else: + trans_data = res_trans['data'].get(self._lang, {}) + # Load local multiple language configuration. + multi_lang: dict = await self._multi_lang.translate_async( + urn_key=urn_key) + if multi_lang: + trans_data.update(multi_lang) + if not trans_data: + trans_data = res_trans['data'].get( + DEFAULT_INTEGRATION_LANGUAGE, {}) + if not trans_data: + raise MIoTSpecError( + f'the language is not supported, {self._lang}') + else: + _LOGGER.error( + 'the language is not supported, %s, try using the ' + 'default language, %s, %s', + self._lang, DEFAULT_INTEGRATION_LANGUAGE, urn) + for tag, value in trans_data.items(): + if value is None or value.strip() == '': + continue + # The dict key is like: + # 'service:002:property:001:valuelist:000' or + # 'service:002:property:001' or 'service:002' + strs: list = tag.split(':') + strs_len = len(strs) + if strs_len == 2: + translation[f's:{int(strs[1])}'] = value + elif strs_len == 4: + type_ = 'p' if strs[2] == 'property' else ( + 'a' if strs[2] == 'action' else 'e') + translation[ + f'{type_}:{int(strs[1])}:{int(strs[3])}' + ] = value + elif strs_len == 6: + translation[ + f'v:{int(strs[1])}:{int(strs[3])}:{int(strs[5])}' + ] = value + except MIoTSpecError as e: + _LOGGER.error('get translation error, %s, %s', urn, e) + # Spec filter + self._spec_filter.filter_spec(urn_key=urn_key) + # Parse device type + spec_instance: MIoTSpecInstance = MIoTSpecInstance( + urn=urn, name=urn_strs[3], + description=instance['description'], + description_trans=( + self._std_lib.device_translate(key=':'.join(urn_strs[:5])) + or instance['description'] + or urn_strs[3])) + # Parse services + for service in instance.get('services', []): + if ( + 'iid' not in service + or 'type' not in service + or 'description' not in service + ): + _LOGGER.error('invalid service, %s, %s', urn, service) + continue + type_strs: list[str] = service['type'].split(':') + if type_strs[3] == 'device-information': + # Ignore device-information service + continue + spec_service: MIoTSpecService = MIoTSpecService(spec=service) + spec_service.name = type_strs[3] + # Filter spec service + spec_service.need_filter = self._spec_filter.filter_service( + siid=service['iid']) + if type_strs[1] != 'miot-spec-v2': + spec_service.proprietary = True + spec_service.description_trans = ( + translation.get(f's:{service["iid"]}', None) + or self._std_lib.service_translate(key=':'.join(type_strs[:5])) + or service['description'] + or spec_service.name + ) + # Parse service property + for property_ in service.get('properties', []): + if ( + 'iid' not in property_ + or 'type' not in property_ + or 'description' not in property_ + or 'format' not in property_ + or 'access' not in property_ + ): + continue + p_type_strs: list[str] = property_['type'].split(':') + spec_prop: MIoTSpecProperty = MIoTSpecProperty( + spec=property_, + service=spec_service, + format_=self.__spec_format2dtype(property_['format']), + access=property_['access'], + unit=property_.get('unit', None)) + spec_prop.name = p_type_strs[3] + # Filter spec property + spec_prop.need_filter = ( + spec_service.need_filter + or self._spec_filter.filter_property( + siid=service['iid'], piid=property_['iid'])) + if p_type_strs[1] != 'miot-spec-v2': + spec_prop.proprietary = spec_service.proprietary or True + spec_prop.description_trans = ( + translation.get( + f'p:{service["iid"]}:{property_["iid"]}', None) + or self._std_lib.property_translate( + key=':'.join(p_type_strs[:5])) + or property_['description'] + or spec_prop.name) + if 'value-range' in property_: + spec_prop.value_range = { + 'min': property_['value-range'][0], + 'max': property_['value-range'][1], + 'step': property_['value-range'][2] + } + spec_prop.precision = len(str( + property_['value-range'][2]).split( + '.')[1].rstrip('0')) if '.' in str( + property_['value-range'][2]) else 0 + elif 'value-list' in property_: + v_list: list[dict] = property_['value-list'] + for index, v in enumerate(v_list): + v['name'] = v['description'] + v['description'] = ( + translation.get( + f'v:{service["iid"]}:{property_["iid"]}:' + f'{index}', None) + or self._std_lib.value_translate( + key=f'{type_strs[:5]}|{p_type_strs[3]}|' + f'{v["description"]}') + or v['name'] + ) + spec_prop.value_list = v_list + elif property_['format'] == 'bool': + v_tag = ':'.join(p_type_strs[:5]) + v_descriptions: dict = ( + await self._bool_trans.translate_async(urn=v_tag)) + if v_descriptions: + spec_prop.value_list = v_descriptions + spec_service.properties.append(spec_prop) + # Parse service event + for event in service.get('events', []): + if ( + 'iid' not in event + or 'type' not in event + or 'description' not in event + or 'arguments' not in event + ): + continue + e_type_strs: list[str] = event['type'].split(':') + spec_event: MIoTSpecEvent = MIoTSpecEvent( + spec=event, service=spec_service) + spec_event.name = e_type_strs[3] + # Filter spec event + spec_event.need_filter = ( + spec_service.need_filter + or self._spec_filter.filter_event( + siid=service['iid'], eiid=event['iid'])) + if e_type_strs[1] != 'miot-spec-v2': + spec_event.proprietary = spec_service.proprietary or True + spec_event.description_trans = ( + translation.get( + f'e:{service["iid"]}:{event["iid"]}', None) + or self._std_lib.event_translate( + key=':'.join(e_type_strs[:5])) + or event['description'] + or spec_event.name + ) + arg_list: list[MIoTSpecProperty] = [] + for piid in event['arguments']: + for prop in spec_service.properties: + if prop.iid == piid: + arg_list.append(prop) + break + spec_event.argument = arg_list + spec_service.events.append(spec_event) + # Parse service action + for action in service.get('actions', []): + if ( + 'iid' not in action + or 'type' not in action + or 'description' not in action + or 'in' not in action + ): + continue + a_type_strs: list[str] = action['type'].split(':') + spec_action: MIoTSpecAction = MIoTSpecAction( + spec=action, service=spec_service) + spec_action.name = a_type_strs[3] + # Filter spec action + spec_action.need_filter = ( + spec_service.need_filter + or self._spec_filter.filter_action( + siid=service['iid'], aiid=action['iid'])) + if a_type_strs[1] != 'miot-spec-v2': + spec_action.proprietary = spec_service.proprietary or True + spec_action.description_trans = ( + translation.get( + f'a:{service["iid"]}:{action["iid"]}', None) + or self._std_lib.action_translate( + key=':'.join(a_type_strs[:5])) + or action['description'] + or spec_action.name + ) + in_list: list[MIoTSpecProperty] = [] + for piid in action['in']: + for prop in spec_service.properties: + if prop.iid == piid: + in_list.append(prop) + break + spec_action.in_ = in_list + out_list: list[MIoTSpecProperty] = [] + for piid in action['out']: + for prop in spec_service.properties: + if prop.iid == piid: + out_list.append(prop) + break + spec_action.out = out_list + spec_service.actions.append(spec_action) + spec_instance.services.append(spec_service) + + await self.__cache_set(urn=urn, data=spec_instance.dump()) + return spec_instance diff --git a/custom_components/xiaomi_home/miot/miot_storage.py b/custom_components/xiaomi_home/miot/miot_storage.py new file mode 100644 index 0000000..88ac8be --- /dev/null +++ b/custom_components/xiaomi_home/miot/miot_storage.py @@ -0,0 +1,1034 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT storage and certificate management. +""" +import os +import asyncio +import binascii +import json +import shutil +import time +import traceback +import hashlib +from datetime import datetime, timezone +from enum import Enum, auto +from pathlib import Path +from typing import Optional, Union +import logging +from urllib.request import Request, urlopen +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend +from cryptography.x509.oid import NameOID +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from .common import load_json_file +from .const import ( + DEFAULT_INTEGRATION_LANGUAGE, + MANUFACTURER_EFFECTIVE_TIME, + MIHOME_CA_CERT_STR, + MIHOME_CA_CERT_SHA256) +from .miot_error import MIoTCertError, MIoTError, MIoTStorageError + +_LOGGER = logging.getLogger(__name__) + + +class MIoTStorageType(Enum): + LOAD = auto() + LOAD_FILE = auto() + SAVE = auto() + SAVE_FILE = auto() + DEL = auto() + DEL_FILE = auto() + CLEAR = auto() + + +class MIoTStorage: + """File management. + + User data will be stored in the `.storage` directory of Home Assistant. + """ + _main_loop: asyncio.AbstractEventLoop = None + _file_future: dict[str, tuple[MIoTStorageType, asyncio.Future]] + + _root_path: str = None + + def __init__( + self, root_path: str, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + """Initialize with a root path.""" + self._main_loop = loop or asyncio.get_running_loop() + self._file_future = {} + + self._root_path = os.path.abspath(root_path) + os.makedirs(self._root_path, exist_ok=True) + + _LOGGER.debug('root path, %s', self._root_path) + + def __get_full_path(self, domain: str, name: str, suffix: str) -> str: + return os.path.join( + self._root_path, domain, f'{name}.{suffix}') + + def __add_file_future( + self, key: str, op_type: MIoTStorageType, fut: asyncio.Future + ) -> None: + def fut_done_callback(fut: asyncio.Future): + del fut + self._file_future.pop(key, None) + + fut.add_done_callback(fut_done_callback) + self._file_future[key] = op_type, fut + + def __load( + self, full_path: str, type_: type = bytes, with_hash_check: bool = True + ) -> Union[bytes, str, dict, list, None]: + if not os.path.exists(full_path): + _LOGGER.debug('load error, file not exists, %s', full_path) + return None + if not os.access(full_path, os.R_OK): + _LOGGER.error('load error, file not readable, %s', full_path) + return None + try: + with open(full_path, 'rb') as r_file: + r_data: bytes = r_file.read() + if r_data is None: + _LOGGER.error('load error, empty file, %s', full_path) + return None + data_bytes: bytes = None + # Hash check + if with_hash_check: + if len(r_data) <= 32: + return None + data_bytes = r_data[:-32] + hash_value = r_data[-32:] + if hashlib.sha256(data_bytes).digest() != hash_value: + _LOGGER.error( + 'load error, hash check failed, %s', full_path) + return None + else: + data_bytes = r_data + if type_ == bytes: + return data_bytes + if type_ == str: + return str(data_bytes, 'utf-8') + if type_ in [dict, list]: + return json.loads(data_bytes) + _LOGGER.error( + 'load error, un-support data type, %s', type_.__name__) + return None + except (OSError, TypeError) as e: + _LOGGER.error('load error, %s, %s', e, traceback.format_exc()) + return None + + def load( + self, domain: str, name: str, type_: type = bytes + ) -> Union[bytes, str, dict, list, None]: + full_path = self.__get_full_path( + domain=domain, name=name, suffix=type_.__name__) + return self.__load(full_path=full_path, type_=type_) + + async def load_async( + self, domain: str, name: str, type_: type = bytes + ) -> Union[bytes, str, dict, list, None]: + full_path = self.__get_full_path( + domain=domain, name=name, suffix=type_.__name__) + if full_path in self._file_future: + # Waiting for the last task to be completed + op_type, fut = self._file_future[full_path] + if op_type == MIoTStorageType.LOAD: + if not fut.done(): + return await fut + else: + await fut + fut = self._main_loop.run_in_executor( + None, self.__load, full_path, type_) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.LOAD, fut) + return await fut + + def __save( + self, full_path: str, data: Union[bytes, str, dict, list, None], + cover: bool = True, with_hash: bool = True + ) -> bool: + if data is None: + _LOGGER.error('save error, save data is None') + return False + if os.path.exists(full_path): + if not cover: + _LOGGER.error('save error, file exists, cover is False') + return False + if not os.access(full_path, os.W_OK): + _LOGGER.error('save error, file not writeable, %s', full_path) + return False + else: + os.makedirs(os.path.dirname(full_path), exist_ok=True) + try: + type_: type = type(data) + w_bytes: bytes = None + if type_ == bytes: + w_bytes = data + elif type_ == str: + w_bytes = data.encode('utf-8') + elif type_ in [dict, list]: + w_bytes = json.dumps(data).encode('utf-8') + else: + _LOGGER.error( + 'save error, un-support data type, %s', type_.__name__) + return None + with open(full_path, 'wb') as w_file: + w_file.write(w_bytes) + if with_hash: + w_file.write(hashlib.sha256(w_bytes).digest()) + return True + except (OSError, TypeError) as e: + _LOGGER.error('save error, %s, %s', e, traceback.format_exc()) + return False + + def save( + self, domain: str, name: str, data: Union[bytes, str, dict, list, None] + ) -> bool: + full_path = self.__get_full_path( + domain=domain, name=name, suffix=type(data).__name__) + return self.__save(full_path=full_path, data=data) + + async def save_async( + self, domain: str, name: str, data: Union[bytes, str, dict, list, None] + ) -> bool: + full_path = self.__get_full_path( + domain=domain, name=name, suffix=type(data).__name__) + if full_path in self._file_future: + # Waiting for the last task to be completed + fut = self._file_future[full_path][1] + await fut + fut = self._main_loop.run_in_executor( + None, self.__save, full_path, data) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.SAVE, fut) + return await fut + + def __remove(self, full_path: str) -> bool: + item = Path(full_path) + if item.is_file() or item.is_symlink(): + item.unlink() + return True + + def remove(self, domain: str, name: str, type_: type) -> bool: + full_path = self.__get_full_path( + domain=domain, name=name, suffix=type_.__name__) + return self.__remove(full_path=full_path) + + async def remove_async(self, domain: str, name: str, type_: type) -> bool: + full_path = self.__get_full_path( + domain=domain, name=name, suffix=type_.__name__) + if full_path in self._file_future: + # Waiting for the last task to be completed + op_type, fut = self._file_future[full_path] + if op_type == MIoTStorageType.DEL: + if not fut.done(): + return await fut + else: + await fut + fut = self._main_loop.run_in_executor(None, self.__remove, full_path) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.DEL, fut) + return await fut + + def __remove_domain(self, full_path: str) -> bool: + path_obj = Path(full_path) + if path_obj.exists(): + # Recursive deletion + shutil.rmtree(path_obj) + return True + + def remove_domain(self, domain: str) -> bool: + full_path = os.path.join(self._root_path, domain) + return self.__remove_domain(full_path=full_path) + + async def remove_domain_async(self, domain: str) -> bool: + full_path = os.path.join(self._root_path, domain) + if full_path in self._file_future: + # Waiting for the last task to be completed + op_type, fut = self._file_future[full_path] + if op_type == MIoTStorageType.DEL: + if not fut.done(): + return await fut + else: + await fut + # Waiting domain tasks finish + for path, value in self._file_future.items(): + if path.startswith(full_path): + await value[1] + fut = self._main_loop.run_in_executor( + None, self.__remove_domain, full_path) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.DEL, fut) + return await fut + + def get_names(self, domain: str, type_: type) -> list[str]: + path: str = os.path.join(self._root_path, domain) + type_str = f'.{type_.__name__}' + names: list[str] = [] + for item in Path(path).glob(f'*{type_str}'): + if not item.is_file() and not item.is_symlink(): + continue + names.append(item.name.replace(type_str, '')) + return names + + def file_exists(self, domain: str, name_with_suffix: str) -> bool: + return os.path.exists( + os.path.join(self._root_path, domain, name_with_suffix)) + + def save_file( + self, domain: str, name_with_suffix: str, data: bytes + ) -> bool: + if not isinstance(data, bytes): + _LOGGER.error('save file error, file must be bytes') + return False + full_path = os.path.join(self._root_path, domain, name_with_suffix) + return self.__save(full_path=full_path, data=data, with_hash=False) + + async def save_file_async( + self, domain: str, name_with_suffix: str, data: bytes + ) -> bool: + if not isinstance(data, bytes): + _LOGGER.error('save file error, file must be bytes') + return False + full_path = os.path.join(self._root_path, domain, name_with_suffix) + if full_path in self._file_future: + # Waiting for the last task to be completed + fut = self._file_future[full_path][1] + await fut + fut = self._main_loop.run_in_executor( + None, self.__save, full_path, data, True, False) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.SAVE_FILE, fut) + return await fut + + def load_file(self, domain: str, name_with_suffix: str) -> Optional[bytes]: + full_path = os.path.join(self._root_path, domain, name_with_suffix) + return self.__load( + full_path=full_path, type_=bytes, with_hash_check=False) + + async def load_file_async( + self, domain: str, name_with_suffix: str + ) -> Optional[bytes]: + full_path = os.path.join(self._root_path, domain, name_with_suffix) + if full_path in self._file_future: + # Waiting for the last task to be completed + op_type, fut = self._file_future[full_path] + if op_type == MIoTStorageType.LOAD_FILE: + if not fut.done(): + return await fut + else: + await fut + fut = self._main_loop.run_in_executor( + None, self.__load, full_path, bytes, False) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.LOAD_FILE, fut) + return await fut + + def remove_file(self, domain: str, name_with_suffix: str) -> bool: + full_path = os.path.join(self._root_path, domain, name_with_suffix) + return self.__remove(full_path=full_path) + + async def remove_file_async( + self, domain: str, name_with_suffix: str + ) -> bool: + full_path = os.path.join(self._root_path, domain, name_with_suffix) + if full_path in self._file_future: + # Waiting for the last task to be completed + op_type, fut = self._file_future[full_path] + if op_type == MIoTStorageType.DEL_FILE: + if not fut.done(): + return await fut + else: + await fut + fut = self._main_loop.run_in_executor(None, self.__remove, full_path) + if not fut.done(): + self.__add_file_future(full_path, MIoTStorageType.DEL_FILE, fut) + return await fut + + def clear(self) -> bool: + root_path = Path(self._root_path) + for item in root_path.iterdir(): + if item.is_file() or item.is_symlink(): + item.unlink() + elif item.is_dir(): + shutil.rmtree(item) + return True + + async def clear_async(self) -> bool: + if self._root_path in self._file_future: + op_type, fut = self._file_future[self._root_path] + if op_type == MIoTStorageType.CLEAR and not fut.done(): + return await fut + # Waiting all future resolve + for value in self._file_future.values(): + await value[1] + + fut = self._main_loop.run_in_executor(None, self.clear) + if not fut.done(): + self.__add_file_future( + self._root_path, MIoTStorageType.CLEAR, fut) + return await fut + + def update_user_config( + self, uid: str, cloud_server: str, config: Optional[dict[str, any]], + replace: bool = False + ) -> bool: + if config is not None and len(config) == 0: + # Do nothing + return True + + config_domain = 'miot_config' + config_name = f'{uid}_{cloud_server}' + if config is None: + # Remove config file + return self.remove( + domain=config_domain, name=config_name, type_=dict) + if replace: + # Replace config file + return self.save( + domain=config_domain, name=config_name, data=config) + local_config = (self.load(domain=config_domain, + name=config_name, type_=dict)) or {} + local_config.update(config) + return self.save( + domain=config_domain, name=config_name, data=local_config) + + async def update_user_config_async( + self, uid: str, cloud_server: str, config: Optional[dict[str, any]], + replace: bool = False + ) -> bool: + """Update user configuration. + + Args: + uid (str): user_id + config (Optional[dict[str]]): + remove config file if config is None + replace (bool, optional): + replace all config item. Defaults to False. + + Returns: + bool: result code + """ + if config is not None and len(config) == 0: + # Do nothing + return True + + config_domain = 'miot_config' + config_name = f'{uid}_{cloud_server}' + if config is None: + # Remove config file + return await self.remove_async( + domain=config_domain, name=config_name, type_=dict) + if replace: + # Replace config file + return await self.save_async( + domain=config_domain, name=config_name, data=config) + local_config = (await self.load_async( + domain=config_domain, name=config_name, type_=dict)) or {} + local_config.update(config) + return await self.save_async( + domain=config_domain, name=config_name, data=local_config) + + def load_user_config( + self, uid: str, cloud_server: str, keys: Optional[list[str]] = None + ) -> dict[str, any]: + if keys is not None and len(keys) == 0: + # Do nothing + return {} + config_domain = 'miot_config' + config_name = f'{uid}_{cloud_server}' + local_config = (self.load(domain=config_domain, + name=config_name, type_=dict)) or {} + if keys is None: + return local_config + return {key: local_config.get(key, None) for key in keys} + + async def load_user_config_async( + self, uid: str, cloud_server: str, keys: Optional[list[str]] = None + ) -> dict[str, any]: + """Load user configuration. + + Args: + uid (str): user id + keys (list[str]): + query key list, return all config item if keys is None + + Returns: + dict[str, any]: query result + """ + if keys is not None and len(keys) == 0: + # Do nothing + return {} + config_domain = 'miot_config' + config_name = f'{uid}_{cloud_server}' + local_config = (await self.load_async( + domain=config_domain, name=config_name, type_=dict)) or {} + if keys is None: + return local_config + return { + key: local_config[key] for key in keys + if key in local_config} + + def gen_storage_path( + self, domain: str = None, name_with_suffix: str = None + ) -> str: + """Generate file path.""" + result = self._root_path + if domain: + result = os.path.join(result, domain) + if name_with_suffix: + result = os.path.join(result, name_with_suffix) + return result + + +class MIoTCert: + """MIoT certificate file management.""" + CERT_DOMAIN: str = 'cert' + CA_NAME: str = 'mihome_ca.cert' + _loop: asyncio.AbstractEventLoop + _storage: MIoTStorage + _uid: str + _cloud_server: str + + _key_name: str + _cert_name: str + + def __init__( + self, storage: MIoTStorage, uid: str, cloud_server: str, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + if not isinstance(storage, MIoTStorage) or not isinstance(uid, str): + raise MIoTError('invalid params') + self._loop = loop or asyncio.get_running_loop() + self._storage = storage + self._uid = uid + self._cloud_server = cloud_server + self._key_name = f'{uid}_{cloud_server}.key' + self._cert_name = f'{uid}_{cloud_server}.cert' + + @property + def ca_file(self) -> str: + """CA certificate file path.""" + return self._storage.gen_storage_path( + domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME) + + @property + def key_file(self) -> str: + """User private key file file path.""" + return self._storage.gen_storage_path( + domain=self.CERT_DOMAIN, name_with_suffix=self._key_name) + + @property + def cert_file(self) -> str: + """User certificate file path.""" + return self._storage.gen_storage_path( + domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name) + + async def verify_ca_cert_async(self) -> bool: + """Verify the integrity of the CA certificate file.""" + ca_data = await self._storage.load_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME) + if ca_data is None: + if not await self._storage.save_file_async( + domain=self.CERT_DOMAIN, + name_with_suffix=self.CA_NAME, + data=MIHOME_CA_CERT_STR.encode('utf-8')): + raise MIoTStorageError('ca cert save failed') + ca_data = await self._storage.load_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME) + if ca_data is None: + raise MIoTStorageError('ca cert load failed') + _LOGGER.debug('ca cert save success') + # Compare the file sha256sum + ca_cert_hash = hashlib.sha256(ca_data).digest() + hash_str = binascii.hexlify(ca_cert_hash).decode('utf-8') + if hash_str != MIHOME_CA_CERT_SHA256: + return False + return True + + async def user_cert_remaining_time_async( + self, cert_data: Optional[bytes] = None, did: Optional[str] = None + ) -> int: + """Get the remaining time of user certificate validity. + + Returns: + If the certificate is not valid, return 0. + """ + if cert_data is None: + cert_data = await self._storage.load_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name) + if cert_data is None: + return 0 + # Check user cert + user_cert: x509.Certificate = None + try: + user_cert = x509.load_pem_x509_certificate( + cert_data, default_backend()) + cert_info = {} + for attribute in user_cert.subject: + if attribute.oid == x509.NameOID.COMMON_NAME: + cert_info['CN'] = attribute.value + elif attribute.oid == x509.NameOID.COUNTRY_NAME: + cert_info['C'] = attribute.value + elif attribute.oid == x509.NameOID.ORGANIZATION_NAME: + cert_info['O'] = attribute.value + + if len(cert_info) != 3: + raise MIoTCertError('invalid cert info') + if ( + did and cert_info['CN'] != + f'mips.{self._uid}.{self.__did_hash(did=did)}.2' + ): + raise MIoTCertError('invalid COMMON_NAME') + if 'C' not in cert_info or cert_info['C'] != 'CN': + raise MIoTCertError('invalid COUNTRY_NAME') + if 'O' not in cert_info or cert_info['O'] != 'Mijia Device': + raise MIoTCertError('invalid ORGANIZATION_NAME') + now_utc: datetime = datetime.now(timezone.utc) + if ( + now_utc < user_cert.not_valid_before_utc or + now_utc > user_cert.not_valid_after_utc + ): + raise MIoTCertError('cert is not valid') + return int((user_cert.not_valid_after_utc-now_utc).total_seconds()) + except (MIoTCertError, ValueError) as error: + _LOGGER.error( + 'load_pem_x509_certificate failed, %s, %s', + error, traceback.format_exc()) + return 0 + + def gen_user_key(self) -> str: + """Generate user private key.""" + private_key = ed25519.Ed25519PrivateKey.generate() + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode('utf-8') + + def gen_user_csr(self, user_key: str, did: str) -> str: + """Generate CSR of user certificate.""" + private_key = serialization.load_pem_private_key( + data=user_key.encode('utf-8'), password=None) + did_hash = self.__did_hash(did=did) + builder = x509.CertificateSigningRequestBuilder().subject_name( + x509.Name([ + # Central hub gateway service is only supported in China. + x509.NameAttribute(NameOID.COUNTRY_NAME, 'CN'), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Mijia Device'), + x509.NameAttribute( + NameOID.COMMON_NAME, f'mips.{self._uid}.{did_hash}.2'), + ])) + csr = builder.sign( + private_key, algorithm=None, backend=default_backend()) + return csr.public_bytes(serialization.Encoding.PEM).decode('utf-8') + + async def load_user_key_async(self) -> Optional[str]: + """Load user private key.""" + data = await self._storage.load_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self._key_name) + return data.decode('utf-8') if data else None + + async def update_user_key_async(self, key: str) -> bool: + """Update user private key.""" + return await self._storage.save_file_async( + domain=self.CERT_DOMAIN, + name_with_suffix=self._key_name, + data=key.encode('utf-8')) + + async def load_user_cert_async(self) -> Optional[str]: + """Load user certificate.""" + data = await self._storage.load_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name) + return data.decode('utf-8') if data else None + + async def update_user_cert_async(self, cert: str) -> bool: + """Update user certificate.""" + return await self._storage.save_file_async( + domain=self.CERT_DOMAIN, + name_with_suffix=self._cert_name, + data=cert.encode('utf-8')) + + async def remove_ca_cert_async(self) -> bool: + """Remove CA certificate.""" + return await self._storage.remove_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self.CA_NAME) + + async def remove_user_key_async(self) -> bool: + """Remove user private key.""" + return await self._storage.remove_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self._key_name) + + async def remove_user_cert_async(self) -> bool: + """Remove user certificate.""" + return await self._storage.remove_file_async( + domain=self.CERT_DOMAIN, name_with_suffix=self._cert_name) + + def __did_hash(self, did: str) -> str: + sha1_hash = hashes.Hash(hashes.SHA1(), backend=default_backend()) + sha1_hash.update(did.encode('utf-8')) + return binascii.hexlify(sha1_hash.finalize()).decode('utf-8') + + +class SpecMultiLang: + """ + MIoT-Spec-V2 multi-language for entities. + """ + MULTI_LANG_FILE = 'specs/multi_lang.json' + _main_loop: asyncio.AbstractEventLoop + _lang: str + _data: Optional[dict[str, dict]] + + def __init__( + self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._lang = lang + self._data = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + multi_lang_data = None + self._data = {} + try: + multi_lang_data = await self._main_loop.run_in_executor( + None, load_json_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + self.MULTI_LANG_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('multi lang, load file error, %s', err) + return + # Check if the file is a valid JSON file + if not isinstance(multi_lang_data, dict): + _LOGGER.error('multi lang, invalid file data') + return + for lang_data in multi_lang_data.values(): + if not isinstance(lang_data, dict): + _LOGGER.error('multi lang, invalid lang data') + return + for data in lang_data.values(): + if not isinstance(data, dict): + _LOGGER.error('multi lang, invalid lang data item') + return + self._data = multi_lang_data + + async def deinit_async(self) -> str: + self._data = None + + async def translate_async(self, urn_key: str) -> dict[str, str]: + """MUST call init_async() first.""" + if urn_key in self._data: + return self._data[urn_key].get(self._lang, {}) + return {} + + +class SpecBoolTranslation: + """ + Boolean value translation. + """ + BOOL_TRANS_FILE = 'specs/bool_trans.json' + _main_loop: asyncio.AbstractEventLoop + _lang: str + _data: Optional[dict[str, dict]] + _data_default: dict[str, dict] + + def __init__( + self, lang: str, loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._lang = lang + self._data = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + data = None + self._data = {} + try: + data = await self._main_loop.run_in_executor( + None, load_json_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + self.BOOL_TRANS_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('bool trans, load file error, %s', err) + return + # Check if the file is a valid JSON file + if ( + not isinstance(data, dict) + or 'data' not in data + or not isinstance(data['data'], dict) + or 'translate' not in data + or not isinstance(data['translate'], dict) + ): + _LOGGER.error('bool trans, valid file') + return + + if 'default' in data['translate']: + data_default = ( + data['translate']['default'].get(self._lang, None) + or data['translate']['default'].get( + DEFAULT_INTEGRATION_LANGUAGE, None)) + if data_default: + self._data_default = [ + {'value': True, 'description': data_default['true']}, + {'value': False, 'description': data_default['false']} + ] + + for urn, key in data['data'].items(): + if key not in data['translate']: + _LOGGER.error('bool trans, unknown key, %s, %s', urn, key) + continue + trans_data = ( + data['translate'][key].get(self._lang, None) + or data['translate'][key].get( + DEFAULT_INTEGRATION_LANGUAGE, None)) + if trans_data: + self._data[urn] = [ + {'value': True, 'description': trans_data['true']}, + {'value': False, 'description': trans_data['false']} + ] + + async def deinit_async(self) -> None: + self._data = None + self._data_default = None + + async def translate_async(self, urn: str) -> list[dict[bool, str]]: + """ + MUST call init_async() before calling this method. + [ + {'value': True, 'description': 'True'}, + {'value': False, 'description': 'False'} + ] + """ + + return self._data.get(urn, self._data_default) + + +class SpecFilter: + """ + MIoT-Spec-V2 filter for entity conversion. + """ + SPEC_FILTER_FILE = 'specs/spec_filter.json' + _main_loop: asyncio.AbstractEventLoop + _data: dict[str, dict[str, set]] + _cache: Optional[dict] + + def __init__(self, loop: Optional[asyncio.AbstractEventLoop]) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._data = None + self._cache = None + + async def init_async(self) -> None: + if isinstance(self._data, dict): + return + filter_data = None + self._data = {} + try: + filter_data = await self._main_loop.run_in_executor( + None, load_json_file, + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + self.SPEC_FILTER_FILE)) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('spec filter, load file error, %s', err) + return + if not isinstance(filter_data, dict): + _LOGGER.error('spec filter, invalid spec filter content') + return + for values in list(filter_data.values()): + if not isinstance(values, dict): + _LOGGER.error('spec filter, invalid spec filter data') + return + for value in values.values(): + if not isinstance(value, list): + _LOGGER.error('spec filter, invalid spec filter rules') + return + + self._data = filter_data + + async def deinit_async(self) -> None: + self._cache = None + self._data = None + + def filter_spec(self, urn_key: str) -> None: + """MUST call init_async() first.""" + if not self._data: + return + self._cache = self._data.get(urn_key, None) + + def filter_service(self, siid: int) -> bool: + """Filter service by siid. + MUST call init_async() and filter_spec() first.""" + if ( + self._cache + and 'services' in self._cache + and ( + str(siid) in self._cache['services'] + or '*' in self._cache['services']) + ): + return True + + return False + + def filter_property(self, siid: int, piid: int) -> bool: + """Filter property by piid. + MUST call init_async() and filter_spec() first.""" + if ( + self._cache + and 'properties' in self._cache + and ( + f'{siid}.{piid}' in self._cache['properties'] + or f'{siid}.*' in self._cache['properties']) + ): + return True + return False + + def filter_event(self, siid: int, eiid: int) -> bool: + """Filter event by eiid. + MUST call init_async() and filter_spec() first.""" + if ( + self._cache + and 'events' in self._cache + and ( + f'{siid}.{eiid}' in self._cache['events'] + or f'{siid}.*' in self._cache['events'] + ) + ): + return True + return False + + def filter_action(self, siid: int, aiid: int) -> bool: + """"Filter action by aiid. + MUST call init_async() and filter_spec() first.""" + if ( + self._cache + and 'actions' in self._cache + and ( + f'{siid}.{aiid}' in self._cache['actions'] + or f'{siid}.*' in self._cache['actions']) + ): + return True + return False + + +class DeviceManufacturer: + """Device manufacturer.""" + DOMAIN: str = 'miot_specs' + _main_loop: asyncio.AbstractEventLoop + _storage: MIoTStorage + _data: dict + + def __init__( + self, storage: MIoTStorage, + loop: Optional[asyncio.AbstractEventLoop] = None + ) -> None: + self._main_loop = loop or asyncio.get_event_loop() + self._storage = storage + self._data = None + + async def init_async(self) -> None: + if self._data: + return + data_cache: dict = None + data_cache = await self._storage.load_async( + domain=self.DOMAIN, name='manufacturer', type_=dict) + if ( + isinstance(data_cache, dict) + and 'data' in data_cache + and 'ts' in data_cache + and (int(time.time()) - data_cache['ts']) < + MANUFACTURER_EFFECTIVE_TIME + ): + self._data = data_cache['data'] + _LOGGER.debug('load manufacturer data success') + return + + data_cloud = await self._main_loop.run_in_executor( + None, self.__get_manufacturer_data) + if data_cloud: + await self._storage.save_async( + domain=self.DOMAIN, name='manufacturer', + data={'data': data_cloud, 'ts': int(time.time())}) + self._data = data_cloud + _LOGGER.debug('update manufacturer data success') + else: + if data_cache: + self._data = data_cache.get('data', None) + _LOGGER.error('load manufacturer data failed, use local data') + else: + _LOGGER.error('load manufacturer data failed') + + async def deinit_async(self) -> None: + self._data = None + + def get_name(self, short_name: str) -> str: + if not self._data or not short_name or short_name not in self._data: + return short_name + return self._data[short_name].get('name', None) or short_name + + def __get_manufacturer_data(self) -> dict: + try: + request = Request( + 'https://cdn.cnbj1.fds.api.mi-img.com/res-conf/xiaomi-home/' + 'manufacturer.json', + method='GET') + content: bytes = None + with urlopen(request) as response: + content = response.read() + return ( + json.loads(str(content, 'utf-8')) + if content else None) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.error('get manufacturer info failed, %s', err) + return None diff --git a/custom_components/xiaomi_home/miot/specs/bool_trans.json b/custom_components/xiaomi_home/miot/specs/bool_trans.json new file mode 100644 index 0000000..649310d --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/bool_trans.json @@ -0,0 +1,235 @@ +{ + "data": { + "urn:miot-spec-v2:property:air-cooler:000000EB": "open_close", + "urn:miot-spec-v2:property:alarm:00000012": "open_close", + "urn:miot-spec-v2:property:anion:00000025": "open_close", + "urn:miot-spec-v2:property:auto-cleanup:00000124": "open_close", + "urn:miot-spec-v2:property:auto-deodorization:00000125": "open_close", + "urn:miot-spec-v2:property:auto-keep-warm:0000002B": "open_close", + "urn:miot-spec-v2:property:automatic-feeding:000000F0": "open_close", + "urn:miot-spec-v2:property:blow:000000CD": "open_close", + "urn:miot-spec-v2:property:deodorization:000000C6": "open_close", + "urn:miot-spec-v2:property:dns-auto-mode:000000DC": "open_close", + "urn:miot-spec-v2:property:current-physical-control-lock:00000099": "open_close", + "urn:miot-spec-v2:property:dryer:00000027": "open_close", + "urn:miot-spec-v2:property:eco:00000024": "open_close", + "urn:miot-spec-v2:property:glimmer-full-color:00000089": "open_close", + "urn:miot-spec-v2:property:guard-mode:000000B6": "open_close", + "urn:miot-spec-v2:property:heater:00000026": "open_close", + "urn:miot-spec-v2:property:heating:000000C7": "open_close", + "urn:miot-spec-v2:property:horizontal-swing:00000017": "open_close", + "urn:miot-spec-v2:property:hot-water-recirculation:0000011C": "open_close", + "urn:miot-spec-v2:property:image-distortion-correction:0000010F": "open_close", + "urn:miot-spec-v2:property:mute:00000040": "open_close", + "urn:miot-spec-v2:property:motion-detection:00000056": "open_close", + "urn:miot-spec-v2:property:motion-tracking:0000008A": "open_close", + "urn:miot-spec-v2:property:off-delay:00000053": "open_close", + "urn:miot-spec-v2:property:on:00000006": "open_close", + "urn:miot-spec-v2:property:physical-controls-locked:0000001D": "open_close", + "urn:miot-spec-v2:property:preheat:00000103": "open_close", + "urn:miot-spec-v2:property:sleep-aid-mode:0000010B": "open_close", + "urn:miot-spec-v2:property:sleep-mode:00000028": "open_close", + "urn:miot-spec-v2:property:soft-wind:000000CF": "open_close", + "urn:miot-spec-v2:property:speed-control:000000E8": "open_close", + "urn:miot-spec-v2:property:time-watermark:00000087": "open_close", + "urn:miot-spec-v2:property:un-straight-blowing:00000100": "open_close", + "urn:miot-spec-v2:property:uv:00000029": "open_close", + "urn:miot-spec-v2:property:valve-switch:000000FE": "open_close", + "urn:miot-spec-v2:property:ventilation:000000CE": "open_close", + "urn:miot-spec-v2:property:vertical-swing:00000018": "open_close", + "urn:miot-spec-v2:property:wake-up-mode:00000107": "open_close", + "urn:miot-spec-v2:property:water-pump:000000F2": "open_close", + "urn:miot-spec-v2:property:watering:000000CC": "open_close", + "urn:miot-spec-v2:property:wdr-mode:00000088": "open_close", + "urn:miot-spec-v2:property:wet:0000002A": "open_close", + "urn:miot-spec-v2:property:wifi-band-combine:000000E0": "open_close", + "urn:miot-spec-v2:property:anti-fake:00000130": "yes_no", + "urn:miot-spec-v2:property:arrhythmia:000000B4": "yes_no", + "urn:miot-spec-v2:property:card-insertion-state:00000106": "yes_no", + "urn:miot-spec-v2:property:delay:0000014F": "yes_no", + "urn:miot-spec-v2:property:driving-status:000000B9": "yes_no", + "urn:miot-spec-v2:property:local-storage:0000011E": "yes_no", + "urn:miot-spec-v2:property:motor-reverse:00000072": "yes_no", + "urn:miot-spec-v2:property:plasma:00000132": "yes_no", + "urn:miot-spec-v2:property:seating-state:000000B8": "yes_no", + "urn:miot-spec-v2:property:silent-execution:000000FB": "yes_no", + "urn:miot-spec-v2:property:snore-state:0000012A": "yes_no", + "urn:miot-spec-v2:property:submersion-state:0000007E": "yes_no", + "urn:miot-spec-v2:property:wifi-ssid-hidden:000000E3": "yes_no", + "urn:miot-spec-v2:property:wind-reverse:00000117": "yes_no", + "urn:miot-spec-v2:property:motion-state:0000007D": "motion_state", + "urn:miot-spec-v2:property:contact-state:0000007C": "contact_state" + }, + "translate": { + "default": { + "zh-Hans": { + "true": "真", + "false": "假" + }, + "zh-Hant": { + "true": "真", + "false": "假" + }, + "en": { + "true": "True", + "false": "False" + }, + "de": { + "true": "Wahr", + "false": "Falsch" + }, + "es": { + "true": "Verdadero", + "false": "Falso" + }, + "fr": { + "true": "Vrai", + "false": "Faux" + }, + "ru": { + "true": "Истина", + "false": "Ложь" + }, + "ja": { + "true": "真", + "false": "偽" + } + }, + "open_close": { + "zh-Hans": { + "true": "开启", + "false": "关闭" + }, + "zh-Hant": { + "true": "開啟", + "false": "關閉" + }, + "en": { + "true": "Open", + "false": "Close" + }, + "de": { + "true": "Öffnen", + "false": "Schließen" + }, + "es": { + "true": "Abierto", + "false": "Cerrado" + }, + "fr": { + "true": "Ouvert", + "false": "Fermer" + }, + "ru": { + "true": "Открыть", + "false": "Закрыть" + }, + "ja": { + "true": "開く", + "false": "閉じる" + } + }, + "yes_no": { + "zh-Hans": { + "true": "是", + "false": "否" + }, + "zh-Hant": { + "true": "是", + "false": "否" + }, + "en": { + "true": "Yes", + "false": "No" + }, + "de": { + "true": "Ja", + "false": "Nein" + }, + "es": { + "true": "Sí", + "false": "No" + }, + "fr": { + "true": "Oui", + "false": "Non" + }, + "ru": { + "true": "Да", + "false": "Нет" + }, + "ja": { + "true": "はい", + "false": "いいえ" + } + }, + "motion_state": { + "zh-Hans": { + "true": "有人", + "false": "无人" + }, + "zh-Hant": { + "true": "有人", + "false": "無人" + }, + "en": { + "true": "Motion Detected", + "false": "No Motion Detected" + }, + "de": { + "true": "Bewegung erkannt", + "false": "Keine Bewegung erkannt" + }, + "es": { + "true": "Movimiento detectado", + "false": "No se detecta movimiento" + }, + "fr": { + "true": "Mouvement détecté", + "false": "Aucun mouvement détecté" + }, + "ru": { + "true": "Обнаружено движение", + "false": "Движение не обнаружено" + }, + "ja": { + "true": "動きを検知", + "false": "動きが検出されません" + } + }, + "contact_state": { + "zh-Hans": { + "true": "接触", + "false": "分离" + }, + "zh-Hant": { + "true": "接觸", + "false": "分離" + }, + "en": { + "true": "Contact", + "false": "No Contact" + }, + "de": { + "true": "Kontakt", + "false": "Kein Kontakt" + }, + "es": { + "true": "Contacto", + "false": "Sin contacto" + }, + "fr": { + "true": "Contact", + "false": "Pas de contact" + }, + "ru": { + "true": "Контакт", + "false": "Нет контакта" + }, + "ja": { + "true": "接触", + "false": "非接触" + } + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/multi_lang.json b/custom_components/xiaomi_home/miot/specs/multi_lang.json new file mode 100644 index 0000000..ed1fd96 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/multi_lang.json @@ -0,0 +1,158 @@ +{ + "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { + "en": { + "service:001": "Device Information", + "service:001:property:003": "Device ID", + "service:001:property:005": "Serial Number (SN)", + "service:002": "Gateway", + "service:002:event:001": "Network Changed", + "service:002:event:002": "Network Changed", + "service:002:property:001": "Access Method", + "service:002:property:001:valuelist:000": "Wired", + "service:002:property:001:valuelist:001": "5G Wireless", + "service:002:property:001:valuelist:002": "2.4G Wireless", + "service:002:property:002": "IP Address", + "service:002:property:003": "WiFi Network Name", + "service:002:property:004": "Current Time", + "service:002:property:005": "DHCP Server MAC Address", + "service:003": "Indicator Light", + "service:003:property:001": "Switch", + "service:004": "Virtual Service", + "service:004:action:001": "Generate Virtual Event", + "service:004:event:001": "Virtual Event Occurred", + "service:004:property:001": "Event Name" + }, + "es": { + "service:001": "Información del dispositivo", + "service:001:property:003": "ID del dispositivo", + "service:001:property:005": "Número de serie (SN)", + "service:002": "Puerta de enlace", + "service:002:event:001": "Cambio de red", + "service:002:event:002": "Cambio de red", + "service:002:property:001": "Método de acceso", + "service:002:property:001:valuelist:000": "Cableado", + "service:002:property:001:valuelist:001": "5G inalámbrico", + "service:002:property:001:valuelist:002": "2.4G inalámbrico", + "service:002:property:002": "Dirección IP", + "service:002:property:003": "Nombre de red WiFi", + "service:002:property:004": "Hora actual", + "service:002:property:005": "Dirección MAC del servidor DHCP", + "service:003": "Luz indicadora", + "service:003:property:001": "Interruptor", + "service:004": "Servicio virtual", + "service:004:action:001": "Generar evento virtual", + "service:004:event:001": "Ocurrió un evento virtual", + "service:004:property:001": "Nombre del evento" + }, + "fr": { + "service:001": "Informations sur l'appareil", + "service:001:property:003": "ID de l'appareil", + "service:001:property:005": "Numéro de série (SN)", + "service:002": "Passerelle", + "service:002:event:001": "Changement de réseau", + "service:002:event:002": "Changement de réseau", + "service:002:property:001": "Méthode d'accès", + "service:002:property:001:valuelist:000": "Câblé", + "service:002:property:001:valuelist:001": "Sans fil 5G", + "service:002:property:001:valuelist:002": "Sans fil 2.4G", + "service:002:property:002": "Adresse IP", + "service:002:property:003": "Nom du réseau WiFi", + "service:002:property:004": "Heure actuelle", + "service:002:property:005": "Adresse MAC du serveur DHCP", + "service:003": "Voyant lumineux", + "service:003:property:001": "Interrupteur", + "service:004": "Service virtuel", + "service:004:action:001": "Générer un événement virtuel", + "service:004:event:001": "Événement virtuel survenu", + "service:004:property:001": "Nom de l'événement" + }, + "ru": { + "service:001": "Информация об устройстве", + "service:001:property:003": "ID устройства", + "service:001:property:005": "Серийный номер (SN)", + "service:002": "Шлюз", + "service:002:event:001": "Сеть изменена", + "service:002:event:002": "Сеть изменена", + "service:002:property:001": "Метод доступа", + "service:002:property:001:valuelist:000": "Проводной", + "service:002:property:001:valuelist:001": "5G Беспроводной", + "service:002:property:001:valuelist:002": "2.4G Беспроводной", + "service:002:property:002": "IP Адрес", + "service:002:property:003": "Название WiFi сети", + "service:002:property:004": "Текущее время", + "service:002:property:005": "MAC адрес DHCP сервера", + "service:003": "Световой индикатор", + "service:003:property:001": "Переключатель", + "service:004": "Виртуальная служба", + "service:004:action:001": "Создать виртуальное событие", + "service:004:event:001": "Произошло виртуальное событие", + "service:004:property:001": "Название события" + }, + "de": { + "service:001": "Geräteinformationen", + "service:001:property:003": "Geräte-ID", + "service:001:property:005": "Seriennummer (SN)", + "service:002": "Gateway", + "service:002:event:001": "Netzwerk geändert", + "service:002:event:002": "Netzwerk geändert", + "service:002:property:001": "Zugriffsmethode", + "service:002:property:001:valuelist:000": "Kabelgebunden", + "service:002:property:001:valuelist:001": "5G Drahtlos", + "service:002:property:001:valuelist:002": "2.4G Drahtlos", + "service:002:property:002": "IP-Adresse", + "service:002:property:003": "WiFi-Netzwerkname", + "service:002:property:004": "Aktuelle Zeit", + "service:002:property:005": "DHCP-Server-MAC-Adresse", + "service:003": "Anzeigelampe", + "service:003:property:001": "Schalter", + "service:004": "Virtueller Dienst", + "service:004:action:001": "Virtuelles Ereignis erzeugen", + "service:004:event:001": "Virtuelles Ereignis aufgetreten", + "service:004:property:001": "Ereignisname" + }, + "ja": { + "service:001": "デバイス情報", + "service:001:property:003": "デバイスID", + "service:001:property:005": "シリアル番号 (SN)", + "service:002": "ゲートウェイ", + "service:002:event:001": "ネットワークが変更されました", + "service:002:event:002": "ネットワークが変更されました", + "service:002:property:001": "アクセス方法", + "service:002:property:001:valuelist:000": "有線", + "service:002:property:001:valuelist:001": "5G ワイヤレス", + "service:002:property:001:valuelist:002": "2.4G ワイヤレス", + "service:002:property:002": "IPアドレス", + "service:002:property:003": "WiFiネットワーク名", + "service:002:property:004": "現在の時間", + "service:002:property:005": "DHCPサーバーMACアドレス", + "service:003": "インジケータライト", + "service:003:property:001": "スイッチ", + "service:004": "バーチャルサービス", + "service:004:action:001": "バーチャルイベントを生成", + "service:004:event:001": "バーチャルイベントが発生しました", + "service:004:property:001": "イベント名" + }, + "zh-Hant": { + "service:001": "設備信息", + "service:001:property:003": "設備ID", + "service:001:property:005": "序號 (SN)", + "service:002": "網關", + "service:002:event:001": "網路發生變化", + "service:002:event:002": "網路發生變化", + "service:002:property:001": "接入方式", + "service:002:property:001:valuelist:000": "有線", + "service:002:property:001:valuelist:001": "5G 無線", + "service:002:property:001:valuelist:002": "2.4G 無線", + "service:002:property:002": "IP地址", + "service:002:property:003": "WiFi網路名稱", + "service:002:property:004": "當前時間", + "service:002:property:005": "DHCP伺服器MAC地址", + "service:003": "指示燈", + "service:003:property:001": "開關", + "service:004": "虛擬服務", + "service:004:action:001": "產生虛擬事件", + "service:004:event:001": "虛擬事件發生", + "service:004:property:001": "事件名稱" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/spec_filter.json b/custom_components/xiaomi_home/miot/specs/spec_filter.json new file mode 100644 index 0000000..0a72547 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/spec_filter.json @@ -0,0 +1,63 @@ +{ + "urn:miot-spec-v2:device:health-pot:0000A051:chunmi-a1": { + "services": [ + "5" + ] + }, + "urn:miot-spec-v2:device:curtain:0000A00C:lumi-hmcn01": { + "services": [ + "4", + "7", + "8" + ], + "properties": [ + "5.1" + ] + }, + "urn:miot-spec-v2:device:light:0000A001:philips-strip3": { + "services": [ + "1", + "3" + ], + "properties": [ + "2.2" + ] + }, + "urn:miot-spec-v2:device:gateway:0000A019:xiaomi-hub1": { + "events": [ + "2.1" + ] + }, + "urn:miot-spec-v2:device:motion-sensor:0000A014:xiaomi-pir1": { + "services": [ + "1", + "5" + ] + }, + "urn:miot-spec-v2:device:light:0000A001:yeelink-color2": { + "properties": [ + "3.*", + "2.5" + ] + }, + "urn:miot-spec-v2:device:light:0000A001:yeelink-dnlight2": { + "services": [ + "3" + ] + }, + "urn:miot-spec-v2:device:light:0000A001:yeelink-mbulb3": { + "services": [ + "3" + ] + }, + "urn:miot-spec-v2:device:air-purifier:0000A007:zhimi-ma4": { + "services": [ + "10" + ], + "properties": [ + "9.*", + "13.*", + "15.*" + ] + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/miot/specs/specv2entity.py b/custom_components/xiaomi_home/miot/specs/specv2entity.py new file mode 100644 index 0000000..e1d14a2 --- /dev/null +++ b/custom_components/xiaomi_home/miot/specs/specv2entity.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Conversion rules of MIoT-Spec-V2 instance to Home Assistant entity. +""" +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.event import EventDeviceClass + +# pylint: disable=pointless-string-statement +"""SPEC_DEVICE_TRANS_MAP +{ + '':{ + 'required':{ + '':{ + 'required':{ + 'properties': { + '': set + }, + 'events': set, + 'actions': set + }, + 'optional':{ + 'properties': set, + 'events': set, + 'actions': set + } + } + }, + 'optional':{ + '':{ + 'required':{ + 'properties': { + '': set + }, + 'events': set, + 'actions': set + }, + 'optional':{ + 'properties': set, + 'events': set, + 'actions': set + } + } + }, + 'entity': str + } +} +""" +SPEC_DEVICE_TRANS_MAP: dict[str, dict | str] = { + 'humidifier': { + 'required': { + 'humidifier': { + 'required': { + 'properties': { + 'on': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'mode', 'target-humidity'} + } + } + }, + 'optional': { + 'environment': { + 'required': { + 'properties': { + 'relative-humidity': {'read', 'write'} + } + } + } + }, + 'entity': 'humidifier' + }, + 'dehumidifier': { + 'required': { + 'dehumidifier': { + 'required': { + 'properties': { + 'on': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'mode', 'target-humidity'} + } + }, + }, + 'optional': { + 'environment': { + 'required': { + 'properties': { + 'relative-humidity': {'read', 'write'} + } + } + } + }, + 'entity': 'dehumidifier' + }, + 'vacuum': { + 'required': { + 'vacuum': { + 'required': { + 'actions': {'start-sweep', 'stop-sweeping'}, + }, + 'optional': { + 'properties': {'status', 'fan-level'}, + 'actions': { + 'pause-sweeping', + 'continue-sweep', + 'stop-and-gocharge' + } + }, + + } + }, + 'optional': { + 'identify': { + 'required': { + 'actions': {'identify'} + } + }, + 'battery': { + 'required': { + 'properties': { + 'battery-level': {'read'} + }, + } + }, + }, + 'entity': 'vacuum' + }, + 'air-conditioner': { + 'required': { + 'air-conditioner': { + 'required': { + 'properties': { + 'on': {'read', 'write'}, + 'mode': {'read', 'write'}, + 'target-temperature': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'target-humidity'} + }, + } + }, + 'optional': { + 'fan-control': { + 'required': {}, + 'optional': { + 'properties': { + 'on', + 'fan-level', + 'horizontal-swing', + 'vertical-swing'}} + }, + 'environment': { + 'required': {}, + 'optional': { + 'properties': {'temperature', 'relative-humidity'} + } + }, + 'air-condition-outlet-matching': { + 'required': {}, + 'optional': { + 'properties': {'ac-state'} + } + } + }, + 'entity': 'climate' + }, + 'air-condition-outlet': 'air-conditioner' +} + +"""SPEC_SERVICE_TRANS_MAP +{ + '':{ + 'required':{ + 'properties': { + '': set + }, + 'events': set, + 'actions': set + }, + 'optional':{ + 'properties': set, + 'events': set, + 'actions': set + }, + 'entity': str + } +} +""" +SPEC_SERVICE_TRANS_MAP: dict[str, dict | str] = { + 'light': { + 'required': { + 'properties': { + 'on': {'read', 'write'} + } + }, + 'optional': { + 'properties': { + 'mode', 'brightness', 'color', 'color-temperature' + } + }, + 'entity': 'light' + }, + 'indicator-light': 'light', + 'ambient-light': 'light', + 'night-light': 'light', + 'white-light': 'light', + 'fan': { + 'required': { + 'properties': { + 'on': {'read', 'write'}, + 'fan-level': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'mode', 'horizontal-swing'} + }, + 'entity': 'fan' + }, + 'fan-control': 'fan', + 'ceiling-fan': 'fan', + 'water-heater': { + 'required': { + 'properties': { + 'on': {'read', 'write'} + } + }, + 'optional': { + 'properties': {'on', 'temperature', 'target-temperature', 'mode'} + }, + 'entity': 'water_heater' + }, + 'curtain': { + 'required': { + 'properties': { + 'motor-control': {'write'} + } + }, + 'optional': { + 'properties': { + 'motor-control', 'status', 'current-position', 'target-position' + } + }, + 'entity': 'cover' + }, + 'window-opener': 'curtain' +} + +"""SPEC_PROP_TRANS_MAP +{ + 'entities':{ + '':{ + 'format': set, + 'access': set + } + }, + 'properties': { + '':{ + 'device_class': str, + 'entity': str + } + } +} +""" +SPEC_PROP_TRANS_MAP: dict[str, dict | str] = { + 'entities': { + 'sensor': { + 'format': {'int', 'float'}, + 'access': {'read'} + }, + 'switch': { + 'format': {'bool'}, + 'access': {'read', 'write'} + } + }, + 'properties': { + 'temperature': { + 'device_class': SensorDeviceClass.TEMPERATURE, + 'entity': 'sensor' + }, + 'relative-humidity': { + 'device_class': SensorDeviceClass.HUMIDITY, + 'entity': 'sensor' + }, + 'air-quality-index': { + 'device_class': SensorDeviceClass.AQI, + 'entity': 'sensor' + }, + 'pm2.5-density': { + 'device_class': SensorDeviceClass.PM25, + 'entity': 'sensor' + }, + 'pm10-density': { + 'device_class': SensorDeviceClass.PM10, + 'entity': 'sensor' + }, + 'pm1': { + 'device_class': SensorDeviceClass.PM1, + 'entity': 'sensor' + }, + 'atmospheric-pressure': { + 'device_class': SensorDeviceClass.ATMOSPHERIC_PRESSURE, + 'entity': 'sensor' + }, + 'tvoc-density': { + 'device_class': SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + 'entity': 'sensor' + }, + 'voc-density': 'tvoc-density', + 'battery-level': { + 'device_class': SensorDeviceClass.BATTERY, + 'entity': 'sensor' + }, + 'voltage': { + 'device_class': SensorDeviceClass.VOLTAGE, + 'entity': 'sensor' + }, + 'illumination': { + 'device_class': SensorDeviceClass.ILLUMINANCE, + 'entity': 'sensor' + }, + 'no-one-determine-time': { + 'device_class': SensorDeviceClass.DURATION, + 'entity': 'sensor' + }, + 'has-someone-duration': 'no-one-determine-time', + 'no-one-duration': 'no-one-determine-time' + } +} + +"""SPEC_EVENT_TRANS_MAP +{ + '': str +} +""" +SPEC_EVENT_TRANS_MAP: dict[str, str] = { + 'click': EventDeviceClass.BUTTON, + 'double-click': EventDeviceClass.BUTTON, + 'long-press': EventDeviceClass.BUTTON, + 'motion-detected': EventDeviceClass.MOTION, + 'no-motion': EventDeviceClass.MOTION, + 'doorbell-ring': EventDeviceClass.DOORBELL +} + +SPEC_ACTION_TRANS_MAP = { + +} diff --git a/custom_components/xiaomi_home/miot/web_pages.py b/custom_components/xiaomi_home/miot/web_pages.py new file mode 100644 index 0000000..16348c5 --- /dev/null +++ b/custom_components/xiaomi_home/miot/web_pages.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +MIoT redirect web pages. +""" + + +def oauth_redirect_page(lang: str, status: str) -> str: + """Return oauth redirect page.""" + return ''' + + + + + + + + + + +
+ +
+ 编组 + Created with Sketch. + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ + +
+ + + + ''' diff --git a/custom_components/xiaomi_home/notify.py b/custom_components/xiaomi_home/notify.py new file mode 100644 index 0000000..42d0d17 --- /dev/null +++ b/custom_components/xiaomi_home/notify.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Notify entities for Xiaomi Home. +""" +from __future__ import annotations +import json +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.notify import NotifyEntity + +from .miot.miot_spec import MIoTSpecAction +from .miot.miot_device import MIoTDevice, MIoTActionEntity +from .miot.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for action in miot_device.action_list.get('notify', []): + new_entities.append(Notify(miot_device=miot_device, spec=action)) + + if new_entities: + async_add_entities(new_entities) + + +class Notify(MIoTActionEntity, NotifyEntity): + """Notify entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecAction) -> None: + """Initialize the Notify.""" + super().__init__(miot_device=miot_device, spec=spec) + self._attr_extra_state_attributes = {} + action_in: str = ', '.join([ + f'{prop.description_trans}({prop.format_})' + for prop in self.spec.in_]) + self._attr_extra_state_attributes['action params'] = f'[{action_in}]' + + async def async_send_message( + self, message: str, title: Optional[str] = None + ) -> None: + """Send a message.""" + del title + if not message: + _LOGGER.error( + 'action exec failed, %s(%s), empty action params', + self.name, self.entity_id) + return + try: + in_list: list = json.loads(message) + except json.JSONDecodeError: + _LOGGER.error( + 'action exec failed, %s(%s), invalid action params format, %s', + self.name, self.entity_id, message) + return + + if not isinstance(in_list, list) or len(in_list) != len(self.spec.in_): + _LOGGER.error( + 'action exec failed, %s(%s), invalid action params, %s', + self.name, self.entity_id, message) + return + + in_value: list[dict] = [] + for index, prop in enumerate(self.spec.in_): + if type(in_list[index]).__name__ != prop.format_: + logging.error( + 'action exec failed, %s(%s), invalid params item, ' + 'which item(%s) in the list must be %s, %s', + self.name, self.entity_id, prop.description_trans, + prop.format_, message) + return + in_value.append({'piid': prop.iid, 'value': in_list[index]}) + return await self.action_async(in_list=in_value) diff --git a/custom_components/xiaomi_home/number.py b/custom_components/xiaomi_home/number.py new file mode 100644 index 0000000..53bc09c --- /dev/null +++ b/custom_components/xiaomi_home/number.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Number entities for Xiaomi Home. +""" +from __future__ import annotations +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.number import NumberEntity + +from .miot.const import DOMAIN +from .miot.miot_spec import MIoTSpecProperty +from .miot.miot_device import MIoTDevice, MIoTPropertyEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for prop in miot_device.prop_list.get('number', []): + new_entities.append(Number(miot_device=miot_device, spec=prop)) + + if new_entities: + async_add_entities(new_entities) + + +class Number(MIoTPropertyEntity, NumberEntity): + """Number entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + """Initialize the Notify.""" + super().__init__(miot_device=miot_device, spec=spec) + # Set device_class + self._attr_device_class = spec.device_class + # Set unit + if self.spec.external_unit: + self._attr_native_unit_of_measurement = self.spec.external_unit + # Set icon + if self.spec.icon: + self._attr_icon = self.spec.icon + # Set value range + if self._value_range: + self._attr_native_min_value = self._value_range['min'] + self._attr_native_max_value = self._value_range['max'] + self._attr_native_step = self._value_range['step'] + + @property + def native_value(self) -> Optional[float]: + """Return the current value of the number.""" + return self._value + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + await self.set_property_async(value=value) diff --git a/custom_components/xiaomi_home/select.py b/custom_components/xiaomi_home/select.py new file mode 100644 index 0000000..4c9bad3 --- /dev/null +++ b/custom_components/xiaomi_home/select.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Select entities for Xiaomi Home. +""" +from __future__ import annotations +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.select import SelectEntity + +from .miot.const import DOMAIN +from .miot.miot_device import MIoTDevice, MIoTPropertyEntity +from .miot.miot_spec import MIoTSpecProperty + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for prop in miot_device.prop_list.get('select', []): + new_entities.append(Select(miot_device=miot_device, spec=prop)) + + if new_entities: + async_add_entities(new_entities) + + +class Select(MIoTPropertyEntity, SelectEntity): + """Select entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + """Initialize the Select.""" + super().__init__(miot_device=miot_device, spec=spec) + self._attr_options = list(self._value_list.values()) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.set_property_async( + value=self.get_vlist_value(description=option)) + + @property + def current_option(self) -> Optional[str]: + """Return the current selected option.""" + return self.get_vlist_description(value=self._value) diff --git a/custom_components/xiaomi_home/sensor.py b/custom_components/xiaomi_home/sensor.py new file mode 100644 index 0000000..7d2e074 --- /dev/null +++ b/custom_components/xiaomi_home/sensor.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Sensor entities for Xiaomi Home. +""" +from __future__ import annotations +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass +from homeassistant.components.sensor import DEVICE_CLASS_UNITS + +from .miot.miot_device import MIoTDevice, MIoTPropertyEntity +from .miot.miot_spec import MIoTSpecProperty +from .miot.const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for prop in miot_device.prop_list.get('sensor', []): + new_entities.append(Sensor(miot_device=miot_device, spec=prop)) + + if new_entities: + async_add_entities(new_entities) + + +class Sensor(MIoTPropertyEntity, SensorEntity): + """Sensor entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + """Initialize the Sensor.""" + super().__init__(miot_device=miot_device, spec=spec) + # Set device_class + if self._value_list: + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_icon = 'mdi:message-text' + self._attr_native_unit_of_measurement = None + self._attr_options = list(self._value_list.values()) + else: + self._attr_device_class = spec.device_class + if spec.external_unit: + self._attr_native_unit_of_measurement = spec.external_unit + else: + # device_class is not empty but unit is empty. + # Set the default unit according to device_class. + unit_sets = DEVICE_CLASS_UNITS.get( + self._attr_device_class, None) + self._attr_native_unit_of_measurement = list( + unit_sets)[0] if unit_sets else None + # Set icon + if spec.icon: + self._attr_icon = spec.icon + + @property + def native_value(self) -> any: + """Return the current value of the sensor.""" + if self._value_range and isinstance(self._value, (int, float)): + if ( + self._value < self._value_range['min'] + or self._value > self._value_range['max'] + ): + _LOGGER.info( + '%s, data exception, out of range, %s, %s', + self.entity_id, self._value, self._value_range) + if self._value_list: + return self._value_list.get(self._value, None) + if isinstance(self._value, str): + return self._value[:255] + return self._value diff --git a/custom_components/xiaomi_home/switch.py b/custom_components/xiaomi_home/switch.py new file mode 100644 index 0000000..dc54ec6 --- /dev/null +++ b/custom_components/xiaomi_home/switch.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Switch entities for Xiaomi Home. +""" +from __future__ import annotations +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.switch import SwitchEntity + +from .miot.miot_device import MIoTDevice +from .miot.miot_spec import MIoTSpecProperty +from .miot.miot_device import MIoTPropertyEntity +from .miot.const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for prop in miot_device.prop_list.get('switch', []): + new_entities.append(Switch(miot_device=miot_device, spec=prop)) + + if new_entities: + async_add_entities(new_entities) + + +class Switch(MIoTPropertyEntity, SwitchEntity): + """Switch entities for Xiaomi Home.""" + # pylint: disable=unused-argument + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + """Initialize the Switch.""" + super().__init__(miot_device=miot_device, spec=spec) + # Set device_class + self._attr_device_class = spec.device_class + + @property + def is_on(self) -> bool: + """On/Off state.""" + return self._value is True + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.set_property_async(value=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.set_property_async(value=False) + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the switch.""" + await self.set_property_async(value=not self.is_on) diff --git a/custom_components/xiaomi_home/test/json_format.py b/custom_components/xiaomi_home/test/json_format.py new file mode 100644 index 0000000..3363d51 --- /dev/null +++ b/custom_components/xiaomi_home/test/json_format.py @@ -0,0 +1,41 @@ +"""Check if a file is a valid JSON file. + +Usage: +python json_check.py [JSON file path] + +Example: +python json_check.py multi_lang.json +""" +import argparse +import json +import sys +import os + +def check_json_file(file_path): + try: + with open(file_path, "r", encoding="utf-8") as file: + json.load(file) + return True + except FileNotFoundError: + print(file_path, "is not found.") + return False + except json.JSONDecodeError: + print(file_path, "is not a valid JSON file.") + return False + +def main(): + parser = argparse.ArgumentParser( + description="Check if a file is a valid JSON file.") + parser.add_argument("file_path", help="JSON file path") + args = parser.parse_args() + script_name = os.path.basename(__file__) + file_name = os.path.basename(args.file_path) + + if not check_json_file(args.file_path): + print(args.file_path, script_name, "FAIL") + sys.exit(1) + + print(script_name, file_name, "PASS") + +if __name__ == "__main__": + main() diff --git a/custom_components/xiaomi_home/test/rule_format.py b/custom_components/xiaomi_home/test/rule_format.py new file mode 100644 index 0000000..975caa0 --- /dev/null +++ b/custom_components/xiaomi_home/test/rule_format.py @@ -0,0 +1,149 @@ +"""Check if conversion rules are valid. + +The files to be checked are in the directory of ../miot/specs/ +To run this script, PYTHONPATH must be set first. +See test_all.sh for the usage. + +You can run all tests by running: +``` +./test_all.sh +``` +""" +import sys +import os +import json + +def load_json(file_path: str) -> dict: + """Load json file.""" + with open(file_path, "r", encoding="utf-8") as file: + data = json.load(file) + return data + +def dict_str_str(d: dict) -> bool: + """restricted format: dict[str, str]""" + if not isinstance(d, dict): + return False + for k, v in d.items(): + if not isinstance(k, str) or not isinstance(v, str): + return False + return True + +def dict_str_dict(d: dict) -> bool: + """restricted format: dict[str, dict]""" + if not isinstance(d, dict): + return False + for k, v in d.items(): + if not isinstance(k, str) or not isinstance(v, dict): + return False + return True + +def nested_2_dict_str_str(d: dict) -> bool: + """restricted format: dict[str, dict[str, str]]""" + if not dict_str_dict(d): + return False + for v in d.values(): + if not dict_str_str(v): + return False + return True + +def nested_3_dict_str_str(d: dict) -> bool: + """restricted format: dict[str, dict[str, dict[str, str]]]""" + if not dict_str_dict(d): + return False + for v in d.values(): + if not nested_2_dict_str_str(v): + return False + return True + +def spec_filter(d: dict) -> bool: + """restricted format: dict[str, dict[str, list]]""" + if not dict_str_dict(d): + return False + for value in d.values(): + for k, v in value.items(): + if not isinstance(k, str) or not isinstance(v, list): + return False + if not all(isinstance(i, str) for i in v): + return False + return True + +def bool_trans(d: dict) -> bool: + """dict[str, dict[str, str] | dict[str, dict[str, str]] ]""" + if not isinstance(d, dict): + return False + if "data" not in d or "translate" not in d: + return False + if not dict_str_str(d["data"]): + return False + if not nested_3_dict_str_str(d["translate"]): + return False + + return True + + +def main(): + script_name = os.path.basename(__file__) + + source_dir = "../miot/specs" + if not bool_trans(load_json(f"{source_dir}/bool_trans.json")): + print(script_name, "bool_trans FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/multi_lang.json")): + print(script_name, "multi_lang FAIL") + sys.exit(1) + if not spec_filter(load_json(f"{source_dir}/spec_filter.json")): + print(script_name, "spec_filter FAIL") + sys.exit(1) + if not nested_2_dict_str_str(load_json( + f"{source_dir}/std_ex_actions.json")): + print(script_name, "std_ex_actions.json FAIL") + sys.exit(1) + if not nested_2_dict_str_str(load_json( + f"{source_dir}/std_ex_devices.json")): + print(script_name, "std_ex_devices.json FAIL") + sys.exit(1) + if not nested_2_dict_str_str(load_json(f"{source_dir}/std_ex_events.json")): + print(script_name, "std_ex_events.json FAIL") + sys.exit(1) + if not nested_2_dict_str_str(load_json( + f"{source_dir}/std_ex_properties.json")): + print(script_name, "std_ex_properties.json FAIL") + sys.exit(1) + if not nested_2_dict_str_str(load_json( + f"{source_dir}/std_ex_services.json")): + print(script_name, "std_ex_services.json FAIL") + sys.exit(1) + if not nested_2_dict_str_str(load_json(f"{source_dir}/std_ex_values.json")): + print(script_name, "std_ex_values.json FAIL") + sys.exit(1) + + source_dir = "../miot/i18n" + if not nested_3_dict_str_str(load_json(f"{source_dir}/de.json")): + print(script_name, "i18n de.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/en.json")): + print(script_name, "i18n en.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/es.json")): + print(script_name, "i18n es.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/fr.json")): + print(script_name, "i18n fr.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/ja.json")): + print(script_name, "i18n ja.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/ru.json")): + print(script_name, "i18n ru.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/zh-Hans.json")): + print(script_name, "i18n zh-Hans.json FAIL") + sys.exit(1) + if not nested_3_dict_str_str(load_json(f"{source_dir}/zh-Hant.json")): + print(script_name, "i18n zh-Hant.json FAIL") + sys.exit(1) + + print(script_name, "PASS") + +if __name__ == "__main__": + main() diff --git a/custom_components/xiaomi_home/test/test_all.sh b/custom_components/xiaomi_home/test/test_all.sh new file mode 100755 index 0000000..bf7b13c --- /dev/null +++ b/custom_components/xiaomi_home/test/test_all.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -e + +# Get the script path. +script_path=$(dirname "$0") +# Change to the script path. +cd "$script_path" +# Set PYTHONPATH. +cd .. +export PYTHONPATH=`pwd` +echo "PYTHONPATH=$PYTHONPATH" +cd - + +# Run the tests. +export source_dir="../miot/specs" +python3 json_format.py $source_dir/bool_trans.json +python3 json_format.py $source_dir/multi_lang.json +python3 json_format.py $source_dir/spec_filter.json +python3 json_format.py $source_dir/std_ex_actions.json +python3 json_format.py $source_dir/std_ex_devices.json +python3 json_format.py $source_dir/std_ex_events.json +python3 json_format.py $source_dir/std_ex_properties.json +python3 json_format.py $source_dir/std_ex_services.json +python3 json_format.py $source_dir/std_ex_values.json +export source_dir="../miot/i18n" +python3 json_format.py $source_dir/de.json +python3 json_format.py $source_dir/en.json +python3 json_format.py $source_dir/es.json +python3 json_format.py $source_dir/fr.json +python3 json_format.py $source_dir/ja.json +python3 json_format.py $source_dir/ru.json +python3 json_format.py $source_dir/zh-Hans.json +python3 json_format.py $source_dir/zh-Hant.json +python3 rule_format.py diff --git a/custom_components/xiaomi_home/text.py b/custom_components/xiaomi_home/text.py new file mode 100644 index 0000000..a54324d --- /dev/null +++ b/custom_components/xiaomi_home/text.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Text entities for Xiaomi Home. +""" +from __future__ import annotations +import json +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.text import TextEntity + +from .miot.const import DOMAIN +from .miot.miot_spec import MIoTSpecAction, MIoTSpecProperty +from .miot.miot_device import MIoTActionEntity, MIoTDevice, MIoTPropertyEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + new_entities = [] + for miot_device in device_list: + for prop in miot_device.prop_list.get('text', []): + new_entities.append(Text(miot_device=miot_device, spec=prop)) + + for action in miot_device.action_list.get('action_text', []): + new_entities.append(ActionText( + miot_device=miot_device, spec=action)) + + if new_entities: + async_add_entities(new_entities) + + +class Text(MIoTPropertyEntity, TextEntity): + """Text entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecProperty) -> None: + """Initialize the Text.""" + super().__init__(miot_device=miot_device, spec=spec) + + @property + def native_value(self) -> Optional[str]: + """Return the current text value.""" + if isinstance(self._value, str): + return self._value[:255] + return self._value + + async def async_set_value(self, value: str) -> None: + """Set the text value.""" + await self.set_property_async(value=value) + + +class ActionText(MIoTActionEntity, TextEntity): + """Text entities for Xiaomi Home.""" + + def __init__(self, miot_device: MIoTDevice, spec: MIoTSpecAction) -> None: + super().__init__(miot_device=miot_device, spec=spec) + self._attr_extra_state_attributes = {} + self._attr_native_value = '' + action_in: str = ', '.join([ + f'{prop.description_trans}({prop.format_})' + for prop in self.spec.in_]) + self._attr_extra_state_attributes['action params'] = f'[{action_in}]' + # For action debug + self.action_platform = 'action_text' + + async def async_set_value(self, value: str) -> None: + if not value: + return + in_list: list = None + try: + in_list = json.loads(value) + except json.JSONDecodeError as e: + _LOGGER.error( + 'action exec failed, %s(%s), invalid action params format, %s', + self.name, self.entity_id, value) + raise ValueError( + f'action exec failed, {self.name}({self.entity_id}), ' + f'invalid action params format, {value}') from e + if not isinstance(in_list, list) or len(in_list) != len(self.spec.in_): + _LOGGER.error( + 'action exec failed, %s(%s), invalid action params, %s', + self.name, self.entity_id, value) + raise ValueError( + f'action exec failed, {self.name}({self.entity_id}), ' + f'invalid action params, {value}') + in_value: list[dict] = [] + for index, prop in enumerate(self.spec.in_): + if type(in_list[index]).__name__ != prop.format_: + logging.error( + 'action exec failed, %s(%s), invalid params item, which ' + 'item(%s) in the list must be %s, %s', self.name, + self.entity_id, prop.description_trans, prop.format_, value) + raise ValueError( + f'action exec failed, {self.name}({self.entity_id}), ' + f'invalid params item, which item({prop.description_trans})' + f' in the list must be {prop.format_}, {value}') + in_value.append({'piid': prop.iid, 'value': in_list[index]}) + + self._attr_native_value = value + if await self.action_async(in_list=in_value): + self.async_write_ha_state() diff --git a/custom_components/xiaomi_home/translations/de.json b/custom_components/xiaomi_home/translations/de.json new file mode 100644 index 0000000..eadbd4a --- /dev/null +++ b/custom_components/xiaomi_home/translations/de.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "Xiaomi Home Integration", + "step": { + "eula": { + "title": "Risikohinweis", + "description": "1. Ihre **Xiaomi-Benutzerinformationen und Geräteinformationen** werden in Ihrem Home Assistant-System gespeichert. **Xiaomi kann die Sicherheit des Home Assistant-Speichermechanismus nicht garantieren**. Sie sind dafür verantwortlich, Ihre Informationen vor Diebstahl zu schützen.\r\n2. Diese Integration wird von der Open-Source-Community unterstützt und gewartet. Es können jedoch Stabilitätsprobleme oder andere Probleme auftreten. Wenn Sie auf ein Problem stoßen, das mit dieser Integration zusammenhängt, sollten Sie **die Open-Source-Community um Hilfe bitten, anstatt sich an den Xiaomi Home Kundendienst zu wenden**.\r\n3. Sie benötigen bestimmte technische Fähigkeiten, um Ihre lokale Laufzeitumgebung zu warten. Diese Integration ist für Anfänger nicht geeignet. \r\n4. Bevor Sie diese Integration verwenden, lesen Sie bitte die **README-Datei sorgfältig durch**.\r\n5. Um eine stabile Nutzung der Integration zu gewährleisten und Missbrauch der Schnittstelle zu verhindern, **darf diese Integration nur in Home Assistant verwendet werden. Weitere Informationen finden Sie in der LICENSE**.\r\n", + "data": { + "eula": "Ich habe das oben genannte Risiko zur Kenntnis genommen und übernehme freiwillig die damit verbundenen Risiken durch die Verwendung der Integration." + } + }, + "auth_config": { + "title": "Grundkonfiguration", + "description": "### Anmeldegebiet\r\nWählen Sie das Gebiet, in dem sich Ihr Xiaomi Home-Konto befindet. Sie können es in der Xiaomi Home App unter `Mein (unten im Menü) > Weitere Einstellungen > Über Xiaomi Home` überprüfen.\r\n### Sprache\r\nWählen Sie die Sprache, in der Geräte und Entitätsnamen angezeigt werden. Teile von Sätzen, die nicht übersetzt sind, werden in Englisch angezeigt.\r\n### OAuth2-Authentifizierungs-Umleitungs-URL\r\nDie Umleitungs-URL für die OAuth2-Authentifizierung lautet **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant muss im selben lokalen Netzwerk wie das aktuelle Betriebsterminal (z. B. ein persönlicher Computer) und das Betriebsterminal muss über diese Adresse auf die Home Assistant-Homepage zugreifen können. Andernfalls kann die Anmeldeauthentifizierung fehlschlagen.\r\n### Hinweis\r\n- Für Benutzer mit Hunderten oder mehr Mi Home-Geräten wird das erste Hinzufügen der Integration einige Zeit in Anspruch nehmen. Bitte haben Sie Geduld.\r\n- Wenn Home Assistant in einer Docker-Umgebung läuft, stellen Sie bitte sicher, dass der Docker-Netzwerkmodus auf host eingestellt ist, da sonst die lokale Steuerungsfunktion möglicherweise nicht richtig funktioniert.\r\n- Die lokale Steuerungsfunktion der Integration hat einige Abhängigkeiten. Bitte lesen Sie das README sorgfältig.\r\n", + "data": { + "cloud_server": "Anmeldegebiet", + "integration_language": "Sprache", + "oauth_redirect_url": "OAuth2-Authentifizierungs-Umleitungs-URL" + } + }, + "oauth_error": { + "title": "Fehler bei der Anmeldung", + "description": "Klicken Sie auf \"Weiter\", um es erneut zu versuchen." + }, + "devices_filter": { + "title": "Familie und Geräte auswählen", + "description": "## Gebrauchsanweisung\r\n### Steuerungsmodus\r\n- Automatisch: Wenn im lokalen Netzwerk ein verfügbarer Xiaomi-Zentralgateway vorhanden ist, wird Home Assistant bevorzugt Steuerbefehle über den Zentralgateway senden, um eine lokale Steuerung zu ermöglichen. Wenn im lokalen Netzwerk kein Zentralgateway vorhanden ist, wird versucht, Steuerbefehle über das Xiaomi-OT-Protokoll zu senden, um eine lokale Steuerung zu ermöglichen. Nur wenn die oben genannten Bedingungen für die lokale Steuerung nicht erfüllt sind, werden die Steuerbefehle über die Cloud gesendet.\r\n- Cloud: Steuerbefehle werden nur über die Cloud gesendet.\r\n### Familienimport für importierte Geräte\r\nDie Integration fügt Geräte aus den ausgewählten Familien hinzu.\r\n### Raumnamensynchronisationsmodus\r\nWenn Geräte von der Xiaomi Home App zu Home Assistant synchronisiert werden, wird die Bezeichnung des Bereichs, in dem sich die Geräte in Home Assistant befinden, nach folgenden Regeln benannt. Beachten Sie, dass das Synchronisieren von Geräten den von Xiaomi Home App festgelegten Familien- und Raum-Einstellungen nicht ändert.\r\n- Nicht synchronisieren: Das Gerät wird keinem Bereich hinzugefügt.\r\n- Andere Optionen: Der Bereich, in den das Gerät aufgenommen wird, wird nach dem Namen der Familie oder des Raums in der Xiaomi Home App benannt.\r\n### Action-Debug-Modus\r\nFür von MIoT-Spec-V2 definierte Gerätemethoden wird neben der Benachrichtigungs-Entität auch eine Texteingabe-Entität generiert. Damit können Sie bei der Fehlerbehebung Steuerbefehle an das Gerät senden.\r\n### Verstecke Nicht-Standard-Entitäten\r\nVerstecke Entitäten, die von nicht standardmäßigen MIoT-Spec-V2-Instanzen mit einem Namen beginnen, der mit einem \"*\" beginnt.\r\n\r\n \r\n### Hallo {nick_name}! Bitte wählen Sie den Steuerungsmodus der Integration sowie die Familie aus, in der sich die hinzuzufügenden Geräte befinden.\r\n", + "data": { + "ctrl_mode": "Steuerungsmodus", + "home_infos": "Familienimport für importierte Geräte", + "area_name_rule": "Raumnamensynchronisationsmodus", + "action_debug": "Action-Debug-Modus", + "hide_non_standard_entities": "Verstecke Nicht-Standard-Entitäten" + } + } + }, + "progress": { + "oauth": "### {link_left}Klicken Sie hier, um sich anzumelden{link_right}\r\n(Sie werden nach erfolgreicher Anmeldung automatisch zur nächsten Seite weitergeleitet)" + }, + "error": { + "eula_not_agree": "Bitte lesen Sie den Risikohinweis.", + "get_token_error": "Fehler beim Abrufen von Anmeldeinformationen (OAuth-Token).", + "get_homeinfo_error": "Fehler beim Abrufen von Familieninformationen.", + "mdns_discovery_error": "Lokaler Geräteerkennungsdienst ist nicht verfügbar.", + "get_cert_error": "Fehler beim Abrufen des Gateway-Zertifikats.", + "no_family_selected": "Keine Familie ausgewählt.", + "no_devices": "In der ausgewählten Familie sind keine Geräte enthalten. Bitte wählen Sie eine Familie mit Geräten aus und fahren Sie fort.", + "no_central_device": "Im Modus \"Xiaomi Central Hub Gateway\" muss ein verfügbares Xiaomi Central Hub Gateway im lokalen Netzwerk von Home Assistant vorhanden sein. Stellen Sie sicher, dass die ausgewählte Familie diese Anforderungen erfüllt." + }, + "abort": { + "network_connect_error": "Konfiguration fehlgeschlagen. Netzwerkverbindung fehlgeschlagen. Überprüfen Sie die Netzwerkkonfiguration des Geräts.", + "already_configured": "Dieser Benutzer hat die Konfiguration bereits abgeschlossen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Konfiguration zu ändern.", + "invalid_auth_info": "Authentifizierungsinformationen sind abgelaufen. Gehen Sie zur Integrationsseite und klicken Sie auf die Schaltfläche \"Konfiguration\", um die Authentifizierung erneut durchzuführen.", + "config_flow_error": "Integrationskonfigurationsfehler: {error}" + } + }, + "options": { + "step": { + "auth_config": { + "title": "Authentifizierungskonfiguration", + "description": "Lokale Authentifizierungsinformationen sind abgelaufen. Starten Sie die Authentifizierung erneut.\r\n### Aktuelles Anmeldegebiet: {cloud_server}\r\n### OAuth2-Authentifizierungs-Umleitungs-URL\r\nDie Umleitungs-URL für die OAuth2-Authentifizierung lautet **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant muss im selben lokalen Netzwerk wie das aktuelle Betriebsterminal (z. B. ein persönlicher Computer) und das Betriebsterminal muss über diese Adresse auf die Home Assistant-Homepage zugreifen können. Andernfalls kann die Anmeldeauthentifizierung fehlschlagen.\r\n", + "data": { + "oauth_redirect_url": "OAuth2-Authentifizierungs-Umleitungs-URL" + } + }, + "oauth_error": { + "title": "Fehler bei der Anmeldung", + "description": "Klicken Sie auf \"Weiter\", um es erneut zu versuchen." + }, + "config_options": { + "title": "Konfigurationsoptionen", + "description": "### Hallo {nick_name}!\r\n\r\nXiaomi Home-Konto-ID: {uid}\r\nAktuelles Anmeldegebiet: {cloud_server}\r\n\r\nWählen Sie die Optionen aus, die Sie erneut konfigurieren möchten, und klicken Sie dann auf \"Weiter\".\r\n", + "data": { + "integration_language": "Integrationsprache", + "update_user_info": "Benutzerinformationen aktualisieren", + "update_devices": "Geräteliste aktualisieren", + "action_debug": "Action-Debug-Modus", + "hide_non_standard_entities": "Verstecke Nicht-Standard-Entitäten", + "update_trans_rules": "Entitätskonvertierungsregeln aktualisieren (globale konfiguration)", + "update_lan_ctrl_config": "LAN-Steuerungskonfiguration aktualisieren (globale Konfiguration)" + } + }, + "update_user_info": { + "title": "Benutzernamen aktualisieren", + "description": "{nick_name}! Bitte geben Sie unten Ihren Benutzernamen ein.\r\n", + "data": { + "nick_name": "Benutzername" + } + }, + "devices_filter": { + "title": "Familie und Geräte neu auswählen", + "description": "## Gebrauchsanweisung\r\n### Steuerungsmodus\r\n- Automatisch: Wenn im lokalen Netzwerk ein verfügbarer Xiaomi-Zentralgateway vorhanden ist, wird Home Assistant bevorzugt Steuerbefehle über den Zentralgateway senden, um eine lokale Steuerung zu ermöglichen. Wenn im lokalen Netzwerk kein Zentralgateway vorhanden ist, wird versucht, Steuerbefehle über das Xiaomi-OT-Protokoll zu senden, um eine lokale Steuerung zu ermöglichen. Nur wenn die oben genannten Bedingungen für die lokale Steuerung nicht erfüllt sind, werden die Steuerbefehle über die Cloud gesendet.\r\n- Cloud: Steuerbefehle werden nur über die Cloud gesendet.\r\n### Familienimport für importierte Geräte\r\nDie Integration fügt Geräte aus den ausgewählten Familien hinzu.\r\n \r\n### Hallo {nick_name}! Bitte wählen Sie den Steuerungsmodus der Integration sowie die Familie aus, in der sich die hinzuzufügenden Geräte befinden.\r\n", + "data": { + "ctrl_mode": "Steuerungsmodus", + "home_infos": "Familienimport für importierte Geräte" + } + }, + "update_trans_rules": { + "title": "Entitätskonvertierungsregeln aktualisieren", + "description": "## Gebrauchsanweisung\r\n- Aktualisieren Sie die Entitätsinformationen der Geräte im aktuellen Integrationsinstanz, einschließlich der mehrsprachigen SPEC-Konfiguration, der SPEC-Booleschen Übersetzung und der SPEC-Modellfilterung.\r\n- **Warnung: Diese Konfiguration ist eine globale Konfiguration** und aktualisiert direkt den lokalen Cache. Wenn in anderen Integrationsinstanzen Geräte desselben Modells vorhanden sind, werden diese nach dem Neuladen der entsprechenden Instanzen ebenfalls aktualisiert.\r\n- Dieser Vorgang kann einige Zeit in Anspruch nehmen, bitte haben Sie Geduld. Wählen Sie \"Bestätigen Sie das Update\" und klicken Sie auf \"Weiter\", um **{urn_count}** Regeln zu aktualisieren, andernfalls überspringen Sie das Update.\r\n", + "data": { + "confirm": "Bestätigen" + } + }, + "update_lan_ctrl_config": { + "title": "LAN-Steuerungskonfiguration aktualisieren", + "description": "## Gebrauchsanweisung\r\nAktualisieren Sie die Konfigurationsinformationen für **LAN-Steuerung von Xiaomi Home-Geräten**. Wenn die Cloud und das zentrale Gateway die Geräte nicht steuern können, versucht die Integration, die Geräte über das LAN zu steuern; wenn keine Netzwerkkarte ausgewählt ist, wird die LAN-Steuerung nicht aktiviert.\r\n- Derzeit werden nur **SPEC v2** WiFi-Geräte im LAN unterstützt. Einige ältere Geräte unterstützen möglicherweise keine Steuerung oder Eigenschaftssynchronisierung.\r\n- Bitte wählen Sie die Netzwerkkarte(n) im selben Netzwerk wie die Geräte aus (mehrere Auswahlen werden unterstützt). Wenn die ausgewählte Netzwerkkarte zwei oder mehr Verbindungen im selben Netzwerk hat, wird empfohlen, die mit der besten Netzwerkverbindung auszuwählen, da sonst die **normale Verwendung der Geräte beeinträchtigt werden kann**.\r\n- **Wenn es im LAN Endgeräte (Gateways, Mobiltelefone usw.) gibt, die lokale Steuerung unterstützen, kann das Aktivieren des LAN-Abonnements lokale Automatisierung oder Geräteanomalien verursachen. Bitte verwenden Sie es mit Vorsicht**.\r\n- **Warnung: Diese Konfiguration ist global und Änderungen wirken sich auf andere Integrationsinstanzen aus. Bitte ändern Sie sie mit Vorsicht**.\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "Bitte wählen Sie die zu verwendende Netzwerkkarte aus", + "enable_subscribe": "LAN-Abonnement aktivieren" + } + }, + "config_confirm": { + "title": "Bestätigen Sie die Konfiguration", + "description": "**{nick_name}**, bitte bestätigen Sie die neuesten Konfigurationsinformationen und klicken Sie dann auf \"Senden\". Die Integration wird mit den aktualisierten Konfigurationen erneut geladen.\r\n\r\nIntegrationsprache:\t{lang_new}\r\nBenutzername:\t{nick_name_new}\r\nAction-Debug-Modus:\t{action_debug}\r\nVerstecke Nicht-Standard-Entitäten:\t{hide_non_standard_entities}\r\nGeräteänderungen:\t{devices_add} neue Geräte hinzufügen, {devices_remove} Geräte entfernen\r\nKonvertierungsregeländerungen:\tInsgesamt {trans_rules_count} Regeln, aktualisiert {trans_rules_count_success} Regeln\r\n", + "data": { + "confirm": "Änderungen bestätigen" + } + } + }, + "progress": { + "oauth": "### {link_left}Klicken Sie hier, um sich erneut anzumelden{link_right}" + }, + "error": { + "not_auth": "Nicht authentifiziert. Klicken Sie auf den Authentifizierungslink, um die Benutzeridentität zu authentifizieren.", + "get_token_error": "Fehler beim Abrufen von Anmeldeinformationen (OAuth-Token).", + "get_homeinfo_error": "Fehler beim Abrufen von Home-Informationen.", + "get_cert_error": "Fehler beim Abrufen des Zentralzertifikats.", + "no_family_selected": "Keine Familie ausgewählt.", + "no_devices": "In der ausgewählten Familie sind keine Geräte vorhanden. Bitte wählen Sie eine Familie mit Geräten und fahren Sie dann fort.", + "no_central_device": "Der Modus \"Zentral Gateway\" erfordert ein verfügbares Xiaomi-Zentral-Gateway im lokalen Netzwerk, in dem Home Assistant installiert ist. Überprüfen Sie, ob die ausgewählte Familie diese Anforderung erfüllt.", + "mdns_discovery_error": "Lokaler Geräteerkennungsdienstfehler.", + "update_config_error": "Fehler beim Aktualisieren der Konfigurationsinformationen.", + "not_confirm": "Änderungen wurden nicht bestätigt. Bitte bestätigen Sie die Auswahl, bevor Sie sie einreichen." + }, + "abort": { + "network_connect_error": "Konfiguration fehlgeschlagen. Netzwerkverbindungsfehler. Überprüfen Sie die Netzwerkkonfiguration des Geräts.", + "options_flow_error": "Integrationsneukonfigurationsfehler: {error}", + "re_add": "Fügen Sie die Integration erneut hinzu. Fehlermeldung: {error}", + "storage_error": "Integrations-Speichermodulfehler. Bitte versuchen Sie es erneut oder fügen Sie die Integration erneut hinzu: {error}", + "inconsistent_account": "Kontoinformationen sind inkonsistent. Bitte melden Sie sich mit den richtigen Kontoinformationen an." + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/en.json b/custom_components/xiaomi_home/translations/en.json new file mode 100644 index 0000000..2298ca0 --- /dev/null +++ b/custom_components/xiaomi_home/translations/en.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "Xiaomi Home Integration", + "step": { + "eula": { + "title": "Risk Notice", + "description": "1. Your Xiaomi user information and device information will be stored in the Home Assistant system. **Xiaomi cannot guarantee the security of the Home Assistant storage mechanism**. You are responsible for preventing your information from being stolen.\r\n2. This integration is maintained by the open-source community. There may be stability issues or other problems. When encountering issues or bugs of this integration, **you should seek help from the open-source community rather than contacting Xiaomi customer service**.\r\n3. You need some technical ability to maintain your local operating environment. The integration is not user-friendly for beginners.\r\n4. Please read the README file before starting.\n\n5. To ensure stable use of the integration and prevent interface abuse, **this integration is only allowed to be used in Home Assistant. For details, please refer to the LICENSE**.\r\n", + "data": { + "eula": "I am aware of the above risks and willing to voluntarily assume any risks associated with the use of the integration." + } + }, + "auth_config": { + "title": "Basic configuration", + "description": "### Login Region\r\nSelect the region of your Xiaomi account. You can find it in the Xiaomi Home APP > Profile (located in the menu at the bottom) > Additional settings > About Xiaomi Home.\r\n### Language\r\nSelect the language of the device and entity names. Some sentences without translation will be displayed in English.\r\n### OAuth2 Redirect URL\r\nThe OAuth2 authentication redirect address is **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. The Home Assistant needs to be in the same local area network as the current operating terminal (e.g., the personal computer) and the operating terminal can access the Home Assistant home page through this address. Otherwise, the login authentication may fail.\r\n### Note\r\n- For users with hundreds or more Mi Home devices, the initial addition of the integration will take some time. Please be patient.\r\n- If Home Assistant is running in a Docker environment, please ensure that the Docker network mode is set to host, otherwise local control functionality may not work properly.\r\n- The local control functionality of the integration has some dependencies. Please read the README carefully.\r\n", + "data": { + "cloud_server": "Login Region", + "integration_language": "Language", + "oauth_redirect_url": "OAuth2 Redirect URL" + } + }, + "oauth_error": { + "title": "Login Error", + "description": "Click NEXT to try again." + }, + "devices_filter": { + "title": "Select Home and Devices", + "description": "## Usage Instructions\r\n### Control mode\r\n- Auto: When there is an available Xiaomi central hub gateway in the local area network, Home Assistant will prioritize sending device control commands through the central hub gateway to achieve local control. If there is no central hub gateway in the local area network, it will attempt to send control commands through Xiaomi LAN control function. Only when the above local control conditions are not met, the device control commands will be sent through the cloud.\r\n- Cloud: All control commands are sent through the cloud.\r\n### Import devices from home\r\nThe integration will add devices from the selected homes.\n### Room name synchronizing mode\nWhen importing devices from Xiaomi Home APP to Home Assistant, the naming convention of the area where the device is added to is as follows. Note that the device synchronizing process does not change the home or room settings in Xiaomi Home APP.\r\n- Do not synchronize: The device will not be added to any area.\r\n- Other options: The device will be added to an area named as the home and/or room name that already exists in Xiaomi Home APP.\r\n### Debug mode for action\r\nFor the action defined in MIoT-Spec-V2 of the device, a Text entity along with a Notify entity will be created, in which you can send control commands to the device for debugging.\r\n### Hide non-standard created entities\r\nHide the entities generated from non-standard MIoT-Spec-V2 instances, whose names begin with \"*\".\r\n\r\n \r\n### Hello {nick_name}, please select the integration control mode and the home where the device you want to import.\r\n", + "data": { + "ctrl_mode": "Control mode", + "home_infos": "Import devices from home", + "area_name_rule": "Room name synchronizing mode", + "action_debug": "Debug mode for action", + "hide_non_standard_entities": "Hide non-standard created entities" + } + } + }, + "progress": { + "oauth": "### {link_left}Click here to login{link_right}\r\n(You will be automatically redirected to the next page after a successful login)" + }, + "error": { + "eula_not_agree": "Please read the risk notice.", + "get_token_error": "Failed to retrieve login authorization information (OAuth token).", + "get_homeinfo_error": "Failed to retrieve home information.", + "mdns_discovery_error": "Local device discovery service exception.", + "get_cert_error": "Failed to retrieve the central hub gateway certificate.", + "no_family_selected": "No home selected.", + "no_devices": "The selected home does not have any devices. Please choose a home containing devices and continue.", + "no_central_device": "[Central Hub Gateway Mode] requires a Xiaomi central hub gateway available in the local network where Home Assistant exists. Please check if the selected home meets the requirement." + }, + "abort": { + "network_connect_error": "Configuration failed. The network connection is abnormal. Please check the equipment network configuration.", + "already_configured": "Configuration for this user is already completed. Please go to the integration page and click the CONFIGURE button for modifications.", + "invalid_auth_info": "Authentication information has expired. Please go to the integration page and click the CONFIGURE button to re-authenticate.", + "config_flow_error": "Integration configuration error: {error}." + } + }, + "options": { + "step": { + "auth_config": { + "title": "Authentication Configuration", + "description": "Local authentication information has expired. Please restart the authentication process.\r\n### Current Login Region: {cloud_server}\r\n### OAuth2 Redirect URL\r\nThe OAuth2 authentication redirect address is **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. The Home Assistant needs to be in the same local area network as the current operating terminal (e.g., the personal computer) and the operating terminal can access the Home Assistant home page through this address. Otherwise, the login authentication may fail.\r\n", + "data": { + "oauth_redirect_url": "OAuth2 Redirect URL" + } + }, + "oauth_error": { + "title": "An error occurred during login.", + "description": "Click NEXT to retry." + }, + "config_options": { + "title": "Configuration Options", + "description": "### Hello, {nick_name}\r\n\r\nXiaomi ID: {uid}\r\nCurrent Login Region: {cloud_server}\r\n\r\nPlease select the options you need to configure, then click NEXT.\r\n", + "data": { + "integration_language": "Integration Language", + "update_user_info": "Update user information", + "update_devices": "Update device list", + "action_debug": "Debug mode for action", + "hide_non_standard_entities": "Hide non-standard created entities", + "update_trans_rules": "Update entity conversion rules", + "update_lan_ctrl_config": "Update LAN control configuration" + } + }, + "update_user_info": { + "title": "Update User Nickname", + "description": "Hello {nick_name}, you can modify your custom nickname below.\r\n", + "data": { + "nick_name": "Nick Name" + } + }, + "devices_filter": { + "title": "Re-select Home and Devices", + "description": "## Usage Instructions\r\n### Control mode\r\n- Auto: When there is an available Xiaomi central hub gateway in the local area network, Home Assistant will prioritize sending device control commands through the central hub gateway to achieve local control. If there is no central hub gateway in the local area network, it will attempt to send control commands through Xiaomi LAN control function. Only when the above local control conditions are not met, the device control commands will be sent through the cloud.\r\n- Cloud: All control commands are sent through the cloud.\r\n### Import devices from home\r\nThe integration will add devices from the selected homes.\r\n \r\n### Hello {nick_name}, please select the integration control mode and the home where the device you want to import.\r\n", + "data": { + "ctrl_mode": "Control mode", + "home_infos": "Import devices from home" + } + }, + "update_trans_rules": { + "title": "Update Entities Transformation Rules", + "description": "## Usage Instructions\r\n- Update the entity information of devices in the current integration instance, including MIoT-Spec-V2 multilingual configuration, boolean translation, and model filtering.\r\n- **Warning**: This is a global configuration and will update the local cache. It will affect all integration instances.\r\n- This operation will take some time, please be patient. Check \"Confirm Update\" and click \"Next\" to start updating **{urn_count}** rules, otherwise skip the update.\r\n", + "data": { + "confirm": "Confirm the update" + } + }, + "update_lan_ctrl_config": { + "title": "Update lan control configuration", + "description": "## Usage Instructions\r\nUpdate the configurations for Xiaomi LAN control function. When the cloud and the central hub gateway cannot control the devices, the integration will attempt to control the devices through the LAN. If no network card is selected, the LAN control function will not take effect.\r\n- Only MIoT-Spec-V2 compatible IP devices in the LAN are supported. Some devices produced before 2020 may not support LAN control or LAN subscription.\r\n- Please select the network card(s) on the same network as the devices to be controlled. Multiple network cards can be selected. If Home Assistant have two or more connections to the local area network because of the multiple selection of the network cards, it is recommended to select the one with the best network connection, otherwise it may have bad effect on the devices.\r\n- If there are terminal devices (Xiaomi speaker with screen, mobile phone, etc.) in the LAN that support local control, enabling LAN subscription may cause local automation and device anomalies.\r\n- **Warning**: This is a global configuration. It will affect all integration instances. Please use it with caution.\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "Please select the network card to use", + "enable_subscribe": "Enable LAN subscription" + } + }, + "config_confirm": { + "title": "Confirm Configuration", + "description": "Hello **{nick_name}**, please confirm the latest configuration information and then Click SUBMIT.\r\nThe integration will reload using the updated configuration.\r\n\r\nIntegration Language: \t{lang_new}\r\nNickname: \t{nick_name_new}\r\nDebug mode for action: \t{action_debug}\r\nHide non-standard created entities: \t{hide_non_standard_entities}\r\nDevice Changes: \tAdd **{devices_add}** devices, Remove **{devices_remove}** devices\r\nTransformation rules change: \tThere are a total of **{trans_rules_count}** rules, and updated **{trans_rules_count_success}** rules\r\n", + "data": { + "confirm": "Confirm the change" + } + } + }, + "progress": { + "oauth": "### {link_left}Please click here to re-login{link_right}" + }, + "error": { + "not_auth": "Not authenticated. Please click the authentication link to authenticate user identity.", + "get_token_error": "Failed to retrieve login authorization information (OAuth token).", + "get_homeinfo_error": "Failed to retrieve home information.", + "get_cert_error": "Failed to retrieve the central hub gateway certificate.", + "no_devices": "The selected home does not have any devices. Please choose a home containing devices and continue.", + "no_family_selected": "No home selected.", + "no_central_device": "[Central Hub Gateway Mode] requires a Xiaomi central hub gateway available in the local network where Home Assistant exists. Please check if the selected home meets the requirement.", + "mdns_discovery_error": "Local device discovery service exception.", + "update_config_error": "Failed to update configuration information.", + "not_confirm": "Changes are not confirmed. Please confirm the change before submitting." + }, + "abort": { + "network_connect_error": "Configuration failed. The network connection is abnormal. Please check the equipment network configuration.", + "options_flow_error": "Integration re-configuration error: {error}", + "re_add": "Please re-add the integration. Error message: {error}", + "storage_error": "Integration storage module exception. Please try again or re-add the integration: {error}", + "inconsistent_account": "Account information is inconsistent. Please login with the correct account." + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/es.json b/custom_components/xiaomi_home/translations/es.json new file mode 100644 index 0000000..84eca20 --- /dev/null +++ b/custom_components/xiaomi_home/translations/es.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "Integración de Xiaomi Home", + "step": { + "eula": { + "title": "Aviso de riesgo", + "description": "1. Su **información de usuario de Xiaomi e información del dispositivo** se almacenará en su sistema Home Assistant. **Xiaomi no puede garantizar la seguridad del mecanismo de almacenamiento de Home Assistant**. Usted es responsable de evitar que su información sea robada.\r\n2. Esta integración es mantenida por la comunidad de código abierto y puede haber problemas de estabilidad u otros problemas. Cuando tenga problemas relacionados con el uso de esta integración, **busque ayuda en la comunidad de código abierto en lugar de contactar al servicio al cliente de Xiaomi**.\r\n3. Es necesario tener ciertas habilidades técnicas para mantener su entorno de ejecución local, esta integración no es amigable para los usuarios novatos.\r\n4. Antes de utilizar esta integración, por favor **lea detenidamente el archivo README**. \r\n5. Para garantizar el uso estable de la integración y prevenir el abuso de la interfaz, **esta integración solo está permitida en Home Assistant. Para más detalles, consulte la LICENSE**.\r\n", + "data": { + "eula": "He leído y entiendo los riesgos anteriores, y estoy dispuesto a asumir cualquier riesgo relacionado con el uso de esta integración." + } + }, + "auth_config": { + "title": "Configuración básica", + "description": "### Región de inicio de sesión\r\nSeleccione la región donde se encuentra su cuenta de Xiaomi. Puede consultar esta información en `Xiaomi Home APP > Yo (ubicado en el menú inferior) > Más ajustes > Acerca de Xiaomi Home`.\r\n### Idioma\r\nSeleccione el idioma utilizado para los nombres de los dispositivos y entidades. Las partes de las frases que no están traducidas se mostrarán en inglés.\r\n### Dirección de redireccionamiento de autenticación de OAuth2\r\nLa dirección de redireccionamiento de autenticación de OAuth2 es **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant debe estar en la misma red local que el terminal de operación actual (por ejemplo, una computadora personal) y el terminal de operación debe poder acceder a la página de inicio de Home Assistant a través de esta dirección, de lo contrario, la autenticación de inicio de sesión podría fallar.\r\n### Nota\r\n- Para los usuarios con cientos o más dispositivos Mi Home, la adición inicial de la integración tomará algún tiempo. Por favor, sea paciente.\r\n- Si Home Assistant se está ejecutando en un entorno Docker, asegúrese de que el modo de red de Docker esté configurado en host, de lo contrario, la funcionalidad de control local puede no funcionar correctamente.\r\n- La funcionalidad de control local de la integración tiene algunas dependencias. Por favor, lea el README cuidadosamente.\r\n", + "data": { + "cloud_server": "Región de inicio de sesión", + "integration_language": "Idioma", + "oauth_redirect_url": "Dirección de redireccionamiento de autenticación de OAuth2" + } + }, + "oauth_error": { + "title": "Error de inicio de sesión", + "description": "Haga clic en \"Siguiente\" para volver a intentarlo" + }, + "devices_filter": { + "title": "Seleccionar hogares y dispositivos", + "description": "## Instrucciones de uso\r\n### Modo de control\r\n- Automático: Cuando hay un gateway central de Xiaomi disponible en la red local, Home Assistant priorizará el envío de comandos de control de dispositivos a través del gateway central para lograr un control localizado. Si no hay un gateway central en la red local, intentará enviar comandos de control a través del protocolo Xiaomi OT para lograr un control localizado. Solo cuando no se cumplan las condiciones anteriores de control localizado, los comandos de control del dispositivo se enviarán a través de la nube.\r\n- Nube: Los comandos de control solo se envían a través de la nube.\r\n### Hogares de dispositivos importados\r\nLa integración agregará los dispositivos en los hogares seleccionados.\r\n### Modo de sincronización del nombre de la habitación\r\nCuando se sincronizan los dispositivos desde la aplicación Xiaomi Home a Home Assistant, los nombres de las áreas donde se encuentran los dispositivos en Home Assistant seguirán las reglas de nomenclatura a continuación. Tenga en cuenta que el proceso de sincronización de dispositivos no cambiará la configuración de hogares y habitaciones en la aplicación Xiaomi Home.\r\n- Sin sincronización: el dispositivo no se agregará a ninguna área.\r\n- Otras opciones: la zona donde se agrega el dispositivo tendrá el mismo nombre que el hogar o la habitación en la aplicación Xiaomi Home.\r\n### Modo de depuración de Action\r\nPara los métodos definidos por MIoT-Spec-V2, además de generar una entidad de notificación, también se generará una entidad de cuadro de entrada de texto que se puede utilizar para enviar comandos de control al dispositivo durante la depuración.\r\n### Ocultar entidades generadas no estándar\r\nOcultar las entidades generadas por la instancia no estándar MIoT-Spec-V2 que comienzan con \"*\".\r\n\r\n \r\n### ¡Hola, {nick_name}! Seleccione el modo de control de integración y el hogar donde se encuentran los dispositivos que desea agregar.\r\n", + "data": { + "ctrl_mode": "Modo de control", + "home_infos": "Hogares de dispositivos importados", + "area_name_rule": "Modo de sincronización del nombre de la habitación", + "action_debug": "Modo de depuración de Action", + "hide_non_standard_entities": "Ocultar entidades generadas no estándar" + } + } + }, + "progress": { + "oauth": "### {link_left}Haga clic aquí para iniciar sesión de nuevo{link_right}\r\n(Será redirigido automáticamente a la siguiente página después de un inicio de sesión exitoso)" + }, + "error": { + "eula_not_agree": "Lea el texto de aviso de riesgo.", + "get_token_error": "Error al obtener la información de autorización de inicio de sesión (token OAuth).", + "get_homeinfo_error": "Error al obtener la información del hogar.", + "mdns_discovery_error": "Error en el servicio de descubrimiento de dispositivos locales.", + "get_cert_error": "Error al obtener el certificado de la puerta de enlace.", + "no_family_selected": "No se ha seleccionado ningún hogar.", + "no_devices": "No hay dispositivos en el hogar seleccionado. Seleccione un hogar con dispositivos y continúe.", + "no_central_device": "【Modo de puerta de enlace central】Se requiere una puerta de enlace Xiaomi disponible en la red local donde se encuentra Home Assistant. Verifique si el hogar seleccionado cumple con este requisito." + }, + "abort": { + "network_connect_error": "La configuración ha fallado. Existe un problema con la conexión de red, verifique la configuración de red del dispositivo.", + "already_configured": "Esta cuenta ya ha finalizado la configuración. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para modificar la configuración.", + "invalid_auth_info": "La información de autorización ha caducado. Ingrese a la página de integración y haga clic en el botón \"Configurar\" para volver a autenticarse.", + "config_flow_error": "Error de configuración de integración: {error}" + } + }, + "options": { + "step": { + "auth_config": { + "title": "Configuración de autorización", + "description": "Se detectó que la información de autenticación local ha caducado, vuelva a autenticarse\r\n### Región de inicio de sesión actual: {cloud_server}\r\n### Dirección de redireccionamiento de autenticación de OAuth2\r\nLa dirección de redireccionamiento de autenticación de OAuth2 es **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant debe estar en la misma red local que el terminal de operación actual (por ejemplo, una computadora personal) y el terminal de operación debe poder acceder a la página de inicio de Home Assistant a través de esta dirección, de lo contrario, la autenticación de inicio de sesión podría fallar.\r\n", + "data": { + "oauth_redirect_url": "Dirección de redireccionamiento de autenticación de OAuth2" + } + }, + "oauth_error": { + "title": "Error de inicio de sesión", + "description": "Haga clic en \"Siguiente\" para volver a intentarlo\r\n" + }, + "config_options": { + "title": "Opciones de configuración", + "description": "### ¡Hola, {nick_name}!\r\n\r\nID de cuenta de Xiaomi: {uid}\r\nRegión de inicio de sesión actual: {cloud_server}\r\n\r\nSeleccione las opciones que desea reconfigurar y haga clic en \"Siguiente\".\r\n", + "data": { + "integration_language": "Idioma de la integración", + "update_user_info": "Actualizar información de usuario", + "update_devices": "Actualizar lista de dispositivos", + "action_debug": "Modo de depuración de Action", + "hide_non_standard_entities": "Ocultar entidades generadas no estándar", + "update_trans_rules": "Actualizar reglas de conversión de entidad (configuración global)", + "update_lan_ctrl_config": "Actualizar configuración de control LAN (configuración global)" + } + }, + "update_user_info": { + "title": "Actualizar apodo de usuario", + "description": "¡Hola, {nick_name}! Modifique su apodo de usuario a continuación.\r\n", + "data": { + "nick_name": "Apodo de usuario" + } + }, + "devices_filter": { + "title": "Recomendar hogares y dispositivos", + "description": "## Instrucciones de uso\r\n### Modo de control\r\n- Automático: Cuando hay un gateway central de Xiaomi disponible en la red local, Home Assistant priorizará el envío de comandos de control de dispositivos a través del gateway central para lograr un control localizado. Si no hay un gateway central en la red local, intentará enviar comandos de control a través del protocolo Xiaomi OT para lograr un control localizado. Solo cuando no se cumplan las condiciones anteriores de control localizado, los comandos de control del dispositivo se enviarán a través de la nube.\r\n- Nube: Los comandos de control solo se envían a través de la nube.\r\n### Hogares de dispositivos importados\r\nLa integración agregará los dispositivos en los hogares seleccionados.\r\n \r\n### ¡Hola, {nick_name}! Seleccione el modo de control de integración y el hogar donde se encuentran los dispositivos que desea agregar.\r\n", + "data": { + "ctrl_mode": "Modo de control", + "home_infos": "Hogares de dispositivos importados" + } + }, + "update_trans_rules": { + "title": "Actualizar reglas de conversión de entidad", + "description": "## Instrucciones de uso\r\n- Actualice la información de la entidad de los dispositivos en la instancia de integración actual, incluida la configuración multilingüe de SPEC, la traducción booleana de SPEC y el filtrado de modelos de SPEC.\r\n- **Advertencia: Esta configuración es una configuración global** y actualizará directamente la caché local. Si hay dispositivos del mismo modelo en otras instancias de integración, las instancias relevantes también se actualizarán después de recargarlas.\r\n- Esta operación tomará algún tiempo, por favor sea paciente. Marque \"Confirmar actualización\" y haga clic en \"Siguiente\" para comenzar a actualizar **{urn_count}** reglas, de lo contrario, omita la actualización.\r\n", + "data": { + "confirm": "Confirmar actualización" + } + }, + "update_lan_ctrl_config": { + "title": "Actualizar configuración de control LAN", + "description": "## Instrucciones de uso\r\nActualice la información de configuración para **control LAN de dispositivos Xiaomi Home**. Cuando la nube y la puerta de enlace central no puedan controlar los dispositivos, la integración intentará controlar los dispositivos a través de la LAN; si no se selecciona ninguna tarjeta de red, el control LAN no se habilitará.\r\n- Actualmente, solo se admiten dispositivos WiFi **SPEC v2** en la LAN. Algunos dispositivos más antiguos pueden no admitir el control o la sincronización de propiedades.\r\n- Seleccione la(s) tarjeta(s) de red en la misma red que los dispositivos (se admiten múltiples selecciones). Si la tarjeta de red seleccionada tiene dos o más conexiones en la misma red, se recomienda seleccionar la que tenga la mejor conexión de red, de lo contrario, puede **afectar el uso normal de los dispositivos**.\r\n- **Si hay dispositivos terminales (puertas de enlace, teléfonos móviles, etc.) en la LAN que admiten el control local, habilitar la suscripción LAN puede causar automatización local o anomalías en los dispositivos. Úselo con precaución**.\r\n- **Advertencia: Esta configuración es global y los cambios afectarán a otras instancias de integración. Modifique con precaución**.\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "Por favor, seleccione la tarjeta de red a utilizar", + "enable_subscribe": "Habilitar suscripción LAN" + } + }, + "config_confirm": { + "title": "Confirmar configuración", + "description": "¡Hola, **{nick_name}**! Por favor, confirme la última información de configuración y haga clic en \"Enviar\" para finalizar la configuración.\r\nLa integración se volverá a cargar con la nueva configuración.\r\n\r\nIdioma de la integración:\t{lang_new}\r\nApodo de usuario:\t{nick_name_new}\r\nModo de depuración de Action:\t{action_debug}\r\nOcultar entidades generadas no estándar:\t{hide_non_standard_entities}\r\nCambios de dispositivos:\t{devices_add} dispositivos agregados, {devices_remove} dispositivos eliminados\r\nCambios en las reglas de conversión:\t{trans_rules_count} reglas en total, {trans_rules_count_success} reglas actualizadas\r\n", + "data": { + "confirm": "Confirmar modificación" + } + } + }, + "progress": { + "oauth": "### {link_left}Haga clic aquí para iniciar sesión de nuevo{link_right}" + }, + "error": { + "not_auth": "Usuario no autenticado. Haga clic en el enlace de autenticación para autenticarse.", + "get_token_error": "Error al obtener la información de autorización de inicio de sesión (token OAuth).", + "get_homeinfo_error": "Error al obtener la información del hogar.", + "get_cert_error": "Error al obtener el certificado de la puerta de enlace.", + "no_family_selected": "No se ha seleccionado ningún hogar.", + "no_devices": "No hay dispositivos en el hogar seleccionado. Seleccione un hogar con dispositivos y continúe.", + "no_central_device": "【Modo de puerta de enlace central】Se requiere una puerta de enlace Xiaomi disponible en la red local donde se encuentra Home Assistant. Verifique si el hogar seleccionado cumple con este requisito.", + "mdns_discovery_error": "Error en el servicio de descubrimiento de dispositivos locales.", + "update_config_error": "Error al actualizar la información de configuración.", + "not_confirm": "No se ha confirmado la opción de modificación. Seleccione y confirme la opción antes de enviar." + }, + "abort": { + "network_connect_error": "La configuración ha fallado. Existe un problema con la conexión de red, verifique la configuración de red del dispositivo.", + "options_flow_error": "Error al reconfigurar la integración: {error}", + "re_add": "Agregue la integración de nuevo, mensaje de error: {error}", + "storage_error": "Error en el módulo de almacenamiento de integración. Intente de nuevo o agregue la integración de nuevo: {error}", + "inconsistent_account": "La información de la cuenta no coincide. Inicie sesión con la cuenta correcta." + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/fr.json b/custom_components/xiaomi_home/translations/fr.json new file mode 100644 index 0000000..26f1c1b --- /dev/null +++ b/custom_components/xiaomi_home/translations/fr.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "Intégration Xiaomi Home", + "step": { + "eula": { + "title": "Notification de risques", + "description": "1. Vos **informations utilisateur Xiaomi et informations sur l'appareil** seront stockées dans votre système Home Assistant. **Xiaomi ne peut garantir la sécurité du mécanisme de stockage de Home Assistant**. Vous êtes responsable de la protection de vos informations contre le vol.\r\n2. Cette intégration est maintenue par la communauté open source et peut rencontrer des problèmes de stabilité ou autres problèmes. Si vous rencontrez des problèmes liés à l'utilisation de cette intégration, vous devez **demander de l'aide à la communauté open source plutôt que de contacter le service client Xiaomi**.\r\n3. Vous avez besoin d'un certain niveau de compétences techniques pour maintenir votre environnement d'exécution local. Cette intégration n'est pas conviviale pour les utilisateurs novices.\r\n4. Avant d'utiliser cette intégration, veuillez **lire attentivement le README**.\r\n5. Pour garantir une utilisation stable de l'intégration et prévenir les abus d'interface, **cette intégration n'est autorisée qu'à être utilisée dans Home Assistant. Pour plus de détails, veuillez consulter la LICENSE**.\r\n", + "data": { + "eula": "Je suis informé des risques ci-dessus et j'accepte volontairement les risques associés à l'utilisation de cette intégration." + } + }, + "auth_config": { + "title": "Configuration de base", + "description": "### Région de connexion\r\nSélectionnez la région où se trouve votre compte Xiaomi. Vous pouvez le trouver dans `Xiaomi Home APP > Mon (situé dans le menu inférieur) > Plus de paramètres > À propos de Xiaomi Home`.\r\n### Langue\r\nChoisissez la langue utilisée pour les noms de périphériques et d'entités. Les parties de phrases sans traduction seront affichées en anglais.\r\n### Adresse de redirection de l'authentification OAuth2\r\nL'adresse de redirection de l'authentification OAuth2 est **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant doit être dans le même réseau local que le terminal de l'opération actuelle (par exemple, un ordinateur personnel) et le terminal de l'opération doit pouvoir accéder à la page d'accueil de Home Assistant via cette adresse. Sinon, l'authentification de connexion peut échouer.\r\n### Remarque\r\n- Pour les utilisateurs ayant des centaines ou plus d'appareils Mi Home, l'ajout initial de l'intégration prendra un certain temps. Veuillez être patient.\r\n- Si Home Assistant fonctionne dans un environnement Docker, veuillez vous assurer que le mode réseau Docker est réglé sur host, sinon la fonctionnalité de contrôle local peut ne pas fonctionner correctement.\r\n- La fonctionnalité de contrôle local de l'intégration a quelques dépendances. Veuillez lire attentivement le README.\r\n", + "data": { + "cloud_server": "Région de connexion", + "integration_language": "Langue", + "oauth_redirect_url": "Adresse de redirection de l'authentification" + } + }, + "oauth_error": { + "title": "Erreur de connexion", + "description": "Cliquez sur \"Suivant\" pour réessayer" + }, + "devices_filter": { + "title": "Sélectionner une maison et des appareils", + "description": "## Instructions d'utilisation\r\n### Mode de contrôle\r\n- Automatique: Lorsqu'il y a une passerelle centrale Xiaomi disponible dans le réseau local, Home Assistant priorisera l'envoi des commandes de contrôle des appareils via la passerelle centrale pour réaliser un contrôle localisé. S'il n'y a pas de passerelle centrale dans le réseau local, il tentera d'envoyer des commandes de contrôle via le protocole Xiaomi OT pour réaliser un contrôle localisé. Ce n'est que lorsque les conditions de contrôle localisé ci-dessus ne sont pas remplies que les commandes de contrôle des appareils seront envoyées via le cloud.\r\n- Cloud: Les commandes de contrôle ne sont envoyées que via le cloud.\r\n### Importer une maison pour les appareils\r\nL'intégration ajoutera les appareils de la maison sélectionnée.\r\n### Mode de synchronisation des noms de pièces\r\nLors de la synchronisation des appareils de Xiaomi Home à Home Assistant, le nom de la pièce où se trouve l'appareil sera nommé selon les règles suivantes. Notez que le processus de synchronisation des appareils n'affecte pas les paramètres de la maison et de la pièce dans Xiaomi Home APP.\r\n- Ne pas synchroniser: L'appareil ne sera ajouté à aucune zone.\r\n- Autre option: La zone dans laquelle l'appareil est ajouté est nommée en fonction du nom de la maison ou de la pièce de Xiaomi Home APP.\r\n### Mode de débogage d'action\r\nPour les méthodes définies par MIoT-Spec-V2, en plus de générer une entité de notification, une entité de zone de texte sera également générée pour que vous puissiez envoyer des commandes de contrôle à l'appareil lors du débogage.\r\n### Masquer les entités générées non standard\r\nMasquer les entités générées non standard de MIoT-Spec-V2 commençant par \"*\".\r\n\r\n \r\n### {nick_name} Bonjour ! Veuillez sélectionner le mode de contrôle de l'intégration et la maison où se trouvent les appareils à ajouter.\r\n", + "data": { + "ctrl_mode": "Mode de contrôle", + "home_infos": "Importer une maison pour les appareils", + "area_name_rule": "Mode de synchronisation des noms de pièces", + "action_debug": "Mode de débogage d'action", + "hide_non_standard_entities": "Masquer les entités générées non standard" + } + } + }, + "progress": { + "oauth": "### {link_left}Veuillez cliquer ici pour vous reconnecter{link_right}\r\n(Vous serez automatiquement redirigé vers la page suivante après une connexion réussie)" + }, + "error": { + "eula_not_agree": "Veuillez lire le texte de notification de risques.", + "get_token_error": "Échec de la récupération des informations d'autorisation de connexion (jeton OAuth).", + "get_homeinfo_error": "Échec de la récupération des informations de la maison.", + "mdns_discovery_error": "Le service de découverte de périphériques locaux est anormal.", + "get_cert_error": "Échec de l'obtention du certificat de la passerelle.", + "no_family_selected": "Aucune maison sélectionnée.", + "no_devices": "Il n'y a pas d'appareil dans la maison sélectionnée. Veuillez sélectionner une maison avec des appareils avant de continuer.", + "no_central_device": "Le mode gateway central a besoin d'un Xiaomi Gateway disponible dans le réseau local où se trouve Home Assistant. Veuillez vérifier si la maison sélectionnée répond à cette exigence." + }, + "abort": { + "network_connect_error": "La configuration a échoué. Erreur de connexion réseau. Veuillez vérifier la configuration du réseau de l'appareil.", + "already_configured": "Cet utilisateur a déjà terminé la configuration. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour modifier la configuration.", + "invalid_auth_info": "Les informations d'authentification ont expiré. Veuillez accéder à la page d'intégration et cliquer sur le bouton \"Configurer\" pour vous authentifier à nouveau.", + "config_flow_error": "Erreur de configuration de l'intégration : {error}" + } + }, + "options": { + "step": { + "auth_config": { + "title": "Configuration d'authentification", + "description": "Les informations d'authentification locales ont expiré. Veuillez recommencer l'authentification\r\n### Région de connexion actuelle : {cloud_server}\r\n### Adresse de redirection de l'authentification OAuth2\r\nL'adresse de redirection de l'authentification OAuth2 est **[http://homeassistant.local:8123](http://homeassistant.local:8123)**. Home Assistant doit être dans le même réseau local que le terminal de l'opération actuelle (par exemple, un ordinateur personnel) et le terminal de l'opération doit pouvoir accéder à la page d'accueil de Home Assistant via cette adresse. Sinon, l'authentification de connexion peut échouer.\r\n", + "data": { + "oauth_redirect_url": "Adresse de redirection de l'authentification" + } + }, + "oauth_error": { + "title": "Erreur de connexion", + "description": "Cliquez sur \"Suivant\" pour réessayer\r\n" + }, + "config_options": { + "title": "Options de configuration", + "description": "### {nick_name} Bonjour !\r\n\r\nID de compte Xiaomi : {uid}\r\nRégion de connexion actuelle : {cloud_server}\r\n\r\nVeuillez sélectionner les options que vous devez reconfigurer et cliquer sur \"Suivant\".\r\n", + "data": { + "integration_language": "Langue d'intégration", + "update_user_info": "Mettre à jour les informations utilisateur", + "update_devices": "Mettre à jour la liste des appareils", + "action_debug": "Mode de débogage d'action", + "hide_non_standard_entities": "Masquer les entités générées non standard", + "update_trans_rules": "Mettre à jour les règles de conversion d'entités (configuration globale)", + "update_lan_ctrl_config": "Mettre à jour la configuration de contrôle LAN (configuration globale)" + } + }, + "update_user_info": { + "title": "Mettre à jour le pseudo de l'utilisateur", + "description": "{nick_name} Bonjour ! Veuillez modifier votre pseudo utilisateur ci-dessous.\r\n", + "data": { + "nick_name": "Pseudo utilisateur" + } + }, + "devices_filter": { + "title": "Re-sélectionner une maison et des appareils", + "description": "## Instructions d'utilisation\r\n### Mode de contrôle\r\n- Automatique: Lorsqu'il y a une passerelle centrale Xiaomi disponible dans le réseau local, Home Assistant priorisera l'envoi des commandes de contrôle des appareils via la passerelle centrale pour réaliser un contrôle localisé. S'il n'y a pas de passerelle centrale dans le réseau local, il tentera d'envoyer des commandes de contrôle via le protocole Xiaomi OT pour réaliser un contrôle localisé. Ce n'est que lorsque les conditions de contrôle localisé ci-dessus ne sont pas remplies que les commandes de contrôle des appareils seront envoyées via le cloud.\r\n- Cloud: Les commandes de contrôle ne sont envoyées que via le cloud.\r\n### Importer une maison pour les appareils\r\nL'intégration ajoutera les appareils de la maison sélectionnée.\r\n \r\n### {nick_name} Bonjour ! Veuillez sélectionner le mode de contrôle de l'intégration et la maison où se trouvent les appareils à ajouter.\r\n", + "data": { + "ctrl_mode": "Mode de contrôle", + "home_infos": "Importer une maison pour les appareils" + } + }, + "update_trans_rules": { + "title": "Mettre à jour les règles de conversion d'entités", + "description": "## Instructions d'utilisation\r\n- Mettez à jour les informations d'entité des appareils dans l'instance d'intégration actuelle, y compris la configuration multilingue SPEC, la traduction booléenne SPEC et le filtrage de modèle SPEC.\r\n- **Avertissement: Cette configuration est une configuration globale** et mettra directement à jour le cache local. S'il y a des appareils du même modèle dans d'autres instances d'intégration, les instances pertinentes seront également mises à jour après rechargement.\r\n- Cette opération prendra du temps, veuillez être patient. Cochez \"Confirmer la mise à jour\" et cliquez sur \"Suivant\" pour commencer à mettre à jour **{urn_count}** règles, sinon passez la mise à jour.\r\n", + "data": { + "confirm": "Confirmer la mise à jour" + } + }, + "update_lan_ctrl_config": { + "title": "Mettre à jour la configuration réseau de contrôle LAN", + "description": "## Instructions d'utilisation\r\nMettez à jour les informations de configuration pour **le contrôle LAN des appareils Xiaomi Home**. Lorsque le cloud et la passerelle centrale ne peuvent pas contrôler les appareils, l'intégration tentera de contrôler les appareils via le LAN ; si aucune carte réseau n'est sélectionnée, le contrôle LAN ne sera pas activé.\r\n- Actuellement, seuls les appareils WiFi **SPEC v2** dans le LAN sont pris en charge. Certains anciens appareils peuvent ne pas prendre en charge le contrôle ou la synchronisation des propriétés.\r\n- Veuillez sélectionner la ou les cartes réseau sur le même réseau que les appareils (plusieurs sélections sont prises en charge). Si la carte réseau sélectionnée a deux ou plusieurs connexions sur le même réseau, il est recommandé de sélectionner celle avec la meilleure connexion réseau, sinon cela peut **affecter l'utilisation normale des appareils**.\r\n- **S'il y a des appareils terminaux (passerelles, téléphones mobiles, etc.) dans le LAN qui prennent en charge le contrôle local, l'activation de l'abonnement LAN peut provoquer des automatisations locales ou des anomalies des appareils. Veuillez l'utiliser avec prudence**.\r\n- **Avertissement : Cette configuration est globale et les modifications affecteront d'autres instances d'intégration. Veuillez modifier avec prudence**.\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "Veuillez sélectionner la carte réseau à utiliser", + "enable_subscribe": "Activer la souscription" + } + }, + "config_confirm": { + "title": "Confirmer la configuration", + "description": "**{nick_name}** Bonjour ! Veuillez confirmer les dernières informations de configuration et cliquer sur \"Soumettre\".\r\nL'intégration rechargera avec la nouvelle configuration.\r\n\r\nLangue d'intégration : {lang_new}\r\nPseudo utilisateur : {nick_name_new}\r\nMode de débogage d'action : {action_debug}\r\nMasquer les entités générées non standard : {hide_non_standard_entities}\r\nModifications des appareils : Ajouter **{devices_add}** appareils, supprimer **{devices_remove}** appareils\r\nModifications des règles de conversion : **{trans_rules_count}** règles au total, mise à jour de **{trans_rules_count_success}** règles\r\n", + "data": { + "confirm": "Confirmer la modification" + } + } + }, + "progress": { + "oauth": "### {link_left}Veuillez cliquer ici pour vous reconnecter{link_right}" + }, + "error": { + "not_auth": "L'utilisateur n'est pas authentifié. Veuillez cliquer sur le lien d'authentification pour vous identifier.", + "get_token_error": "Impossible d'obtenir les informations d'authentification (jeton OAuth).", + "get_homeinfo_error": "Impossible d'obtenir les informations de la maison.", + "get_cert_error": "Impossible d'obtenir le certificat central.", + "no_family_selected": "Aucune maison sélectionnée.", + "no_devices": "Aucun périphérique dans la maison sélectionnée. Veuillez sélectionner une maison avec des périphériques et continuer.", + "no_central_device": "Le mode passerelle centrale nécessite une passerelle Xiaomi disponible dans le réseau local où est installé Home Assistant. Veuillez vérifier que la maison sélectionnée répond à cette exigence.", + "mdns_discovery_error": "Service de découverte de périphérique local en panne.", + "update_config_error": "Échec de la mise à jour des informations de configuration.", + "not_confirm": "La modification n'a pas été confirmée. Veuillez cocher la case de confirmation avant de soumettre." + }, + "abort": { + "network_connect_error": "Échec de la configuration. Problème de connexion réseau, veuillez vérifier la configuration du périphérique.", + "options_flow_error": "Erreur de réinitialisation de la configuration de l'intégration : {error}", + "re_add": "Veuillez réajouter l'intégration, message d'erreur : {error}", + "storage_error": "Erreur de stockage pour l'intégration. Veuillez réessayer ou réajouter l'intégration : {error}", + "inconsistent_account": "Les informations de compte sont incohérentes. Veuillez vous connecter avec les informations de compte correctes." + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/ja.json b/custom_components/xiaomi_home/translations/ja.json new file mode 100644 index 0000000..235ee58 --- /dev/null +++ b/custom_components/xiaomi_home/translations/ja.json @@ -0,0 +1,146 @@ +{ + "config": { + "flow_title": "Xiaomi Home インテグレーション", + "step": { + "eula": { + "title": "リスク告知", + "description": "1. あなたの**Xiaomiユーザー情報とデバイス情報**は、Home Assistantシステムに保存されます。**XiaomiはHome Assistantの保存メカニズムの安全性を保証できません**。情報が盗まれないようにする責任はあなたにあります。\r\n2. このインテグレーションはオープンソースコミュニティによって管理されています。安定性の問題やその他の問題が発生する可能性があります。問題が発生した場合は、 **Xiaomi カスタマーサポートに連絡するのではなく、オープンソースコミュニティに助けを求める必要があります**。\r\n3. あなたはある程度の技術力を持っている必要があります。このインテグレーションは初心者には友好的ではありません。\r\n4. このインテグレーションを使用する前に、 **README をよく読んでください**。\r\n5. 統合の安定した使用を確保し、インターフェースの乱用を防ぐために、**この統合はHome Assistantでのみ使用することが許可されています。詳細についてはLICENSEを参照してください**。\r\n", + "data": { + "eula": "私は上記のリスクを理解し、インテグレーションを使用することによる関連するリスクを自己責任で引き受けます。" + } + }, + "auth_config": { + "title": "基本設定", + "description": "### ログインエリア\r\nXiaomi アカウントが属する地域を選択します。 `Xiaomi Home アプリ> マイ(ボトムメニューにあります)> その他の設定> Xiaomi Home について` で確認できます。\r\n### 言語\r\nデバイスおよびエンティティ名に使用される言語を選択します。一部の翻訳が欠落している場合、英語が表示されます。\r\n### OAuth2 認証リダイレクトアドレス\r\nOAuth2 認証リダイレクトアドレスは **[http://homeassistant.local:8123](http://homeassistant.local:8123)** です。Home Assistant は、現在の操作端末(たとえば、パーソナルコンピュータ)と同じ LAN 内にあり、操作端末がこのアドレスで Home Assistant ホームページにアクセスできる場合にのみログイン認証が成功する場合があります。\r\n### 注意事項\r\n- 数百台以上のMi Homeデバイスをお持ちのユーザーの場合、統合の初回追加には時間がかかります。しばらくお待ちください。\r\n- Home AssistantがDocker環境で実行されている場合は、Dockerのネットワークモードがhostに設定されていることを確認してください。そうしないと、ローカル制御機能が正しく動作しない可能性があります。\r\n- 統合のローカル制御機能にはいくつかの依存関係があります。READMEを注意深く読んでください。\r\n", + "data": { + "cloud_server": "ログインエリア", + "integration_language": "言語", + "oauth_redirect_url": "認証リダイレクトアドレス" + } + }, + "oauth_error": { + "title": "ログインエラー", + "description": "「次へ」をクリックして再試行してください" + }, + "devices_filter": { + "title": "ホームとデバイスを選択", + "description": "## 使用方法\r\n### 制御モード\r\n- 自動: ローカルエリアネットワーク内に利用可能なXiaomi中央ゲートウェイが存在する場合、Home Assistantは中央ゲートウェイを介してデバイス制御コマンドを優先的に送信し、ローカル制御機能を実現します。ローカルエリアネットワーク内に中央ゲートウェイが存在しない場合、Xiaomi OTプロトコルを介して制御コマンドを送信し、ローカル制御機能を実現しようとします。上記のローカル制御条件が満たされない場合にのみ、デバイス制御コマンドはクラウドを介して送信されます。\r\n- クラウド: 制御コマンドはクラウドを介してのみ送信されます。\r\n### 導入されたデバイスのホーム\r\n統合は、選択された家庭にあるデバイスを追加します。\r\n### 部屋名同期モード\r\nXiaomi Home アプリから Home Assistant に同期されるデバイスの場合、デバイスが Home Assistant 内でどのような領域にあるかを示す名前の命名方式は、以下のルールに従います。ただし、デバイスの同期プロセスは、Xiaomi Home アプリで家庭および部屋の設定を変更しないことに注意してください。\r\n- 同期しない:デバイスはどの領域にも追加されません。\r\n- その他のオプション:デバイスが追加される領域は、Xiaomi Home アプリの家庭または部屋の名前に従って命名されます。\r\n### Action デバッグモード\r\nデバイスが MIoT-Spec-V2 で定義された方法を実行する場合、通知エンティティの生成に加えて、テキスト入力ボックスエンティティも生成されます。これを使用して、デバイスに制御命令を送信することができます。\r\n### 非標準生成エンティティを非表示にする\r\n「*」で始まる名前の非標準 MIoT-Spec-V2 インスタンスによって生成されたエンティティを非表示にします。\r\n\r\n \r\n### {nick_name} さん、こんにちは! 統合制御モードと追加するデバイスがあるホームを選択してください。\r\n", + "data": { + "ctrl_mode": "制御モード", + "home_infos": "導入されたデバイスのホーム", + "area_name_rule": "部屋名同期モード", + "action_debug": "Action デバッグモード", + "hide_non_standard_entities": "非標準生成エンティティを非表示にする" + } + } + }, + "progress": { + "oauth": "### {link_left}ここをクリックして再度ログインしてください{link_right}\r\n(ログインに成功すると、自動的に次のページにリダイレクトされます)" + }, + "error": { + "eula_not_agree": "リスク告知文書を読んでください。", + "get_token_error": "ログイン認証情報(OAuth トークン)を取得できませんでした。", + "get_homeinfo_error": "ホーム情報を取得できませんでした。", + "mdns_discovery_error": "ローカルデバイス検出サービスに異常があります。", + "get_cert_error": "ゲートウェイ証明書を取得できませんでした。", + "no_family_selected": "家庭が選択されていません。", + "no_devices": "選択された家庭にデバイスがありません。デバイスがある家庭を選択して続行してください。", + "no_central_device": "【中央ゲートウェイモード】Home Assistant が存在する LAN 内に使用可能な Xiaomi 中央ゲートウェイがある必要があります。選択された家庭がこの要件を満たしているかどうかを確認してください。", + "update_config_error": "設定情報の更新に失敗しました。", + "not_confirm": "変更項目が確認されていません。確認を選択してから送信してください。" + }, + "abort": { + "network_connect_error": "設定に失敗しました。ネットワーク接続に異常があります。デバイスのネットワーク設定を確認してください。", + "already_configured": "このユーザーはすでに設定が完了しています。統合ページにアクセスして、「設定」ボタンをクリックして設定を変更してください。", + "invalid_auth_info": "認証情報が期限切れになりました。統合ページにアクセスして、「設定」ボタンをクリックして再度認証してください。", + "config_flow_error": "統合設定エラー:{error}" + } + }, + "options": { + "step": { + "auth_config": { + "title": "認証構成", + "description": "ローカル認証情報が期限切れになっています。認証を再開してください。\r\n### 現在のログインエリア:{cloud_server}\r\n### OAuth2 認証リダイレクトアドレス\r\nOAuth2 認証リダイレクトアドレスは **[http://homeassistant.local:8123](http://homeassistant.local:8123)** です。Home Assistant は、現在の操作端末(たとえば、パーソナルコンピュータ)と同じ LAN 内にあり、操作端末がこのアドレスで Home Assistant ホームページにアクセスできる場合にのみログイン認証が成功する場合があります。\r\n", + "data": { + "oauth_redirect_url": "認証リダイレクトアドレス" + } + }, + "oauth_error": { + "title": "ログインエラー", + "description": "「次へ」をクリックして再試行してください" + }, + "config_options": { + "title": "設定オプション", + "description": "### {nick_name} さん、こんにちは!\r\n\r\nXiaomi アカウントID:{uid}\r\n現在のログインエリア:{cloud_server}\r\n\r\n必要な構成オプションを選択して、[次へ] をクリックしてください。\r\n", + "data": { + "integration_language": "統合言語", + "update_user_info": "ユーザー情報を更新する", + "update_devices": "デバイスリストを更新する", + "action_debug": "Action デバッグモード", + "hide_non_standard_entities": "非標準生成エンティティを非表示にする", + "update_trans_rules": "エンティティ変換ルールを更新する (グローバル設定)", + "update_lan_ctrl_config": "LAN制御構成を更新する(グローバル設定)" + } + }, + "update_user_info": { + "title": "ユーザー名を更新する", + "description": "{nick_name} さん、こんにちは! 下のボックスからユーザー名を変更してください。\r\n", + "data": { + "nick_name": "ユーザー名" + } + }, + "devices_filter": { + "title": "ホームとデバイスを再度選択", + "description": "## 使用方法\r\n### 制御モード\r\n- 自動: ローカルエリアネットワーク内に利用可能なXiaomi中央ゲートウェイが存在する場合、Home Assistantは中央ゲートウェイを介してデバイス制御コマンドを優先的に送信し、ローカル制御機能を実現します。ローカルエリアネットワーク内に中央ゲートウェイが存在しない場合、Xiaomi OTプロトコルを介して制御コマンドを送信し、ローカル制御機能を実現しようとします。上記のローカル制御条件が満たされない場合にのみ、デバイス制御コマンドはクラウドを介して送信されます。\r\n- クラウド: 制御コマンドはクラウドを介してのみ送信されます。\r\n### 導入されたデバイスのホーム\r\n統合は、選択された家庭にあるデバイスを追加します。\r\n \r\n### {nick_name} さん、こんにちは! 統合制御モードと追加するデバイスがあるホームを選択してください。\r\n", + "data": { + "ctrl_mode": "制御モード", + "home_infos": "導入されたデバイスのホーム" + } + }, + "update_trans_rules": { + "title": "エンティティ変換ルールを更新する", + "description": "## 使用方法\r\n- 現在の統合インスタンス内のデバイスのエンティティ情報を更新します。これには、SPEC多言語設定、SPECブール値翻訳、SPECモデルフィルタリングが含まれます。\r\n- **警告: この設定はグローバル設定**であり、ローカルキャッシュを直接更新します。他の統合インスタンスに同じモデルのデバイスがある場合、関連するインスタンスを再読み込みした後に更新されます。\r\n- この操作には時間がかかるため、しばらくお待ちください。「更新を確認」を選択し、「次へ」をクリックして **{urn_count}** ルールの更新を開始します。そうでない場合は、更新をスキップします。\r\n", + "data": { + "confirm": "確認する" + } + }, + "update_lan_ctrl_config": { + "title": "LAN制御構成を更新する", + "description": "## 使用方法\r\n**Xiaomi HomeデバイスのLAN制御**の設定情報を更新します。クラウドと中央ゲートウェイがデバイスを制御できない場合、統合はLANを介してデバイスを制御しようとします。ネットワークカードが選択されていない場合、LAN制御は有効になりません。\r\n- 現在、LAN内の**SPEC v2** WiFiデバイスのみがサポートされています。一部の古いデバイスは、制御やプロパティの同期をサポートしていない場合があります。\r\n- デバイスと同じネットワーク上のネットワークカードを選択してください(複数選択がサポートされています)。選択したネットワークカードが同じネットワークに2つ以上の接続を持っている場合は、最適なネットワーク接続を持つものを選択することをお勧めします。そうしないと、デバイスの正常な使用に**影響を与える可能性があります**。\r\n- **LAN内にローカル制御をサポートする端末デバイス(ゲートウェイ、携帯電話など)が存在する場合、LANサブスクリプションを有効にすると、ローカルオートメーションやデバイスの異常が発生する可能性があります。慎重に使用してください**。\r\n- **警告:この設定はグローバル設定であり、変更は他の統合インスタンスに影響を与えます。慎重に変更してください**。\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "使用するネットワークカードを選択してください", + "enable_subscribe": "LANサブスクリプションを有効にする" + } + }, + "config_confirm": { + "title": "構成を確認する", + "description": "**{nick_name}** さん、こんにちは! 最新の構成情報を確認してください。[送信] をクリックして、更新された構成を使用して再度読み込みます。\r\n\r\n統合言語:\t{lang_new}\r\nユーザー名:\t{nick_name_new}\r\nAction デバッグモード:\t{action_debug}\r\n非標準生成エンティティを非表示にする:\t{hide_non_standard_entities}\r\nデバイス変更:\t追加 **{devices_add}** 個のデバイス、削除 **{devices_remove}** 個のデバイス\r\n変換ルール変更:\t合計 **{trans_rules_count}** 個の規則、更新 **{trans_rules_count_success}** 個の規則\r\n", + "data": { + "confirm": "変更を確認する" + } + } + }, + "progress": { + "oauth": "### {link_left}ここをクリックして再度ログインしてください{link_right}" + }, + "error": { + "not_auth": "ユーザーが認証されていません。認証リンクをクリックしてユーザーの身元を確認してください。", + "get_token_error": "ログイン認証情報(OAuthトークン)の取得に失敗しました。", + "get_homeinfo_error": "家庭情報の取得に失敗しました。", + "get_cert_error": "中枢証明書の取得に失敗しました。", + "no_family_selected": "家族が選択されていません。", + "no_devices": "選択された家庭にはデバイスがありません。デバイスがある家庭を選択してから続行してください。", + "no_central_device": "【中枢ゲートウェイモード】には、Home Assistantが存在するローカルネットワークに使用可能なXiaomi Central Hub Gatewayが存在する必要があります。選択された家庭がこの要件を満たしているかどうかを確認してください。", + "mdns_discovery_error": "ローカルデバイス発見サービスが異常です。", + "update_config_error": "構成情報の更新に失敗しました。", + "not_confirm": "変更を確認していません。確認をチェックしてから送信してください。" + }, + "abort": { + "network_connect_error": "構成に失敗しました。ネットワーク接続に異常があります。デバイスのネットワーク構成を確認してください。", + "options_flow_error": "統合の再設定エラー:{error}", + "re_add": "統合を再度追加してください。エラーメッセージ:{error}", + "storage_error": "統合ストレージモジュールに異常があります。再試行するか、統合を再度追加してください:{error}", + "inconsistent_account": "アカウント情報が一致しません。正しいアカウントでログインしてください。" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/ru.json b/custom_components/xiaomi_home/translations/ru.json new file mode 100644 index 0000000..0d18c49 --- /dev/null +++ b/custom_components/xiaomi_home/translations/ru.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "Ми Дома Интеграция", + "step": { + "eula": { + "title": "Риск-информирование", + "description": "1. Ваша **информация о пользователе Xiaomi и информация об устройстве** будет храниться в вашей системе Home Assistant. **Xiaomi не может гарантировать безопасность механизма хранения Home Assistant**. Вы несете ответственность за предотвращение кражи вашей информации.\r\n2. Эта интеграция поддерживается сообществом Open Source и может иметь проблемы с устойчивостью или другими проблемами, если вы столкнулись с проблемами при использовании этой интеграции, **вы должны обратиться за помощью к сообществу Open Source, а не к службе поддержки Xiaomi**.\r\n3. Вам нужно иметь определенные технические навыки для поддержания вашей локальной рабочей среды, эта интеграция не является дружественной для новичков.\r\n4. Перед использованием этой интеграции, **внимательно прочитайте README**.\r\n5. Чтобы обеспечить стабильное использование интеграции и предотвратить злоупотребление интерфейсом, **эта интеграция разрешена только для использования в Home Assistant. Для получения подробной информации, пожалуйста, обратитесь к LICENSE**.\r\n", + "data": { + "eula": "Я ознакомлен с вышеуказанными рисками и добровольно несу связанные с использованием интеграции риски." + } + }, + "auth_config": { + "title": "Основные настройки", + "description": "### Регион входа в систему\r\nВыберите регион, в котором находится ваша учетная запись Xiaomi. Вы можете узнать об этом в `Xiaomi Home> Мой (в нижнем меню)> Дополнительные настройки> О Xiaomi Home`.\r\n### Язык\r\nВыберите язык, используемый для имен устройств и сущностей. Части предложений, которые не имеют перевода, будут отображаться на английском языке.\r\n### Адрес перенаправления для аутентификации OAuth2\r\nАдрес перенаправления для аутентификации OAuth2 - ** [http: //homeassistant.local: 8123] (http: //homeassistant.local: 8123) **, Home Assistant должен находиться в одной локальной сети с текущим терминалом (например, персональный компьютер), и терминал должен иметь доступ к домашней странице Home Assistant по этому адресу, в противном случае аутентификация входа может завершиться неудачно.\r\n### Примечание\r\n- Для пользователей с сотнями или более устройств Mi Home первоначальное добавление интеграции займет некоторое время. Пожалуйста, будьте терпеливы.\r\n- Если Home Assistant работает в среде Docker, убедитесь, что сетевой режим Docker установлен на host, иначе функция локального управления может работать неправильно.\r\n- Функция локального управления интеграции имеет некоторые зависимости. Пожалуйста, внимательно прочитайте README.\r\n", + "data": { + "cloud_server": "Регион входа в систему", + "integration_language": "Язык", + "oauth_redirect_url": "Адрес перенаправления для аутентификации OAuth2" + } + }, + "oauth_error": { + "title": "Ошибка входа в систему", + "description": "Нажмите кнопку «Далее», чтобы повторить попытку" + }, + "devices_filter": { + "title": "Выберите дом и устройства", + "description": "## Инструкция по использованию\r\n### Режим управления\r\n- Авто: Когда в локальной сети доступен центральный шлюз Xiaomi, Home Assistant будет в первую очередь отправлять команды управления устройствами через центральный шлюз для достижения локализованного управления. Если в локальной сети нет центрального шлюза, он попытается отправить команды управления через протокол Xiaomi OT для достижения локализованного управления. Только если вышеуказанные условия локализованного управления не выполняются, команды управления устройствами будут отправляться через облако.\r\n- Облако: Команды управления отправляются только через облако.\r\n### Импорт домашнего устройства\r\nИнтеграция добавит устройства из выбранных домов.\r\n### Режим синхронизации имен комнат\r\nПри синхронизации устройств из приложения Xiaomi Home в Home Assistant имена комнат устройств в Home Assistant будут именоваться в соответствии с именами дома или комнаты в приложении Xiaomi Home.\r\n- Не синхронизировать: устройство не будет добавлено в любую область.\r\n- Другие параметры: область, в которую добавляется устройство, называется именем дома или комнаты в приложении Xiaomi Home.\r\n### Режим отладки Action\r\nДля методов, определенных в MIoT-Spec-V2, помимо создания уведомительной сущности будет создана сущность текстового поля ввода, которую можно использовать для отправки команд управления устройством во время отладки.\r\n### Скрыть нестандартные сущности\r\nСкрыть сущности, созданные нестандартными примерами MIoT-Spec-V2, имена которых начинаются с « * ».\r\n\r\n \r\n### {nick_name} Здравствуйте! Выберите режим управления интеграцией и дом, в котором находятся устройства, которые вы хотите добавить.\r\n", + "data": { + "ctrl_mode": "Режим управления", + "home_infos": "Импорт домашнего устройства", + "area_name_rule": "Режим синхронизации имен комнат", + "action_debug": "Режим отладки Action", + "hide_non_standard_entities": "Скрыть нестандартные сущности" + } + } + }, + "progress": { + "oauth": "### {link_left}Пожалуйста, нажмите здесь, чтобы войти в систему{link_right}\r\n(После успешного входа вы будете автоматически перенаправлены на следующую страницу)" + }, + "error": { + "eula_not_agree": "Пожалуйста, ознакомьтесь с текстом рискового информирования.", + "get_token_error": "Не удалось получить информацию об авторизации входа в систему (OAuth token).", + "get_homeinfo_error": "Не удалось получить информацию о домашнем устройстве.", + "mdns_discovery_error": "Сервис обнаружения локальных устройств недоступен.", + "get_cert_error": "Не удалось получить сертификат центрального шлюза.", + "no_family_selected": "Не выбрана домашняя сеть.", + "no_devices": "В выбранной домашней сети нет устройств. Пожалуйста, выберите домашнюю сеть с устройствами и продолжайте.", + "no_central_device": "Для режима центрального шлюза Xiaomi необходимо наличие доступного центрального шлюза Xiaomi в локальной сети Home Assistant. Проверьте, соответствует ли выбранная домашняя сеть этому требованию.", + "abort": { + "network_connect_error": "Ошибка настройки. Сетевое подключение недоступно. Проверьте настройки сети устройства.", + "already_configured": "Этот пользователь уже настроен. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы изменить настройки.", + "invalid_auth_info": "Информация об авторизации истекла. Перейдите на страницу интеграции и нажмите кнопку «Настроить», чтобы переавторизоваться.", + "config_flow_error": "Ошибка настройки интеграции: {error}" + } + } + }, + "options": { + "step": { + "auth_config": { + "title": "Настройка аутентификации", + "description": "Обнаружено, что информация об аутентификации локальной сети устарела, повторите аутентификацию.\r\n### Текущий регион входа в систему: {cloud_server}\r\n### Адрес перенаправления для аутентификации OAuth2\r\nАдрес перенаправления для аутентификации OAuth2 - ** [http: //homeassistant.local: 8123] (http: //homeassistant.local: 8123) **, Home Assistant должен находиться в одной локальной сети с текущим терминалом (например, персональный компьютер), и терминал должен иметь доступ к домашней странице Home Assistant по этому адресу, в противном случае аутентификация входа может завершиться неудачно.\r\n", + "data": { + "oauth_redirect_url": "Адрес перенаправления для аутентификации OAuth2" + } + }, + "oauth_error": { + "title": "Ошибка входа в систему", + "description": "Нажмите кнопку «Далее», чтобы повторить попытку" + }, + "config_options": { + "title": "Параметры настройки", + "description": "### {nick_name} Здравствуйте!\r\n\r\nID учетной записи Xiaomi: {uid}\r\nТекущий регион входа в систему: {cloud_server}\r\n\r\nВыберите параметры, которые нужно настроить заново, а затем нажмите «Далее».\r\n", + "data": { + "integration_language": "Язык интеграции", + "update_user_info": "Обновить информацию о пользователе", + "update_devices": "Обновить список устройств", + "action_debug": "Режим отладки Action", + "hide_non_standard_entities": "Скрыть нестандартные сущности", + "update_trans_rules": "Обновить правила преобразования сущностей (глобальная настройка)", + "update_lan_ctrl_config": "Обновить конфигурацию управления LAN (глобальная настройка)" + } + }, + "update_user_info": { + "title": "Обновить имя пользователя", + "description": "{nick_name} Здравствуйте! Пожалуйста, введите свое имя пользователя ниже.\r\n", + "data": { + "nick_name": "Имя пользователя" + } + }, + "devices_filter": { + "title": "Выберите дом и устройства", + "description": "## Инструкция по использованию\r\n### Режим управления\r\n- Авто: Когда в локальной сети доступен центральный шлюз Xiaomi, Home Assistant будет в первую очередь отправлять команды управления устройствами через центральный шлюз для достижения локализованного управления. Если в локальной сети нет центрального шлюза, он попытается отправить команды управления через протокол Xiaomi OT для достижения локализованного управления. Только если вышеуказанные условия локализованного управления не выполняются, команды управления устройствами будут отправляться через облако.\r\n- Облако: Команды управления отправляются только через облако.\r\n### Импорт домашнего устройства\r\nИнтеграция добавит устройства из выбранных домов.\r\n \r\n### {nick_name} Здравствуйте! Выберите режим управления интеграцией и дом, в котором находятся устройства, которые вы хотите добавить.\r\n", + "data": { + "ctrl_mode": "Режим управления", + "home_infos": "Импорт домашнего устройства" + } + }, + "update_trans_rules": { + "title": "Обновить правила преобразования сущностей", + "description": "## Инструкция по использованию\r\n- Обновите информацию об объектах устройств в текущем экземпляре интеграции, включая многоязычную конфигурацию SPEC, булевый перевод SPEC и фильтрацию моделей SPEC.\r\n- **Предупреждение: Эта конфигурация является глобальной конфигурацией** и напрямую обновит локальный кэш. Если в других экземплярах интеграции есть устройства той же модели, соответствующие экземпляры также будут обновлены после перезагрузки.\r\n- Эта операция займет некоторое время, пожалуйста, будьте терпеливы. Установите флажок \"Подтвердить обновление\" и нажмите \"Далее\", чтобы начать обновление **{urn_count}** правил, иначе пропустите обновление.\r\n", + "data": { + "confirm": "Подтвердить обновление" + } + }, + "update_lan_ctrl_config": { + "title": "Обновить конфигурацию управления LAN", + "description": "## Инструкция по использованию\r\nОбновите информацию о конфигурации для **LAN-управления устройствами Xiaomi Home**. Когда облако и центральный шлюз не могут управлять устройствами, интеграция попытается управлять устройствами через LAN; если сетевая карта не выбрана, управление через LAN не будет включено.\r\n- В настоящее время поддерживаются только устройства WiFi **SPEC v2** в локальной сети. Некоторые старые устройства могут не поддерживать управление или синхронизацию свойств.\r\n- Пожалуйста, выберите сетевую карту(и) в той же сети, что и устройства (поддерживается множественный выбор). Если выбранная сетевая карта имеет два или более соединений в одной сети, рекомендуется выбрать ту, которая имеет наилучшее сетевое соединение, иначе это может **повлиять на нормальное использование устройств**.\r\n- **Если в локальной сети есть терминальные устройства (шлюзы, мобильные телефоны и т. д.), поддерживающие локальное управление, включение подписки на LAN может вызвать локальную автоматизацию или аномалии устройств. Пожалуйста, используйте с осторожностью**.\r\n- **Предупреждение: Эта конфигурация является глобальной, и изменения повлияют на другие экземпляры интеграции. Пожалуйста, изменяйте с осторожностью**.\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "Пожалуйста, выберите сетевую карту для использования", + "enable_subscribe": "Включить подписку LAN" + } + }, + "config_confirm": { + "title": "Подтверждение настройки", + "description": "**{nick_name}** Здравствуйте! Подтвердите последнюю информацию о настройке и нажмите «Отправить». Интеграция будет перезагружена с использованием обновленных настроек.\r\n\r\nЯзык интеграции:\t{lang_new}\r\nИмя пользователя:\t{nick_name_new}\r\nРежим отладки Action:\t{action_debug}\r\nСкрыть непроизводственные сущности:\t{hide_non_standard_entities}\r\nИзменение устройства:\tДобавлено **{devices_add}** устройство, удалено **{devices_remove}** устройства\r\nИзменение правил преобразования:\tВсего **{trans_rules_count}** правил, обновлено **{trans_rules_count_success}** правил\r\n", + "data": { + "confirm": "Подтвердить изменения" + } + } + }, + "progress": { + "oauth": "### {link_left}Пожалуйста, нажмите здесь, чтобы повторно войти в систему{link_right}" + }, + "error": { + "not_auth": "Пользователь не аутентифицирован. Нажмите ссылку для аутентификации пользователя.", + "get_token_error": "Не удалось получить информацию об авторизации входа (OAuth token).", + "get_homeinfo_error": "Не удалось получить информацию о домашней сети.", + "get_cert_error": "Не удалось получить центральный сертификат.", + "no_family_selected": "Не выбрана семья.", + "no_devices": "В выбранной семье нет устройств. Пожалуйста, выберите семью с устройствами и продолжайте.", + "no_central_device": "Для режима центрального шлюза необходим существующий в локальной сети Home Assistant с доступным Xiaomi-шлюзом. Пожалуйста, проверьте, соответствует ли выбранная семья этому требованию.", + "mdns_discovery_error": "Ошибка сервиса поиска локальных устройств.", + "update_config_error": "Не удалось обновить информацию о конфигурации.", + "not_confirm": "Изменение не подтверждено. Пожалуйста, отметьте для подтверждения и отправки." + }, + "abort": { + "network_connect_error": "Ошибка конфигурации. Сбой сетевого подключения. Проверьте настройки сети устройства.", + "options_flow_error": "Ошибка повторной настройки интеграции: {error}", + "re_add": "Пожалуйста, добавьте интеграцию снова. Информация об ошибке: {error}", + "storage_error": "Ошибка хранения интеграции. Пожалуйста, повторите попытку или добавьте интеграцию снова: {error}", + "inconsistent_account": "Информация об аккаунте не соответствует. Пожалуйста, используйте правильный аккаунт для входа." + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/zh-Hans.json b/custom_components/xiaomi_home/translations/zh-Hans.json new file mode 100644 index 0000000..35b2c2f --- /dev/null +++ b/custom_components/xiaomi_home/translations/zh-Hans.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "米家集成", + "step": { + "eula": { + "title": "风险告知", + "description": "1. 您的小米用户信息和设备信息将会存储在您的 Home Assistant 系统中,**小米无法保证 Home Assistant 存储机制的安全性**。您需要负责防止您的信息被窃取。\r\n2. 此集成由开源社区维护,可能会出现稳定性问题或其它问题,使用此集成遇到相关问题时,您应当**向开源社区寻求帮助,而不是联系小米客服**。\r\n3. 您需要有一定的技术能力来维护您的本地运行环境,此集成对新手用户来说并不友好。\r\n4. 在使用此集成前,请仔细阅读README。\r\n5. 为了用户能够稳定地使用集成,避免接口被滥用,**此集成仅允许在 Home Assistant 中使用,详情请参考LICENSE**。\r\n", + "data": { + "eula": "我已悉知以上风险并自愿承担因使用集成所带来的相关风险。" + } + }, + "auth_config": { + "title": "基础配置", + "description": "### 登录地区\r\n选择小米账号所在的地区。您可以在 `米家APP > 我的(位于底部菜单) > 更多设置 > 关于米家` 中查看。\r\n### 语言\r\n选择设备及实体名称所用的语言。缺少翻译的部分语句将使用英文显示。\r\n### OAuth2 认证跳转地址\r\nOAuth2 认证跳转地址为 **[http://homeassistant.local:8123](http://homeassistant.local:8123)**,Home Assistant 需要与当前操作终端(例如,个人电脑)在同一局域网内,且操作终端能通过该地址访问 Home Assistant 首页,否则登录认证可能会失败。\r\n### 注意事项\r\n- 对于数百个及以上米家设备的用户,首次添加集成会耗费一些时间,请耐心等待。\r\n- 如果 Home Assistant 运行在docker环境下,请确保docker网络模式为host,否则会导致本地控制功能异常。\r\n- 集成本地控制功能存在一些依赖项,请仔细阅读README。\r\n", + "data": { + "cloud_server": "登录地区", + "integration_language": "语言", + "oauth_redirect_url": "认证跳转地址" + } + }, + "oauth_error": { + "title": "登录出现错误", + "description": "点击“下一步”重试" + }, + "devices_filter": { + "title": "选择家庭与设备", + "description": "## 使用介绍\r\n### 控制模式\r\n- 自动:本地局域网内存在可用的小米中枢网关时, Home Assistant 会优先通过中枢网关发送设备控制指令,以实现本地化控制功能。本地局域网不存在中枢时,会尝试通过小米OT协议发送控制指令,以实现本地化控制功能。只有当上述本地化控制条件不满足时,设备控制指令才会通过云端发送。\r\n- 云端:控制指令仅通过云端发送。\r\n### 导入设备的家庭\r\n集成将添加已选中家庭中的设备。\r\n### 房间名同步模式\r\n将设备从米家APP同步到 Home Assistant 时,设备在 Home Assistant 中所处区域的名称的命名方式将遵循以下规则。注意,设备同步过程不会改变米家APP中家庭和房间的设置。\r\n- 不同步:设备不会被添加至任何区域。\r\n- 其它选项:设备所添加到的区域以米家APP中的家庭或房间名称命名。\r\n### Action 调试模式\r\n对于设备 MIoT-Spec-V2 定义的方法,在生成通知实体之外,还会生成一个文本输入框实体,您可以在调试时用它向设备发送控制指令。\r\n### 隐藏非标准生成实体\r\n隐藏名称以“*”开头的非标准 MIoT-Spec-V2 实例生成的实体。\r\n\r\n \r\n### {nick_name} 您好!请选择集成控制模式以及您想要添加的设备所处的家庭。\r\n", + "data": { + "ctrl_mode": "控制模式", + "home_infos": "导入设备的家庭", + "area_name_rule": "房间名同步模式", + "action_debug": "Action 调试模式", + "hide_non_standard_entities": "隐藏非标准生成实体" + } + } + }, + "progress": { + "oauth": "### {link_left}请点击此处进行登录{link_right}\r\n(登录成功后,将会自动跳转至下一页面)" + }, + "error": { + "eula_not_agree": "请阅读风险告知文本。", + "get_token_error": "获取登录授权信息(OAuth token)失败。", + "get_homeinfo_error": "获取家庭信息失败。", + "mdns_discovery_error": "本地设备发现服务异常。", + "get_cert_error": "获取中枢证书失败。", + "no_family_selected": "未选择家庭。", + "no_devices": "选择的家庭中没有设备。请选择有设备的家庭,而后继续。", + "no_central_device": "【中枢网关模式】需要 Home Assistant 所在的局域网中存在可用的小米中枢网关。请检查选择的家庭是否符合该要求。" + }, + "abort": { + "network_connect_error": "配置失败。网络连接异常,请检查设备网络配置。", + "already_configured": "该用户已配置完成。请进入集成页面,点击“配置”按钮修改配置。", + "invalid_auth_info": "认证信息已过期。请进入集成页面,点击“配置”按钮重新认证。", + "config_flow_error": "集成配置错误:{error}" + } + }, + "options": { + "step": { + "auth_config": { + "title": "认证配置", + "description": "检测到本地认证信息过期,请重新开始认证\r\n### 当前登录地区: {cloud_server}\r\n### OAuth2 认证跳转地址\r\nOAuth2 认证跳转地址为 **[http://homeassistant.local:8123](http://homeassistant.local:8123)**,Home Assistant 需要与当前操作终端(例如,个人电脑)在同一局域网内,且操作终端能通过该地址访问 Home Assistant 首页,否则登录认证可能会失败。\r\n", + "data": { + "oauth_redirect_url": "认证跳转地址" + } + }, + "oauth_error": { + "title": "登录出现错误", + "description": "点击“下一步”重试" + }, + "config_options": { + "title": "配置选项", + "description": "### {nick_name} 您好!\r\n\r\n小米账号ID:{uid}\r\n当前登录区域:{cloud_server}\r\n\r\n请勾选需要重新配置的选项,然后点击“下一步”。\r\n", + "data": { + "integration_language": "集成语言", + "update_user_info": "更新用户信息", + "update_devices": "更新设备列表", + "action_debug": "Action 调试模式", + "hide_non_standard_entities": "隐藏非标准生成实体", + "update_trans_rules": "更新实体转换规则", + "update_lan_ctrl_config": "更新局域网控制配置" + } + }, + "update_user_info": { + "title": "更新用户昵称", + "description": "{nick_name} 您好!请在下方修改您的用户昵称。\r\n", + "data": { + "nick_name": "用户昵称" + } + }, + "devices_filter": { + "title": "重新选择家庭与设备", + "description": "## 使用介绍\r\n### 控制模式\r\n- 自动:本地局域网内存在可用的小米中枢网关时, Home Assistant 会优先通过中枢网关发送设备控制指令,以实现本地化控制功能。本地局域网不存在中枢时,会尝试通过小米OT协议发送控制指令,以实现本地化控制功能。只有当上述本地化控制条件不满足时,设备控制指令才会通过云端发送。\r\n- 云端:控制指令仅通过云端发送。\r\n### 导入设备的家庭\r\n集成将添加已选中家庭中的设备。\r\n \r\n### {nick_name} 您好!请选择集成控制模式以及您想要添加的设备所处的家庭。\r\n", + "data": { + "ctrl_mode": "控制模式", + "home_infos": "导入设备的家庭" + } + }, + "update_trans_rules": { + "title": "更新实体转换规则", + "description": "## 使用介绍\r\n- 更新当前集成实例中设备的实体信息,包含 MIoT-Spec-V2 多语言配置、布尔值翻译、过滤规则。\r\n- **警告**:该配置为全局配置,将会更新本地缓存,会影响所有集成实例。\r\n- 该操作会耗费一定时间,请耐心等待,勾选“确认更新”,点击“下一步”开始更新 **{urn_count}** 条规则,否则跳过更新。\r\n", + "data": { + "confirm": "确认更新" + } + }, + "update_lan_ctrl_config": { + "title": "更新局域网控制配置", + "description": "## 使用介绍\r\n更新小米局域网控制功能的配置信息。当云端和中枢网关均无法控制设备时,集成会尝试通过局域网控制设备。如果未选择网卡,局域网控制将不会生效。\r\n- 目前只支持控制局域网内的兼容 MIoT-Spec-V2 的 IP 设备,部分2020年之前生产的旧设备可能不支持局域网控制或者不支持局域网订阅。\r\n- 请选择和被控设备同一局域网的网卡(支持多选)。如果选择多个网卡导致 Home Assistant 到同一局域网存在多个连接,建议只保留最优的网络连接,否则可能会影响设备的正常使用。\r\n- 如果局域网内存在支持本地控制的终端设备(带屏音箱、手机等),启用局域网订阅可能会导致本地自动化或者设备异常。\r\n- **警告**:该配置为全局配置,会影响所有集成实例,请谨慎修改。\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "请选择使用的网卡", + "enable_subscribe": "启用局域网订阅" + } + }, + "config_confirm": { + "title": "确认配置", + "description": "**{nick_name}** 您好!请确认最新的配置信息,然后点击“提交”。\r\n集成将会使用更新后的配置重新载入。\r\n\r\n集成语言:\t{lang_new}\r\n用户昵称:\t{nick_name_new}\r\nAction 调试模式:\t{action_debug}\r\n隐藏非标准生成实体:\t{hide_non_standard_entities}\r\n设备变化:\t新增 **{devices_add}** 个设备,移除 **{devices_remove}** 个设备\r\n转换规则变化:\t共条 **{trans_rules_count}** 规则,更新 **{trans_rules_count_success}** 条规则\r\n", + "data": { + "confirm": "确认修改" + } + } + }, + "progress": { + "oauth": "### {link_left}请点击此处重新登录{link_right}" + }, + "error": { + "not_auth": "用户未认证。请点击认证链接以认证用户身份。", + "get_token_error": "获取登录授权信息(OAuth token)失败。", + "get_homeinfo_error": "获取家庭信息失败。", + "get_cert_error": "获取中枢证书失败。", + "no_family_selected": "未选择家庭。", + "no_devices": "选择的家庭中没有设备,请选择有设备的家庭,而后继续。", + "no_central_device": "【中枢网关模式】需要 Home Assistant 所在的局域网中存在可用的小米中枢网关。请检查选择的家庭是否符合该要求。", + "mdns_discovery_error": "本地设备发现服务异常。", + "update_config_error": "配置信息更新失败。", + "not_confirm": "未确认修改项。请勾选确认后再提交。" + }, + "abort": { + "network_connect_error": "配置失败。网络连接异常,请检查设备网络配置。", + "options_flow_error": "集成重新配置错误:{error}", + "re_add": "请重新添加集成,错误信息:{error}", + "storage_error": "集成存储模块异常。请重试或者重新添加集成:{error}", + "inconsistent_account": "账号信息不一致。请使用正确的账号登录。" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/translations/zh-Hant.json b/custom_components/xiaomi_home/translations/zh-Hant.json new file mode 100644 index 0000000..0e9ff74 --- /dev/null +++ b/custom_components/xiaomi_home/translations/zh-Hant.json @@ -0,0 +1,144 @@ +{ + "config": { + "flow_title": "米家集成", + "step": { + "eula": { + "title": "風險告知", + "description": "1. 您的**小米用戶信息和設備信息**將會存儲在您的 Home Assistant 系統中,**小米無法保證 Home Assistant 存儲機制的安全性**。您需要負責防止您的信息被竊取。\r\n2. 此集成由開源社區維護,可能會出現穩定性問題或其他問題,使用此集成遇到相關問題時,您應當**向開源社區尋求幫助,而不是聯繫小米客服**。\r\n3. 您需要有一定的技術能力來維護您的本地運行環境,此集成對新手用戶來說並不友好。\r\n4. 在使用此集成前,請**仔細閱讀README**。\r\n5. 為了用戶能夠穩定地使用集成,避免接口被濫用,**此集成僅允許在 Home Assistant 中使用,詳情請參考LICENSE**。\r\n", + "data": { + "eula": "我已悉知以上風險並自願承擔因使用集成所帶來的相關風險。" + } + }, + "auth_config": { + "title": "基礎配置", + "description": "### 登錄地區\r\n選擇小米帳號所在的地區。您可以在 `米家APP > 我的(位於底部菜單) > 更多設置 > 關於米家` 中查看。\r\n### 語言\r\n選擇設備及實體名稱所用的語言。缺少翻譯的部分語句將使用英文顯示。\r\n### OAuth2 認證跳轉地址\r\nOAuth2 認證跳轉地址為 **[http://homeassistant.local:8123](http://homeassistant.local:8123)**,Home Assistant 需要與當前操作終端(例如,個人電腦)在同一局域網內,且操作終端能通過該地址訪問 Home Assistant 首頁,否則登錄認證可能會失敗。\r\n### 注意事項\r\n- 對於數百個及以上米家設備的用戶,首次添加集成會耗費一些時間,請耐心等待。\r\n- 如果 Home Assistant 運行在docker環境下,請確保docker網絡模式為host,否則會導致本地控制功能異常。\r\n- 集成本地控制功能存在一些依賴項,請仔細閱讀README。\r\n", + "data": { + "cloud_server": "登錄地區", + "integration_language": "語言", + "oauth_redirect_url": "認證跳轉地址" + } + }, + "oauth_error": { + "title": "登錄出現錯誤", + "description": "點擊“下一步”重試" + }, + "devices_filter": { + "title": "選擇家庭與設備", + "description": "## 使用介紹\r\n### 控制模式\r\n- 自動:本地區域網內存在可用的小米中樞網關時, Home Assistant 會優先通過中樞網關發送設備控制指令,以實現本地化控制功能。本地區域網不存在中樞時,會嘗試通過小米OT協議發送控制指令,以實現本地化控制功能。只有當上述本地化控制條件不滿足時,設備控制指令才會通過雲端發送。\r\n- 雲端:控制指令僅通過雲端發送。\r\n### 導入設備的家庭\r\n集成將添加已選中家庭中的設備。\r\n### 房間名同步模式\r\n將設備從米家APP同步到 Home Assistant 時,設備在 Home Assistant 中所處區域的名稱的命名方式將遵循以下規則。注意,設備同步過程不會改變米家APP中家庭和房間的設置。\r\n- 不同步:設備不會被添加至任何區域。\r\n- 其它選項:設備所添加到的區域以米家APP中的家庭或房間名稱命名。\r\n### Action 調試模式\r\n對於設備 MIoT-Spec-V2 定義的方法,在生成通知實體之外,還會生成一個文本輸入框實體,您可以在調試時用它向設備發送控制指令。\r\n### 隱藏非標準生成實體\r\n隱藏名稱以“*”開頭的非標準 MIoT-Spec-V2 實例生成的實體。\r\n\r\n \r\n### {nick_name} 您好!請選擇集成控制模式以及您想要添加的設備所處的家庭。\r\n", + "data": { + "ctrl_mode": "控制模式", + "home_infos": "導入設備的家庭", + "area_name_rule": "房間名同步模式", + "action_debug": "Action 調試模式", + "hide_non_standard_entities": "隱藏非標準生成實體" + } + } + }, + "progress": { + "oauth": "### {link_left}請點擊此處進行登錄{link_right}\r\n(登錄成功後,將會自動跳轉至下一頁面)" + }, + "error": { + "eula_not_agree": "請閱讀風險告知文本。", + "get_token_error": "獲取登錄授權信息(OAuth token)失敗。", + "get_homeinfo_error": "獲取家庭信息失敗。", + "mdns_discovery_error": "本地設備發現服務異常。", + "get_cert_error": "獲取中樞證書失敗。", + "no_family_selected": "未選擇家庭。", + "no_devices": "選擇的家庭中沒有設備。請選擇有設備的家庭,而後繼續。", + "no_central_device": "【中樞網關模式】需要 Home Assistant 所在的局域網中存在可用的小米中樞網關。請檢查選擇的家庭是否符合該要求。" + }, + "abort": { + "network_connect_error": "配置失敗。網絡連接異常,請檢查設備網絡配置。", + "already_configured": "該用戶已配置完成。請進入集成頁面,點擊“配置”按鈕修改配置。", + "invalid_auth_info": "認證信息已過期。請進入集成頁面,點擊“配置”按鈕重新認證。", + "config_flow_error": "集成配置錯誤:{error}" + } + }, + "options": { + "step": { + "auth_config": { + "title": "認證配置", + "description": "檢測到本地認證信息過期,請重新開始認證\r\n### 當前登錄地區: {cloud_server}\r\n### OAuth2 認證跳轉地址\r\nOAuth2 認證跳轉地址為 **[http://homeassistant.local:8123](http://homeassistant.local:8123)**,Home Assistant 需要與當前操作終端(例如,個人電腦)在同一局域網內,且操作終端能通過該地址訪問 Home Assistant 首頁,否則登錄認證可能會失敗。\r\n", + "data": { + "oauth_redirect_url": "認證跳轉地址" + } + }, + "oauth_error": { + "title": "登錄出現錯誤", + "description": "點擊“下一步”重試" + }, + "config_options": { + "title": "配置選項", + "description": "### {nick_name} 您好!\r\n\r\n小米帳號ID:{uid}\r\n當前登錄區域:{cloud_server}\r\n\r\n請勾選需要重新配置的選項,然後點擊“下一步”。\r\n", + "data": { + "integration_language": "集成語言", + "update_user_info": "更新用戶信息", + "update_devices": "更新設備列表", + "action_debug": "Action 調試模式", + "hide_non_standard_entities": "隱藏非標準生成實體", + "update_trans_rules": "更新實體轉換規則", + "update_lan_ctrl_config": "更新局域網控制配置" + } + }, + "update_user_info": { + "title": "更新用戶暱稱", + "description": "{nick_name} 您好!請在下方修改您的用戶暱稱。\r\n", + "data": { + "nick_name": "用戶暱稱" + } + }, + "devices_filter": { + "title": "重新選擇家庭與設備", + "description": "\r\n## 使用介紹\r\n### 控制模式\r\n- 自動:本地局域網內存在可用的小米中樞網關時, Home Assistant 會優先通過中樞網關發送設備控制指令,以實現本地化控制功能。只有當本地化控制條件不滿足時,設備控制指令才會通過雲端發送。\r\n- 雲端:控制指令強制通過雲端發送。\r\n### 導入設備的家庭\r\n集成將添加已選中家庭中的設備。\r\n \r\n### {nick_name} 您好!請選擇集成控制模式以及您想要添加的設備所處的家庭。\r\n", + "data": { + "ctrl_mode": "控制模式", + "home_infos": "導入設備的家庭" + } + }, + "update_trans_rules": { + "title": "更新實體轉換規則", + "description": "## 使用介紹\r\n- 更新當前集成實例中設備的實體信息,包含SPEC多語言配置、SPEC布爾值翻譯、SPEC模型過濾。\r\n- **警告:該配置為全局配置**,會直接更新本地緩存,如果其它集成實例中有相同型號設備,相關實例重載後也會更新。\r\n- 該操作會耗費一定時間,請耐心等待,勾選“確認更新”,點擊“下一步”開始更新 **{urn_count}** 條規則,否則跳過更新。\r\n", + "data": { + "confirm": "確認更新" + } + }, + "update_lan_ctrl_config": { + "title": "更新局域網控制配置", + "description": "## 使用介紹\r\n更新**局域網控制米家設備**時的配置信息,當雲端和中樞網關無法控制設備時,集成會嘗試通過局域網控制設備;如果未選擇網卡,局域網控制將不會啟用。\r\n- 目前只支持控制局域網內 **SPEC v2** WiFi 設備,部分舊設備可能不支持控制或者不支持屬性同步。\r\n- 請選擇和設備同一網絡的網卡(支持多選),如果選擇的網卡存在兩個及以上連接在同一網絡中,建議選擇網絡連接最優的,否則可能會**影響設備正常使用**。\r\n- **如果局域網中存在支持本地控制的終端設備(網關、手機等),啟用局域網訂閱可能會導致本地自動化或者設備異常,請謹慎使用**。\r\n- **警告:該配置為全局配置,修改會影響其他集成實例,請謹慎修改**。\r\n{notice_net_dup}\r\n", + "data": { + "net_interfaces": "請選擇使用的網卡", + "enable_subscribe": "啟用局域網訂閱" + } + }, + "config_confirm": { + "title": "確認配置", + "description": "**{nick_name}** 您好!請確認最新的配置信息,然後點擊“提交”。\r\n集成將會使用更新後的配置重新載入。\r\n\r\n集成語言:\t{lang_new}\r\n用戶暱稱:\t{nick_name_new}\r\nAction 調試模式:\t{action_debug}\r\n隱藏非標準生成實體:\t{hide_non_standard_entities}\r\n設備變化:\t新增 **{devices_add}** 個設備,移除 **{devices_remove}** 個設備\r\n轉換規則變化:\t共條 **{trans_rules_count}** 規則,更新 **{trans_rules_count_success}** 條規則\r\n", + "data": { + "confirm": "確認修改" + } + } + }, + "progress": { + "oauth": "### {link_left}請點擊此處重新登錄{link_right}" + }, + "error": { + "not_auth": "用戶未認證。請點擊認證鏈接以認證用戶身份。", + "get_token_error": "獲取登錄授權信息(OAuth token)失敗。", + "get_homeinfo_error": "獲取家庭信息失敗。", + "get_cert_error": "獲取中樞證書失敗。", + "no_family_selected": "未選擇家庭。", + "no_devices": "選擇的家庭中沒有設備,請選擇有設備的家庭,而後繼續。", + "no_central_device": "【中樞網關模式】需要 Home Assistant 所在的局域網中存在可用的小米中樞網關。請檢查選擇的家庭是否符合該要求。", + "mdns_discovery_error": "本地設備發現服務異常。", + "update_config_error": "配置信息更新失敗。", + "not_confirm": "未確認修改項。請勾選確認後再提交。" + }, + "abort": { + "network_connect_error": "配置失敗。網絡連接異常,請檢查設備網絡配置。", + "options_flow_error": "集成重新配置錯誤:{error}", + "re_add": "請重新添加集成,錯誤信息:{error}", + "storage_error": "集成存儲模塊異常。請重試或者重新添加集成:{error}", + "inconsistent_account": "帳號信息不一致。請使用正確的帳號登錄。" + } + } +} \ No newline at end of file diff --git a/custom_components/xiaomi_home/vacuum.py b/custom_components/xiaomi_home/vacuum.py new file mode 100644 index 0000000..fda2d5a --- /dev/null +++ b/custom_components/xiaomi_home/vacuum.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Vacuum entities for Xiaomi Home. +""" +from __future__ import annotations +from typing import Any, Optional +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumEntityFeature +) + +from .miot.const import DOMAIN +from .miot.miot_device import MIoTDevice, MIoTServiceEntity, MIoTEntityData +from .miot.miot_spec import ( + MIoTSpecAction, + MIoTSpecProperty) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('vacuum', []): + new_entities.append( + Vacuum(miot_device=miot_device, entity_data=data)) + if new_entities: + async_add_entities(new_entities) + + +class Vacuum(MIoTServiceEntity, StateVacuumEntity): + """Vacuum entities for Xiaomi Home.""" + # pylint: disable=unused-argument + _prop_status: Optional[MIoTSpecProperty] + _prop_fan_level: Optional[MIoTSpecProperty] + _prop_battery_level: Optional[MIoTSpecProperty] + + _action_start_sweep: Optional[MIoTSpecAction] + _action_stop_sweeping: Optional[MIoTSpecAction] + _action_pause_sweeping: Optional[MIoTSpecAction] + _action_continue_sweep: Optional[MIoTSpecAction] + _action_stop_and_gocharge: Optional[MIoTSpecAction] + _action_identify: Optional[MIoTSpecAction] + + _status_map: Optional[dict[int, str]] + _fan_level_map: Optional[dict[int, str]] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_supported_features = VacuumEntityFeature(0) + + self._prop_status = None + self._prop_fan_level = None + self._prop_battery_level = None + self._action_start_sweep = None + self._action_stop_sweeping = None + self._action_pause_sweeping = None + self._action_continue_sweep = None + self._action_stop_and_gocharge = None + self._action_identify = None + self._status_map = None + self._fan_level_map = None + + # properties + for prop in entity_data.props: + if prop.name == 'status': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'invalid status value_list, %s', self.entity_id) + continue + self._status_map = { + item['value']: item['description'] + for item in prop.value_list} + self._prop_status = prop + elif prop.name == 'fan-level': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'invalid fan-level value_list, %s', self.entity_id) + continue + self._fan_level_map = { + item['value']: item['description'] + for item in prop.value_list} + self._attr_fan_speed_list = list(self._fan_level_map.values()) + self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED + self._prop_fan_level = prop + + elif prop.name == 'battery-level': + self._attr_supported_features |= VacuumEntityFeature.BATTERY + self._prop_battery_level = prop + # action + for action in entity_data.actions: + if action.name == 'start-sweep': + self._attr_supported_features |= VacuumEntityFeature.START + self._action_start_sweep = action + elif action.name == 'stop-sweeping': + self._attr_supported_features |= VacuumEntityFeature.STOP + self._action_stop_sweeping = action + elif action.name == 'pause-sweeping': + self._attr_supported_features |= VacuumEntityFeature.PAUSE + self._action_pause_sweeping = action + elif action.name == 'continue-sweep': + self._action_continue_sweep = action + elif action.name == 'stop-and-gocharge': + self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME + self._action_stop_and_gocharge = action + + elif action.name == 'identify': + self._attr_supported_features |= VacuumEntityFeature.LOCATE + self._action_identify = action + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + if self.state.lower() in ['paused', '暂停中']: + await self.action_async(action=self._action_continue_sweep) + return + await self.action_async(action=self._action_start_sweep) + + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner, do not return to base.""" + await self.action_async(action=self._action_stop_sweeping) + + async def async_pause(self) -> None: + """Pause the cleaning task.""" + await self.action_async(action=self._action_pause_sweeping) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self.action_async(action=self._action_stop_and_gocharge) + + async def async_clean_spot(self, **kwargs: Any) -> None: + """Perform a spot clean-up.""" + + async def async_locate(self, **kwargs: Any) -> None: + """Locate the vacuum cleaner.""" + await self.action_async(action=self._action_identify) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + + @property + def state(self) -> Optional[str]: + """Return the current state of the vacuum cleaner.""" + return self.get_map_description( + map_=self._status_map, + key=self.get_prop_value(prop=self._prop_status)) + + @property + def battery_level(self) -> Optional[int]: + """Return the current battery level of the vacuum cleaner.""" + return self.get_prop_value(prop=self._prop_battery_level) + + @property + def fan_speed(self) -> Optional[str]: + """Return the current fan speed of the vacuum cleaner.""" + return self.get_map_description( + map_=self._fan_level_map, + key=self.get_prop_value(prop=self._prop_fan_level)) diff --git a/custom_components/xiaomi_home/water_heater.py b/custom_components/xiaomi_home/water_heater.py new file mode 100644 index 0000000..1828316 --- /dev/null +++ b/custom_components/xiaomi_home/water_heater.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +""" +Copyright (C) 2024 Xiaomi Corporation. + +The ownership and intellectual property rights of Xiaomi Home Assistant +Integration and related Xiaomi cloud service API interface provided under this +license, including source code and object code (collectively, "Licensed Work"), +are owned by Xiaomi. Subject to the terms and conditions of this License, Xiaomi +hereby grants you a personal, limited, non-exclusive, non-transferable, +non-sublicensable, and royalty-free license to reproduce, use, modify, and +distribute the Licensed Work only for your use of Home Assistant for +non-commercial purposes. For the avoidance of doubt, Xiaomi does not authorize +you to use the Licensed Work for any other purpose, including but not limited +to use Licensed Work to develop applications (APP), Web services, and other +forms of software. + +You may reproduce and distribute copies of the Licensed Work, with or without +modifications, whether in source or object form, provided that you must give +any other recipients of the Licensed Work a copy of this License and retain all +copyright and disclaimers. + +Xiaomi provides the Licensed Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied, including, without +limitation, any warranties, undertakes, or conditions of TITLE, NO ERROR OR +OMISSION, CONTINUITY, RELIABILITY, NON-INFRINGEMENT, MERCHANTABILITY, or +FITNESS FOR A PARTICULAR PURPOSE. In any event, you are solely responsible +for any direct, indirect, special, incidental, or consequential damages or +losses arising from the use or inability to use the Licensed Work. + +Xiaomi reserves all rights not expressly granted to you in this License. +Except for the rights expressly granted by Xiaomi under this License, Xiaomi +does not authorize you in any form to use the trademarks, copyrights, or other +forms of intellectual property rights of Xiaomi and its affiliates, including, +without limitation, without obtaining other written permission from Xiaomi, you +shall not use "Xiaomi", "Mijia" and other words related to Xiaomi or words that +may make the public associate with Xiaomi in any form to publicize or promote +the software or hardware devices that use the Licensed Work. + +Xiaomi has the right to immediately terminate all your authorization under this +License in the event: +1. You assert patent invalidation, litigation, or other claims against patents +or other intellectual property rights of Xiaomi or its affiliates; or, +2. You make, have made, manufacture, sell, or offer to sell products that knock +off Xiaomi or its affiliates' products. + +Water heater entities for Xiaomi Home. +""" +from __future__ import annotations +import logging +from typing import Optional + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.water_heater import ( + ATTR_TEMPERATURE, + WaterHeaterEntity, + WaterHeaterEntityFeature +) + +from .miot.const import DOMAIN +from .miot.miot_device import MIoTDevice, MIoTEntityData, MIoTServiceEntity +from .miot.miot_spec import MIoTSpecProperty + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + device_list: list[MIoTDevice] = hass.data[DOMAIN]['devices'][ + config_entry.entry_id] + + new_entities = [] + for miot_device in device_list: + for data in miot_device.entity_list.get('water_heater', []): + new_entities.append(WaterHeater( + miot_device=miot_device, entity_data=data)) + + if new_entities: + async_add_entities(new_entities) + + +class WaterHeater(MIoTServiceEntity, WaterHeaterEntity): + """Water heater entities for Xiaomi Home.""" + _prop_on: Optional[MIoTSpecProperty] + _prop_temp: Optional[MIoTSpecProperty] + _prop_target_temp: Optional[MIoTSpecProperty] + _prop_mode: Optional[MIoTSpecProperty] + + _mode_list: Optional[dict[any, any]] + + def __init__( + self, miot_device: MIoTDevice, entity_data: MIoTEntityData + ) -> None: + """Initialize the Water heater.""" + super().__init__(miot_device=miot_device, entity_data=entity_data) + self._attr_temperature_unit = None + self._attr_supported_features = WaterHeaterEntityFeature(0) + self._prop_on = None + self._prop_temp = None + self._prop_target_temp = None + self._prop_mode = None + self._mode_list = None + + # properties + for prop in entity_data.props: + # on + if prop.name == 'on': + self._prop_on = prop + # temperature + if prop.name == 'temperature': + if isinstance(prop.value_range, dict): + self._attr_min_temp = prop.value_range['min'] + self._attr_max_temp = prop.value_range['max'] + if ( + self._attr_temperature_unit is None + and prop.external_unit + ): + self._attr_temperature_unit = prop.external_unit + self._prop_temp = prop + else: + _LOGGER.error( + 'invalid temperature value_range format, %s', + self.entity_id) + # target-temperature + if prop.name == 'target-temperature': + if self._attr_temperature_unit is None and prop.external_unit: + self._attr_temperature_unit = prop.external_unit + self._attr_supported_features |= ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE) + self._prop_target_temp = prop + # mode + if prop.name == 'mode': + if ( + not isinstance(prop.value_list, list) + or not prop.value_list + ): + _LOGGER.error( + 'mode value_list is None, %s', self.entity_id) + continue + self._mode_list = { + item['value']: item['description'] + for item in prop.value_list} + self._attr_operation_list = list(self._mode_list.values()) + self._attr_supported_features |= ( + WaterHeaterEntityFeature.OPERATION_MODE) + self._prop_mode = prop + + async def async_turn_on(self) -> None: + """Turn the water heater on.""" + await self.set_property_async(prop=self._prop_on, value=True) + + async def async_turn_off(self) -> None: + """Turn the water heater off.""" + await self.set_property_async(prop=self._prop_on, value=False) + + async def async_set_temperature(self, **kwargs: any) -> None: + """Set the temperature the water heater should heat water to.""" + await self.set_property_async( + prop=self._prop_target_temp, value=kwargs[ATTR_TEMPERATURE]) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set the operation mode of the water heater. + Must be in the operation_list. + """ + await self.set_property_async( + prop=self._prop_mode, + value=self.__get_mode_value(description=operation_mode)) + + async def async_turn_away_mode_on(self) -> None: + """Set the water heater to away mode.""" + await self.hass.async_add_executor_job(self.turn_away_mode_on) + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self.get_prop_value(prop=self._prop_temp) + + @property + def target_temperature(self) -> Optional[float]: + """Return the target temperature.""" + return self.get_prop_value(prop=self._prop_target_temp) + + @property + def current_operation(self) -> Optional[str]: + """Return the current mode.""" + return self.__get_mode_description( + key=self.get_prop_value(prop=self._prop_mode)) + + def __get_mode_description(self, key: int) -> Optional[str]: + """Convert mode value to description.""" + if self._mode_list is None: + return None + return self._mode_list.get(key, None) + + def __get_mode_value(self, description: str) -> Optional[int]: + """Convert mode description to value.""" + if self._mode_list is None: + return None + for key, value in self._mode_list.items(): + if value == description: + return key + return None diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md new file mode 100644 index 0000000..4fc27b4 --- /dev/null +++ b/doc/CHANGELOG.md @@ -0,0 +1,7 @@ +# CHANGELOG + +## 0.1.0 +### Added +- first version +### Changed +### Fixed diff --git a/doc/CONTRIBUTING.md b/doc/CONTRIBUTING.md new file mode 100644 index 0000000..ff13ba1 --- /dev/null +++ b/doc/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# Contribution Guidelines + +[English](./CONTRIBUTING.md) | [简体中文](./CONTRIBUTING_zh.md) + +Thank you for considering contributing to our project! We appreciate your efforts to make our project better. + +Before you start contributing, please take a moment to review the following guidelines. + +## How Can I Contribute? + +### Reporting Bugs + +If you encounter a bug in the project, please [open an issue](https://github.com/XiaoMi/ha_xiaomi_home/issues/new/) on GitHub and provide the detailed information about the bug, including the steps to reproduce the bug, the logs of debug level and the time when it occurs. + +The [method](https://www.home-assistant.io/integrations/logger/#log-filters) to set the integration's log level: + +``` +# Set the log level in configuration.yaml + +logger: + default: critical + logs: + custom_components.xiaomi_home: debug +``` + +### Suggesting Enhancements + +If you have ideas for enhancements or new features, you are welcomed to [start a discussion on ideas](https://github.com/XiaoMi/ha_xiaomi_home/discussions/new?category=ideas) on GitHub to discuss your ideas. + +### Contributing Code + +1. Fork the repository and create your branch from `main`. +2. Ensure that your code adheres to the project coding standard. +3. Make sure that your commit messages are descriptive and meaningful. +4. Pull requests should be accompanied by a clear description of the problem and the solution. +5. Update the documents if necessary. +6. Run tests if they are available and ensure they pass. + +## Pull Request Guidelines + +Before submitting a pull request, please make sure that the following requirements are met: + +- Your pull request addresses a single issue or feature. +- You have tested your changes locally. +- Your code follows the project's [code style](#code-style). Run [`pylint`](https://github.com/google/pyink) over your code using this [pylintrc](../.pylintrc). +- All existing tests pass, and you have added new tests if applicable. +- Any dependent changes are documented. + +## Code Style + +We follow [Google Style](https://google.github.io/styleguide/pyguide.html) for code style and formatting. Please make sure to adhere to this guideline in your contributions. + +## Commit Message Format + +``` +: + + + +