├── tests ├── __init__.py ├── conftest.py └── test_config_flow.py ├── custom_components ├── __init__.py └── smartthinq_sensors │ ├── wideq │ ├── devices │ │ ├── __init__.py │ │ ├── fan.py │ │ ├── styler.py │ │ ├── hood.py │ │ ├── dishwasher.py │ │ └── waterheater.py │ ├── backports │ │ ├── __init__.py │ │ ├── README │ │ ├── enum.py │ │ └── functools.py │ ├── __init__.py │ ├── local_lang_pack.json │ ├── core_util.py │ ├── factory.py │ ├── core_exceptions.py │ ├── const.py │ └── device_info.py │ ├── manifest.json │ ├── const.py │ ├── services.yaml │ ├── translations │ ├── es.json │ ├── nb.json │ ├── hr.json │ ├── sk.json │ ├── pl.json │ ├── da.json │ ├── de.json │ ├── en.json │ ├── pt-BR.json │ ├── pt.json │ ├── fr-BE.json │ ├── fr.json │ ├── fr-CA.json │ ├── it.json │ └── el.json │ ├── button.py │ ├── diagnostics.py │ ├── select.py │ ├── humidifier.py │ ├── light.py │ └── water_heater.py ├── .prettierignore ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── washerpics ├── washer.jpg └── washerrunning.gif ├── scripts ├── lint ├── setup └── develop ├── hacs.json ├── script └── integration_init ├── .coveragerc ├── .dockerignore ├── requirements.txt ├── requirements_test.txt ├── .gitattributes ├── config └── configuration.yaml ├── .github ├── workflows │ ├── hassfest.yaml │ ├── validate.yaml │ ├── linting.yaml │ ├── release.yml │ ├── tests.yaml │ └── stale.yaml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .gitignore ├── Dockerfile.dev ├── setup.cfg ├── .devcontainer └── devcontainer.json ├── .pylintrc ├── .ruff.toml ├── info.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """custom integation tests.""" 2 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | .strict-typing 3 | azure-*.yml 4 | docs/source/_templates/* 5 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/devices/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for LG ThinQ devices.""" 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] 3 | } 4 | -------------------------------------------------------------------------------- /washerpics/washer.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ollo69/ha-smartthinq-sensors/HEAD/washerpics/washer.jpg -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/backports/__init__.py: -------------------------------------------------------------------------------- 1 | """Backports from newer Python versions.""" 2 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | ruff check . --fix 8 | -------------------------------------------------------------------------------- /washerpics/washerrunning.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ollo69/ha-smartthinq-sensors/HEAD/washerpics/washerrunning.gif -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m pip install --requirement requirements.txt 8 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SmartThinQ LGE Sensors", 3 | "content_in_root": false, 4 | "zip_release": true, 5 | "filename": "smartthinq_sensors.zip", 6 | "homeassistant": "2025.1.0" 7 | } 8 | -------------------------------------------------------------------------------- /script/integration_init: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Create empty init in custom components directory 4 | echo "Init custom components directory" 5 | touch "${PWD}/custom_components/__init__.py" 6 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for LG ThinQ.""" 2 | 3 | # flake8: noqa 4 | from .const import * 5 | from .device_info import * 6 | from .factory import get_lge_device 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = custom_components 3 | omit = 4 | custom_components/__init__.py 5 | custom_components/smartthinq_sensors/diagnostics.py 6 | custom_components/smartthinq_sensors/wideq/* 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # General files 2 | .git 3 | .github 4 | config 5 | docs 6 | 7 | # Development 8 | .devcontainer 9 | .vscode 10 | 11 | # Test related files 12 | tests 13 | 14 | # Other virtualization methods 15 | venv 16 | .vagrant 17 | 18 | # Temporary files 19 | **/__pycache__ -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Home Assistant Core 2 | colorlog==6.8.2 3 | homeassistant==2025.1.0 4 | pip>=21.3.1 5 | ruff==0.0.261 6 | pre-commit==3.0.0 7 | flake8==6.1.0 8 | isort==5.12.0 9 | black==24.3.0 10 | xmltodict>=0.13.0 11 | charset_normalizer>=3.2.0 12 | pycountry>=23.12.11 13 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/backports/README: -------------------------------------------------------------------------------- 1 | This package contains backports of Python functionality from future Python 2 | versions. 3 | 4 | Some of the backports have been copied directly from the CPython project, 5 | and are subject to license agreement as detailed in LICENSE.Python. 6 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | # Strictly for tests 2 | pytest==8.3.4 3 | #pytest-cov==2.9.0 4 | #pytest-homeassistant 5 | pytest-homeassistant-custom-component==0.13.201 6 | # From our manifest.json for our custom component 7 | xmltodict>=0.13.0 8 | charset_normalizer>=3.2.0 9 | pycountry>=23.12.11 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Ensure Docker script files uses LF to support Docker for Windows. 2 | # Ensure "git config --global core.autocrlf input" before you clone 3 | * text eol=lf 4 | *.py whitespace=error 5 | 6 | *.ico binary 7 | *.gif binary 8 | *.jpg binary 9 | *.png binary 10 | *.zip binary 11 | *.mp3 binary 12 | 13 | Dockerfile.dev linguist-language=Dockerfile 14 | -------------------------------------------------------------------------------- /config/configuration.yaml: -------------------------------------------------------------------------------- 1 | # Loads default set of integrations. Do not remove. 2 | default_config: 3 | 4 | # Load frontend themes from the themes folder 5 | #frontend: 6 | # themes: !include_dir_merge_named themes 7 | 8 | # Text to speech 9 | tts: 10 | - platform: google_translate 11 | 12 | logger: 13 | default: info 14 | # logs: 15 | # custom_components.smartthinq_sensors: debug 16 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with Hassfest 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | branches: ["*"] 10 | 11 | schedule: 12 | - cron: "0 0 * * *" 13 | 14 | jobs: 15 | validate_hassfest: 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - uses: "actions/checkout@v4" 19 | - uses: home-assistant/actions/hassfest@master 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # artifacts 2 | __pycache__ 3 | .pytest* 4 | .cache 5 | *.egg-info 6 | */build/* 7 | */dist/* 8 | 9 | # pycharm 10 | .idea/ 11 | 12 | # Unit test / coverage reports 13 | .coverage 14 | coverage.xml 15 | 16 | 17 | # Home Assistant configuration 18 | config/* 19 | !config/configuration.yaml 20 | 21 | # WideQ test file 22 | custom_components/smartthinq_sensors/wideq/deviceV1.txt 23 | custom_components/smartthinq_sensors/wideq/deviceV2.txt 24 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with Hacs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | branches: ["*"] 10 | 11 | schedule: 12 | - cron: "0 0 * * *" 13 | 14 | jobs: 15 | validate_hacs: 16 | runs-on: "ubuntu-latest" 17 | steps: 18 | - uses: "actions/checkout@v4" 19 | - name: HACS validation 20 | uses: "hacs/action@main" 21 | with: 22 | category: "integration" 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | //"editor.formatOnSave": true 3 | "[python]": { 4 | "editor.defaultFormatter": "ms-python.black-formatter", 5 | "editor.formatOnSave": true 6 | }, 7 | // Added --no-cov to work around TypeError: message must be set 8 | // https://github.com/microsoft/vscode-python/issues/14067 9 | "python.testing.pytestArgs": ["--no-cov"], 10 | // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings 11 | "python.testing.pytestEnabled": false 12 | } 13 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "smartthinq_sensors", 3 | "name": "SmartThinQ LGE Sensors", 4 | "codeowners": ["@ollo69"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/ollo69/ha-smartthinq-sensors", 8 | "integration_type": "hub", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/ollo69/ha-smartthinq-sensors/issues", 11 | "requirements": [ 12 | "pycountry>=23.12.11", 13 | "xmltodict>=0.13.0", 14 | "charset_normalizer>=3.2.0" 15 | ], 16 | "version": "0.41.2" 17 | } 18 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | # Create config dir if not present 8 | if [[ ! -d "${PWD}/config" ]]; then 9 | mkdir -p "${PWD}/config" 10 | hass --config "${PWD}/config" --script ensure_config 11 | fi 12 | 13 | # Set the path to custom_components 14 | ## This let's us have the structure we want /custom_components/integration_blueprint 15 | ## while at the same time have Home Assistant configuration inside /config 16 | ## without resulting to symlinks. 17 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 18 | 19 | # Start Home Assistant 20 | #hass --config "${PWD}/config" --debug 21 | hass --config "${PWD}/config" 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: Feature Request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/linting.yaml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | branches: ["*"] 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.13 21 | 22 | - name: Install dependencies 23 | run: | 24 | pip install -r requirements.txt 25 | - name: flake8 26 | run: flake8 --extend-ignore=E704 . 27 | - name: isort 28 | run: isort --diff --check . 29 | - name: Black 30 | run: black --line-length 88 --diff --check . 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | release: 5 | types: 6 | - "published" 7 | 8 | permissions: {} 9 | 10 | jobs: 11 | release: 12 | name: "Release" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: "Checkout the repository" 18 | uses: "actions/checkout@v4.1.7" 19 | 20 | - name: "ZIP the integration directory" 21 | shell: "bash" 22 | run: | 23 | cd "${{ github.workspace }}/custom_components/smartthinq_sensors" 24 | zip smartthinq_sensors.zip -r ./ 25 | 26 | - name: "Upload the ZIP file to the release" 27 | uses: "softprops/action-gh-release@v2.0.8" 28 | with: 29 | files: ${{ github.workspace }}/custom_components/smartthinq_sensors/smartthinq_sensors.zip 30 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/local_lang_pack.json: -------------------------------------------------------------------------------- 1 | { 2 | "en-US": { 3 | "@WM_TERM_NO_SELECT_W": "Not Selected", 4 | "@WM_STATE_POWER_OFF_W": "Power OFF", 5 | "@WM_STATE_INITIAL_W": "Standby", 6 | "@WM_STATE_PAUSE_W": "Paused", 7 | "@WM_STATE_RESERVE_W": "Delay Set", 8 | "@CP_UX30_CARD_DELAY_SET": "Delay Set", 9 | "@WM_STATE_DETECTING_W": "Detecting", 10 | "@WM_STATE_RUNNING_W": "Washing", 11 | "@WM_STATE_RINSING_W": "Rinsing", 12 | "@WM_STATE_SPINNING_W": "Spinning", 13 | "@WM_STATE_DRYING_W": "Drying", 14 | "@WM_STATE_END_W": "Finished", 15 | "@WM_STATE_COOLDOWN_W": "Cool Down", 16 | "@WM_STATE_RINSEHOLD_W": "Rinse Hold", 17 | "@WM_STATE_REFRESHING_W": "Refreshing", 18 | "@WM_STATE_STEAMSOFTENING_W": "Steam Softening", 19 | "@WM_STATE_ERROR_W": "Error" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | If possible attach the JSON info file for your devices. 13 | 14 | **Expected behavior** 15 | If applicable, a clear and concise description of what you expected to happen. 16 | 17 | **Screenshots** 18 | If applicable, add screenshots to help explain your problem. 19 | 20 | **Environment details:** 21 | - Environment (HASSIO, Raspbian, etc): 22 | - Home Assistant version installed: 23 | - Component version installed: 24 | - Last know working version: 25 | - LG device type and model with issue: 26 | - LG devices connected (list): 27 | 28 | **Output of HA logs** 29 | Paste the relavant output of the HA log here. 30 | 31 | ``` 32 | 33 | ``` 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Python tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | branches: ["*"] 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.13"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install -r requirements_test.txt 32 | 33 | - name: Run pytest 34 | # run: | 35 | # pytest --cov-report xml:coverage.xml 36 | run: | 37 | pytest \ 38 | -qq \ 39 | --timeout=9 \ 40 | --durations=10 \ 41 | -n auto \ 42 | -o console_output_style=count \ 43 | -p no:sugar \ 44 | tests 45 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | // Example of attaching to local debug server 7 | "name": "Python: Attach Local", 8 | "type": "python", 9 | "request": "attach", 10 | "port": 5678, 11 | "host": "localhost", 12 | "pathMappings": [ 13 | { 14 | "localRoot": "${workspaceFolder}", 15 | "remoteRoot": "." 16 | } 17 | ], 18 | }, 19 | { 20 | // Example of attaching to my production server 21 | "name": "Python: Attach Remote", 22 | "type": "python", 23 | "request": "attach", 24 | "port": 5678, 25 | "host": "homeassistant.local", 26 | "pathMappings": [ 27 | { 28 | "localRoot": "${workspaceFolder}", 29 | "remoteRoot": "/usr/src/homeassistant" 30 | } 31 | ], 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/backports/enum.py: -------------------------------------------------------------------------------- 1 | """Enum backports from standard lib.""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | from typing import Any, TypeVar 7 | 8 | _StrEnumSelfT = TypeVar("_StrEnumSelfT", bound="StrEnum") 9 | 10 | 11 | class StrEnum(str, Enum): 12 | """Partial backport of Python 3.11's StrEnum for our basic use cases.""" 13 | 14 | def __new__( 15 | cls: type[_StrEnumSelfT], value: str, *args: Any, **kwargs: Any 16 | ) -> _StrEnumSelfT: 17 | """Create a new StrEnum instance.""" 18 | if not isinstance(value, str): 19 | raise TypeError(f"{value!r} is not a string") 20 | return super().__new__(cls, value, *args, **kwargs) 21 | 22 | def __str__(self) -> str: 23 | """Return self.value.""" 24 | return str(self.value) 25 | 26 | @staticmethod 27 | def _generate_next_value_( 28 | name: str, start: int, count: int, last_values: list[Any] 29 | ) -> Any: 30 | """ 31 | Make `auto()` explicitly unsupported. 32 | 33 | We may revisit this when it's very clear that Python 3.11's 34 | `StrEnum.auto()` behavior will no longer change. 35 | """ 36 | raise TypeError("auto() is not supported by this implementation") 37 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:1-3.13 2 | 3 | SHELL ["/bin/bash", "-o", "pipefail", "-c"] 4 | 5 | # Uninstall pre-installed formatting and linting tools 6 | # They would conflict with our pinned versions 7 | RUN \ 8 | pipx uninstall pydocstyle \ 9 | && pipx uninstall pycodestyle \ 10 | && pipx uninstall mypy \ 11 | && pipx uninstall pylint 12 | 13 | RUN \ 14 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ 15 | && apt-get update \ 16 | && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ 17 | # Additional library needed by some tests and accordingly by VScode Tests Discovery 18 | bluez \ 19 | ffmpeg \ 20 | libudev-dev \ 21 | libavformat-dev \ 22 | libavcodec-dev \ 23 | libavdevice-dev \ 24 | libavutil-dev \ 25 | libgammu-dev \ 26 | libswscale-dev \ 27 | libswresample-dev \ 28 | libavfilter-dev \ 29 | libpcap-dev \ 30 | libturbojpeg0 \ 31 | libyaml-dev \ 32 | libxml2 \ 33 | git \ 34 | cmake \ 35 | && apt-get clean \ 36 | && rm -rf /var/lib/apt/lists/* 37 | 38 | # Add go2rtc binary 39 | COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc 40 | 41 | # Install uv 42 | RUN pip3 install uv 43 | 44 | WORKDIR /workspaces 45 | 46 | # Set the default shell to bash instead of sh 47 | ENV SHELL /bin/bash 48 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | source = 3 | custom_components 4 | 5 | [coverage:report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplemented() 9 | if __name__ == '__main__': 10 | main() 11 | show_missing = true 12 | 13 | [tool:pytest] 14 | testpaths = tests 15 | norecursedirs = .git 16 | addopts = 17 | --strict-markers 18 | --cov=custom_components 19 | asyncio_mode = auto 20 | 21 | [isort] 22 | # https://github.com/timothycrosley/isort 23 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 24 | # splits long import on multiple lines indented by 4 spaces 25 | profile = black 26 | line_length = 88 27 | # will group `import x` and `from x import` of the same module. 28 | force_sort_within_sections = true 29 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 30 | default_section = THIRDPARTY 31 | known_first_party = homeassistant 32 | known_local_folder = custom_components, tests 33 | forced_separate = tests 34 | combine_as_imports = true 35 | 36 | [flake8] 37 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 38 | max-complexity = 25 39 | doctests = True 40 | # To work with Black 41 | # E501: line too long 42 | # W503: Line break occurred before a binary operator 43 | # E203: Whitespace before ':' 44 | # D202 No blank lines allowed after function docstring 45 | # W504 line break after binary operator 46 | ignore = 47 | E501, 48 | W503, 49 | E203, 50 | D202, 51 | W504 52 | noqa-require-code = True 53 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SmartThinQ Sensors Component", 3 | "dockerFile": "../Dockerfile.dev", 4 | "postCreateCommand": "scripts/setup", 5 | "forwardPorts": [8123], 6 | "portsAttributes": { 7 | "8123": { 8 | "label": "Home Assistant", 9 | "onAutoForward": "notify" 10 | } 11 | }, 12 | "customizations": { 13 | "vscode": { 14 | "extensions": [ 15 | "ms-python.black-formatter", 16 | "ms-python.pylint", 17 | "ms-python.vscode-pylance", 18 | "visualstudioexptteam.vscodeintellicode", 19 | "redhat.vscode-yaml", 20 | "esbenp.prettier-vscode", 21 | "GitHub.vscode-pull-request-github", 22 | "ryanluker.vscode-coverage-gutters" 23 | ], 24 | "settings": { 25 | "files.eol": "\n", 26 | "editor.tabSize": 4, 27 | "python.pythonPath": "/usr/local/bin/python", 28 | "python.testing.pytestArgs": ["--no-cov"], 29 | "python.analysis.autoSearchPaths": false, 30 | "editor.formatOnPaste": false, 31 | "editor.formatOnSave": true, 32 | "editor.formatOnType": true, 33 | "files.trimTrailingWhitespace": true, 34 | "terminal.integrated.profiles.linux": { 35 | "zsh": { 36 | "path": "/usr/bin/zsh" 37 | } 38 | }, 39 | "terminal.integrated.defaultProfile.linux": "zsh", 40 | "[python]": { 41 | "editor.defaultFormatter": "ms-python.black-formatter" 42 | } 43 | } 44 | } 45 | }, 46 | "remoteUser": "vscode" 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: "Close stale issues and PRs" 7 | 8 | on: 9 | schedule: 10 | - cron: "0 1 * * *" 11 | workflow_dispatch: 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | stale: 18 | permissions: 19 | issues: write # for actions/stale to close stale issues 20 | pull-requests: write # for actions/stale to close stale PRs 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/stale@v7 24 | with: 25 | stale-issue-message: "This issue is stale because it has been open 45 days with no activity. Remove stale label or comment or this will be closed in 7 days." 26 | stale-pr-message: "This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 10 days." 27 | close-issue-message: "This issue was closed because it has been stalled for 7 days with no activity." 28 | close-pr-message: "This PR was closed because it has been stalled for 10 days with no activity." 29 | exempt-issue-labels: "Feature Request,documentation,enhancement" 30 | days-before-issue-stale: 45 31 | days-before-pr-stale: -1 32 | days-before-issue-close: 7 33 | days-before-pr-close: -1 34 | ascending: true 35 | operations-per-run: 400 36 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 8123", 6 | "type": "shell", 7 | "command": "scripts/develop", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant on port 8123 (with emulation)", 12 | "type": "shell", 13 | "command": "scripts/develop", 14 | "options": {"env": {"thinq2_emulation": "ENABLED"}}, 15 | "problemMatcher": [] 16 | }, 17 | { 18 | "label": "Install Requirements", 19 | "type": "shell", 20 | "command": "pip3 install --use-deprecated=legacy-resolver -r requirements.txt", 21 | "group": { 22 | "kind": "build", 23 | "isDefault": true 24 | }, 25 | "presentation": { 26 | "reveal": "always", 27 | "panel": "new" 28 | }, 29 | "problemMatcher": [] 30 | }, 31 | { 32 | "label": "Install Test Requirements", 33 | "type": "shell", 34 | "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test.txt", 35 | "group": { 36 | "kind": "build", 37 | "isDefault": true 38 | }, 39 | "presentation": { 40 | "reveal": "always", 41 | "panel": "new" 42 | }, 43 | "problemMatcher": [] 44 | }, 45 | { 46 | "label": "Run PyTest", 47 | "detail": "Run pytest for integration.", 48 | "type": "shell", 49 | "command": "pytest --cov-report term-missing -vv --durations=10", 50 | "group": { 51 | "kind": "test", 52 | "isDefault": true 53 | }, 54 | "presentation": { 55 | "reveal": "always", 56 | "panel": "new" 57 | }, 58 | "problemMatcher": [] 59 | } 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | # PyLint message control settings 3 | # Reasons disabled: 4 | # format - handled by black 5 | # locally-disabled - it spams too much 6 | # duplicate-code - unavoidable 7 | # cyclic-import - doesn't test if both import on load 8 | # abstract-class-little-used - prevents from setting right foundation 9 | # unused-argument - generic callbacks and setup methods create a lot of warnings 10 | # too-many-* - are not enforced for the sake of readability 11 | # too-few-* - same as too-many-* 12 | # abstract-method - with intro of async there are always methods missing 13 | # inconsistent-return-statements - doesn't handle raise 14 | # too-many-ancestors - it's too strict. 15 | # wrong-import-order - isort guards this 16 | # consider-using-f-string - str.format sometimes more readable 17 | # --- 18 | # Enable once current issues are fixed: 19 | # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) 20 | # consider-using-assignment-expr (Pylint CodeStyle extension) 21 | disable = 22 | format, 23 | abstract-method, 24 | cyclic-import, 25 | duplicate-code, 26 | inconsistent-return-statements, 27 | locally-disabled, 28 | not-context-manager, 29 | too-few-public-methods, 30 | too-many-ancestors, 31 | too-many-arguments, 32 | too-many-branches, 33 | too-many-instance-attributes, 34 | too-many-lines, 35 | too-many-locals, 36 | too-many-public-methods, 37 | too-many-return-statements, 38 | too-many-statements, 39 | too-many-boolean-expressions, 40 | unused-argument, 41 | wrong-import-order, 42 | consider-using-f-string, 43 | unexpected-keyword-arg 44 | # consider-using-namedtuple-or-dataclass, 45 | # consider-using-assignment-expr 46 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/const.py: -------------------------------------------------------------------------------- 1 | """Constants for LGE ThinQ custom component.""" 2 | 3 | __version__ = "0.41.2" 4 | PROJECT_URL = "https://github.com/ollo69/ha-smartthinq-sensors/" 5 | ISSUE_URL = f"{PROJECT_URL}issues" 6 | 7 | DOMAIN = "smartthinq_sensors" 8 | 9 | MIN_HA_MAJ_VER = 2025 10 | MIN_HA_MIN_VER = 1 11 | __min_ha_version__ = f"{MIN_HA_MAJ_VER}.{MIN_HA_MIN_VER}.0" 12 | 13 | # general sensor attributes 14 | ATTR_CURRENT_COURSE = "current_course" 15 | ATTR_ERROR_STATE = "error_state" 16 | ATTR_INITIAL_TIME = "initial_time" 17 | ATTR_REMAIN_TIME = "remain_time" 18 | ATTR_RESERVE_TIME = "reserve_time" 19 | ATTR_START_TIME = "start_time" 20 | ATTR_END_TIME = "end_time" 21 | ATTR_RUN_COMPLETED = "run_completed" 22 | 23 | # refrigerator sensor attributes 24 | ATTR_DOOR_OPEN = "door_open" 25 | ATTR_FRIDGE_TEMP = "fridge_temp" 26 | ATTR_FREEZER_TEMP = "freezer_temp" 27 | ATTR_TEMP_UNIT = "temp_unit" 28 | 29 | # range sensor attributes 30 | ATTR_OVEN_LOWER_TARGET_TEMP = "oven_lower_target_temp" 31 | ATTR_OVEN_UPPER_TARGET_TEMP = "oven_upper_target_temp" 32 | ATTR_OVEN_TEMP_UNIT = "oven_temp_unit" 33 | 34 | # configuration 35 | CONF_LANGUAGE = "language" 36 | CONF_OAUTH2_URL = "oauth2_url" 37 | CONF_USE_API_V2 = "use_api_v2" 38 | CONF_USE_HA_SESSION = "use_ha_session" 39 | CONF_USE_REDIRECT = "use_redirect" 40 | 41 | CLIENT = "client" 42 | LGE_DEVICES = "lge_devices" 43 | 44 | LGE_DISCOVERY_NEW = f"{DOMAIN}_discovery_new" 45 | 46 | DEFAULT_ICON = "def_icon" 47 | DEFAULT_SENSOR = "default" 48 | 49 | STARTUP = f""" 50 | ------------------------------------------------------------------- 51 | {DOMAIN} 52 | Version: {__version__} 53 | This is a custom component 54 | If you have any issues with this you need to open an issue here: 55 | {ISSUE_URL} 56 | ------------------------------------------------------------------- 57 | """ 58 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/services.yaml: -------------------------------------------------------------------------------- 1 | remote_start: 2 | name: Remote Start 3 | description: Send to ThinQ device the remote start command. 4 | target: 5 | entity: 6 | integration: smartthinq_sensors 7 | domain: sensor 8 | fields: 9 | course: 10 | name: course 11 | description: Course (if not set will use current) 12 | required: false 13 | selector: 14 | text: 15 | 16 | wake_up: 17 | name: WakeUp 18 | description: Send to ThinQ device the wakeup command. 19 | target: 20 | entity: 21 | integration: smartthinq_sensors 22 | domain: sensor 23 | 24 | set_time: 25 | name: Set time 26 | description: Set time device. 27 | target: 28 | entity: 29 | integration: smartthinq_sensors 30 | domain: sensor 31 | fields: 32 | time_wanted: 33 | name: time 34 | description: Time (if not set will use Home-Assistant time) 35 | required: false 36 | selector: 37 | time: 38 | 39 | set_fan_mode: 40 | name: Set fan mode 41 | description: Set fan operation for dehumidifier device. 42 | target: 43 | entity: 44 | integration: smartthinq_sensors 45 | domain: humidifier 46 | fields: 47 | fan_mode: 48 | name: Fan mode 49 | description: New value of fan mode. 50 | required: true 51 | example: "low" 52 | selector: 53 | text: 54 | 55 | set_sleep_time: 56 | name: Set sleep time 57 | description: Set sleep time. 58 | target: 59 | entity: 60 | integration: smartthinq_sensors 61 | domain: climate 62 | fields: 63 | sleep_time: 64 | name: "Timeout" 65 | description: Timeout for sleep mode in minutes 66 | default: 60 67 | required: true 68 | selector: 69 | number: 70 | min: 0 71 | max: 720 72 | mode: box 73 | unit_of_measurement: minutes 74 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Solo se permite una única configuración de SmartThinQ LGE Sensors.", 5 | "no_smartthinq_devices": "No se encontraron dispositivos SmartThinQ. Configuración del componente interrumpida." 6 | }, 7 | "error": { 8 | "invalid_region": "Formato de país no válido.", 9 | "invalid_language": "Formato de idioma no válido.", 10 | "invalid_url": "URL de redireccionamiento no válida" 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "region": "Codigo País", 16 | "language": "Codigo Idioma" 17 | }, 18 | "description": "Inserta tu información de acceso a SmartThinQ.", 19 | "title": "Sensores SmartThinQ LGE" 20 | }, 21 | "url": { 22 | "data": { 23 | "login_url": "URL de acceso a SmartThinQ", 24 | "callback_url": "URL de redireccionamiento" 25 | }, 26 | "description": "Usa la URL en el primer campo para iniciar sesión con tus credenciales de SmartThinQ, luego pega en el segundo campo la URL donde se redirige el navegador después del inicio de sesión.", 27 | "title": "Sensores SmartThinQ LGE - Autenticación" 28 | }, 29 | "token": { 30 | "data": { 31 | "token": "Actualización de Token" 32 | }, 33 | "description": "Guarda el Token generado para futuros usos, luego confirma para completar la configuración.", 34 | "title": "Sensores SmartThinQ LGE - Nueva Actualización de Token" 35 | } 36 | }, 37 | "title": "Sensores SmartThinQ LGE" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/nb.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Only a single configuration of SmartThinQ LGE Sensors is allowed.", 5 | "no_smartthinq_devices": "No SmartThinQ devices found. Component setup aborted." 6 | }, 7 | "error": { 8 | "invalid_region": "Ugyldig regionformat.", 9 | "invalid_language": "Ugyldig språkformat.", 10 | "invalid_url": "Ugyldig URL for omdirigering." 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "region": "Landskode", 16 | "language": "Språkkode" 17 | }, 18 | "description": "Velg informasjon om lokalisering av SmartThinQ-kontoen.", 19 | "title": "SmartThinQ LGE-sensorer" 20 | }, 21 | "url": { 22 | "data": { 23 | "login_url": "SmartThinQ påloggings-URL", 24 | "callback_url": "Omadresserings-URL" 25 | }, 26 | "description": "Bruk URL-en i det første feltet for å utføre pålogging til SmartThinQ med legitimasjonen din, og lim deretter inn URL-en der nettleseren blir omdirigert etter påloggingen i det andre feltet. Del tilbakemeldinger om oppsettet ditt [her] (https://git.io/JU166).", 27 | "title": "SmartThinQ LGE-sensorer - autentisering" 28 | }, 29 | "token": { 30 | "data": { 31 | "token": "Oppdater Token" 32 | }, 33 | "description": "Lagre det genererte tokenet for fremtidig bruk, og bekreft deretter for å fullføre konfigurasjonen.", 34 | "title": "SmartThinQ LGE-sensorer - Nytt oppdateringstoken" 35 | } 36 | }, 37 | "title": "SmartThinQ LGE-sensorer" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/hr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Samo jedna konfiguracija SmartThinQ LGE Senzora je dozvoljena.", 5 | "no_smartthinq_devices": "Nije pronađen niti jedan SmartThinQ uređaj. Podešavanje komponente prekinuto." 6 | }, 7 | "error": { 8 | "invalid_region": "Neispravan format regije.", 9 | "invalid_language": "Neispravan format jezika.", 10 | "invalid_url": "Neispravan URL za preusmjeravanje." 11 | }, 12 | "step": { 13 | "user": { 14 | "data": { 15 | "region": "Kod za državu", 16 | "language": "Kod za jezik" 17 | }, 18 | "description": "Odaberite podatke o lokalizaciji računa SmartThinQ.", 19 | "title": "SmartThinQ LGE Senzori" 20 | }, 21 | "url": { 22 | "data": { 23 | "login_url": "SmartThinQ URL za prijavu", 24 | "callback_url": "URL za preusmjeravanje" 25 | }, 26 | "description": "Upotrijebite URL u prvom polju za prijavu na SmartThinQ sa svojim vjerodajnicama, a u drugo polje zalijepite URL na koji je preglednik preusmjeren poslije prijave. Podijelite povratne informacije o svojoj implementaciji [ovdje](https://git.io/JU166).", 27 | "title": "SmartThinQ LGE Senzori - autentikacija" 28 | }, 29 | "token": { 30 | "data": { 31 | "token": "Osvježite token" 32 | }, 33 | "description": "Sačuvajte token za buduću upotrebu, a zatim potvrdite da biste dovršili konfiguraciju.", 34 | "title": "SmartThinQ LGE Senzori - Novi token" 35 | } 36 | }, 37 | "title": "SmartThinQ LGE Senzoris" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for integration_blueprint integration.""" 2 | 3 | # Fixtures allow you to replace functions with a Mock object. You can perform 4 | # many options via the Mock to reflect a particular behavior from the original 5 | # function that you want to see without going through the function's actual logic. 6 | # Fixtures can either be passed into tests as parameters, or if autouse=True, they 7 | # will automatically be used across all tests. 8 | # 9 | # Fixtures that are defined in conftest.py are available across all tests. You can also 10 | # define fixtures within a particular test file to scope them locally. 11 | # 12 | # pytest_homeassistant_custom_component provides some fixtures that are provided by 13 | # Home Assistant core. You can find those fixture definitions here: 14 | # https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py 15 | # 16 | # See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that 17 | # pytest includes fixtures OOB which you can use as defined on this page) 18 | from unittest.mock import patch 19 | 20 | import pytest 21 | 22 | pytest_plugins = "pytest_homeassistant_custom_component" 23 | 24 | 25 | # This fixture enables loading custom integrations in all tests. 26 | # Remove to enable selective use of this fixture 27 | @pytest.fixture(autouse=True) 28 | def auto_enable_custom_integrations(enable_custom_integrations): 29 | yield 30 | 31 | 32 | # This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent 33 | # notifications. These calls would fail without this fixture since the persistent_notification 34 | # integration is never loaded during a test. 35 | @pytest.fixture(name="skip_notifications", autouse=True) 36 | def skip_notifications_fixture(): 37 | """Skip notification calls.""" 38 | with patch("homeassistant.components.persistent_notification.async_create"), patch( 39 | "homeassistant.components.persistent_notification.async_dismiss" 40 | ): 41 | yield 42 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | # The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml 2 | 3 | target-version = "py310" 4 | 5 | select = [ 6 | "B007", # Loop control variable {name} not used within loop body 7 | "B014", # Exception handler with duplicate exception 8 | "C", # complexity 9 | "D", # docstrings 10 | "E", # pycodestyle 11 | "F", # pyflakes/autoflake 12 | "ICN001", # import concentions; {name} should be imported as {asname} 13 | "PGH004", # Use specific rule codes when using noqa 14 | "PLC0414", # Useless import alias. Import alias does not rename original package. 15 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 16 | "SIM117", # Merge with-statements that use the same scope 17 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 18 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 19 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 20 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 21 | "SIM401", # Use get from dict with default instead of an if block 22 | "T20", # flake8-print 23 | "TRY004", # Prefer TypeError exception for invalid type 24 | "RUF006", # Store a reference to the return value of asyncio.create_task 25 | "UP", # pyupgrade 26 | "W", # pycodestyle 27 | ] 28 | 29 | ignore = [ 30 | "D202", # No blank lines allowed after function docstring 31 | "D203", # 1 blank line required before class docstring 32 | "D213", # Multi-line docstring summary should start at the second line 33 | "D404", # First word of the docstring should not be This 34 | "D406", # Section name should end with a newline 35 | "D407", # Section name underlining 36 | "D411", # Missing blank line before section 37 | "E501", # line too long 38 | "E731", # do not assign a lambda expression, use a def 39 | ] 40 | 41 | [flake8-pytest-style] 42 | fixture-parentheses = false 43 | 44 | [pyupgrade] 45 | keep-runtime-typing = true 46 | 47 | [mccabe] 48 | max-complexity = 25 -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/core_util.py: -------------------------------------------------------------------------------- 1 | """Support for LG SmartThinQ device.""" 2 | 3 | import uuid 4 | 5 | 6 | def as_list(obj) -> list: 7 | """ 8 | Wrap non-lists in lists. 9 | 10 | If `obj` is a list, return it unchanged. 11 | Otherwise, return a single-element list containing it. 12 | """ 13 | 14 | if isinstance(obj, list): 15 | return obj 16 | return [obj] 17 | 18 | 19 | def add_end_slash(url: str) -> str: 20 | """Add final slash to url.""" 21 | if not url.endswith("/"): 22 | return url + "/" 23 | return url 24 | 25 | 26 | def gen_uuid() -> str: 27 | """Return a str uuid in uuid4 format""" 28 | return str(uuid.uuid4()) 29 | 30 | 31 | class TempUnitConversion: 32 | """Class to convert temperature unit with LG device conversion rules.""" 33 | 34 | def __init__(self): 35 | """Initialize object.""" 36 | self._f2c_map = None 37 | self._c2f_map = None 38 | 39 | def f2c(self, value, model_info): 40 | """Convert Fahrenheit to Celsius temperatures based on model info.""" 41 | 42 | # Unbelievably, SmartThinQ devices have their own lookup tables 43 | # for mapping the two temperature scales. You can get *close* by 44 | # using a real conversion between the two temperature scales, but 45 | # precise control requires using the custom LUT. 46 | 47 | if self._f2c_map is None: 48 | mapping = model_info.value("TempFahToCel").options 49 | self._f2c_map = {int(f): c for f, c in mapping.items()} 50 | return self._f2c_map.get(value, value) 51 | 52 | def c2f(self, value, model_info): 53 | """Convert Celsius to Fahrenheit temperatures based on model info.""" 54 | 55 | # Just as unbelievably, this is not exactly the inverse of the 56 | # `f2c` map. There are a few values in this reverse mapping that 57 | # are not in the other. 58 | 59 | if self._c2f_map is None: 60 | mapping = model_info.value("TempCelToFah").options 61 | out = {} 62 | for cel, fah in mapping.items(): 63 | try: 64 | c_num = int(cel) 65 | except ValueError: 66 | c_num = float(cel) 67 | out[c_num] = fah 68 | self._c2f_map = out 69 | return self._c2f_map.get(value, value) 70 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/backports/functools.py: -------------------------------------------------------------------------------- 1 | """Functools backports from standard lib.""" 2 | 3 | # This file contains parts of Python's module wrapper 4 | # for the _functools C module 5 | # to allow utilities written in Python to be added 6 | # to the functools module. 7 | # Written by Nick Coghlan , 8 | # Raymond Hettinger , 9 | # and Łukasz Langa . 10 | # Copyright © 2001-2023 Python Software Foundation; All Rights Reserved 11 | 12 | from __future__ import annotations 13 | 14 | from collections.abc import Callable 15 | from types import GenericAlias 16 | from typing import Any, Generic, Self, TypeVar, overload 17 | 18 | _T = TypeVar("_T") 19 | 20 | 21 | class cached_property(Generic[_T]): 22 | """Backport of Python 3.12's cached_property. 23 | 24 | Includes https://github.com/python/cpython/pull/101890/files 25 | """ 26 | 27 | def __init__(self, func: Callable[[Any], _T]) -> None: 28 | """Initialize.""" 29 | self.func: Callable[[Any], _T] = func 30 | self.attrname: str | None = None 31 | self.__doc__ = func.__doc__ 32 | 33 | def __set_name__(self, owner: type[Any], name: str) -> None: 34 | """Set name.""" 35 | if self.attrname is None: 36 | self.attrname = name 37 | elif name != self.attrname: 38 | raise TypeError( 39 | "Cannot assign the same cached_property to two different names " 40 | f"({self.attrname!r} and {name!r})." 41 | ) 42 | 43 | @overload 44 | def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... 45 | 46 | @overload 47 | def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T: ... 48 | 49 | def __get__( 50 | self, instance: Any | None, owner: type[Any] | None = None 51 | ) -> _T | Self: 52 | """Get.""" 53 | if instance is None: 54 | return self 55 | if self.attrname is None: 56 | raise TypeError( 57 | "Cannot use cached_property instance without calling __set_name__ on it." 58 | ) 59 | try: 60 | cache = instance.__dict__ 61 | # not all objects have __dict__ (e.g. class defines slots) 62 | except AttributeError: 63 | msg = ( 64 | f"No '__dict__' attribute on {type(instance).__name__!r} " 65 | f"instance to cache {self.attrname!r} property." 66 | ) 67 | raise TypeError(msg) from None 68 | val = self.func(instance) 69 | try: 70 | cache[self.attrname] = val 71 | except TypeError: 72 | msg = ( 73 | f"The '__dict__' attribute on {type(instance).__name__!r} instance " 74 | f"does not support item assignment for caching {self.attrname!r} property." 75 | ) 76 | raise TypeError(msg) from None 77 | return val 78 | 79 | __class_getitem__ = classmethod(GenericAlias) # type: ignore[var-annotated] 80 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Len jedna konfigurácia SmartThinQ LGE Sensors je povolená.", 5 | "no_smartthinq_devices": "Nebolo nájdené SmartThinQ zariadenie. Nastavenie komponentu bolo prerušené.", 6 | "unsupported_version": "Táto integrácia vyžaduje aspoň verziu HomeAssistant {req_ver}, používate verziu {run_ver}.", 7 | "reconfigured": "Konfigurácia bola úspešne dokončená." 8 | }, 9 | "error": { 10 | "error_connect": "Chyba pri pripájaní k SmartThinQ. Skúste to znova a uistite sa, že máte v telefóne prístup k aplikácii ThinQ.", 11 | "error_url": "Chyba pri získavaní prihlasovacej adresy URL z ThinQ.", 12 | "invalid_region": "Nesprávny formát regiónu.", 13 | "invalid_language": "Nesprávny formát jazyka.", 14 | "invalid_url": "Nesprávna spätná URL.", 15 | "invalid_credentials": "Neplatné poverenia SmartThinQ. Pomocou aplikácie LG na svojom mobilnom zariadení overte, či existujú zmluvné podmienky, ktoré je potrebné prijať. Účty založené na sociálnej sieti nie sú podporované a vo väčšine prípadov s touto integráciou nefungujú.", 16 | "invalid_config": "Našla sa neplatná konfigurácia, prekonfigurujte ju.", 17 | "no_user_info": "Vyžaduje sa používateľské meno a heslo.", 18 | "unknown": "Neznáma chyba." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Používateľské meno účtu LG", 24 | "password": "Heslo", 25 | "region": "Región", 26 | "language": "Jazyk", 27 | "use_redirect": "Použite metódu overenia presmerovania adresy URL", 28 | "use_ha_session": "Share Home Assistant HTTP pripojenie (môže spôsobiť chyby SSL)", 29 | "use_tls_v1": "Vynútiť použitie protokolu TLSv1 (niekedy sa vyžaduje)", 30 | "exclude_dh": "Nepoužívajte šifrovanie DH (v prípade chyby „kľúč dh je príliš malý“)" 31 | }, 32 | "description": "Vyplňte informácie o prístupe k SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "SmartThinQ prihlasovacie URL", 37 | "callback_url": "Spätná URL" 38 | }, 39 | "description": "Pomocou URL v prvom poli vykonajte prihlásenie do SmartThinQ a potom vložte URL, ktorú vygeneruje prehliadač po prihlásení, do druhého poľa.", 40 | "title": "SmartThinQ LGE Sensors - Autentifikácia" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Obnovte Token" 45 | }, 46 | "description": "Uložte vygenerovaný token pre budúce použitie a potom potvrďte dokončenie konfigurácie.", 47 | "title": "SmartThinQ LGE Sensors - Nový obnovený token" 48 | } 49 | }, 50 | "title": "SmartThinQ LGE Sensors" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/factory.py: -------------------------------------------------------------------------------- 1 | """Factory module for ThinQ library.""" 2 | 3 | from __future__ import annotations 4 | 5 | from .const import TemperatureUnit 6 | from .core_async import ClientAsync 7 | from .device import Device 8 | from .device_info import ( 9 | WM_COMPLEX_DEVICES, 10 | WM_DEVICE_TYPES, 11 | DeviceInfo, 12 | DeviceType, 13 | NetworkType, 14 | PlatformType, 15 | ) 16 | from .devices.ac import AirConditionerDevice 17 | from .devices.airpurifier import AirPurifierDevice 18 | from .devices.dehumidifier import DeHumidifierDevice 19 | from .devices.dishwasher import DishWasherDevice 20 | from .devices.fan import FanDevice 21 | from .devices.hood import HoodDevice 22 | from .devices.microwave import MicroWaveDevice 23 | from .devices.range import RangeDevice 24 | from .devices.refrigerator import RefrigeratorDevice 25 | from .devices.styler import StylerDevice 26 | from .devices.washerDryer import WMDevice 27 | from .devices.waterheater import WaterHeaterDevice 28 | 29 | 30 | def _get_sub_devices(device_type: DeviceType) -> list[str | None]: 31 | """Return a list of complex devices""" 32 | if sub_devices := WM_COMPLEX_DEVICES.get(device_type): 33 | return sub_devices 34 | return [None] 35 | 36 | 37 | def get_lge_device( 38 | client: ClientAsync, device_info: DeviceInfo, temp_unit=TemperatureUnit.CELSIUS 39 | ) -> list[Device] | None: 40 | """Return a list of device objects based on the device type.""" 41 | 42 | device_type = device_info.type 43 | platform_type = device_info.platform_type 44 | network_type = device_info.network_type 45 | 46 | if platform_type == PlatformType.UNKNOWN: 47 | return None 48 | if network_type != NetworkType.WIFI: 49 | return None 50 | 51 | if device_type == DeviceType.AC: 52 | return [AirConditionerDevice(client, device_info, temp_unit)] 53 | if device_type == DeviceType.AIR_PURIFIER: 54 | return [AirPurifierDevice(client, device_info)] 55 | if device_type == DeviceType.DEHUMIDIFIER: 56 | return [DeHumidifierDevice(client, device_info)] 57 | if device_type == DeviceType.DISHWASHER: 58 | return [DishWasherDevice(client, device_info)] 59 | if device_type == DeviceType.FAN: 60 | return [FanDevice(client, device_info)] 61 | if device_type == DeviceType.HOOD: 62 | return [HoodDevice(client, device_info)] 63 | if device_type == DeviceType.MICROWAVE: 64 | return [MicroWaveDevice(client, device_info)] 65 | if device_type == DeviceType.RANGE: 66 | return [RangeDevice(client, device_info)] 67 | if device_type == DeviceType.REFRIGERATOR: 68 | return [RefrigeratorDevice(client, device_info)] 69 | if device_type == DeviceType.STYLER: 70 | return [StylerDevice(client, device_info)] 71 | if device_type == DeviceType.WATER_HEATER: 72 | return [WaterHeaterDevice(client, device_info, temp_unit)] 73 | if device_type in WM_DEVICE_TYPES: 74 | return [ 75 | WMDevice(client, device_info, sub_device=sub_device) 76 | for sub_device in _get_sub_devices(device_type) 77 | ] 78 | return None 79 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja SmartThinQ LGE Sensors.", 5 | "no_smartthinq_devices": "Nie znaleziono urządzeń SmartThinQ. Konfiguracja komponentów przerwana.", 6 | "unsupported_version": "Ta integracja wymaga HomeAssistanta w wersji co najmniej {req_ver}, Ty korzystasz z wersji {run_ver}.", 7 | "reconfigured": "Konfiguracja zakończona sukcesem." 8 | }, 9 | "error": { 10 | "error_connect": "Błąd łącznia ze SmartThinQ. Spróbuj ponownie i upewnij się, że możesz dostac się do ThinQ na swoim telefonie.", 11 | "error_url": "Błąd pobierania URL logowania z ThinQ.", 12 | "invalid_region": "Nieprawidłowy format regionu.", 13 | "invalid_language": "Niepoprawny format języka.", 14 | "invalid_url": "Nieprawidłowy adres URL przekierowania.", 15 | "invalid_credentials": "Błędne dane logowania do SmartThinQ. Użyj aplikacji LG na swoim telefonie, aby zweryfikować, czy nie ma nowych Warunków Usług do zaakceptowania. Konta bazujące na sieciach społecznościowych nie są wspierane i w większości przypadków nie działają z tą integracją.", 16 | "invalid_config": "Znalaziono błędną konfigurację, zrekonfiguruj integrację.", 17 | "no_user_info": "Nazwa użytkownika i hasło są wymagane.", 18 | "unknown": "Nieznany błąd." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Nazwa użytkownika konta LG", 24 | "password": "Hasło", 25 | "region": "Region", 26 | "language": "Język", 27 | "use_redirect": "Użyj metody bezpośredniego uwierzytelniania", 28 | "use_tls_v1": "Wymuś użycie protokołu TLSv1 (czasami wymagane)", 29 | "exclude_dh": "Nie używaj szyfrowania DH (w przypadku błędu 'dh key too small')" 30 | }, 31 | "description": "Podaj swoje ustawienia regionalne dla SmartThinQ." 32 | }, 33 | "url": { 34 | "data": { 35 | "login_url": "URL logowania SmartThinQ", 36 | "callback_url": "URL przekierowania" 37 | }, 38 | "description": "Skopiuj i otwórz adres URL z pierwszego pola, aby zalogować się do SmartThinQ, a następnie po zalogowaniu się na konto SmartThinQ w drugie pole wklej adres URL, na który przekierowała cię przeglądarka po zalogowaniu.", 39 | "title": "Sensory SmartThinQ LGE - Autoryzacja" 40 | }, 41 | "token": { 42 | "data": { 43 | "token": "Odśwież Token" 44 | }, 45 | "description": "Zapisz wygenerowany token, może się przydać w przyszłości, a następnie potwierdź, aby zakończyć konfigurację.", 46 | "title": "SmartThinQ LGE Sensors - token został zapisany" 47 | } 48 | }, 49 | "title": "Sensory SmartThinQ LGE" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # LG ThinQ Devices integration for HomeAssistant 2 | 3 | A HomeAssistant custom integration to monitor and control LG devices using ThinQ API based on [WideQ project][wideq]. 4 | 5 | Supported devices are: 6 | 7 | - Air Conditioner 8 | - Air Purifier 9 | - Dehumidifier 10 | - Dishwasher 11 | - Dryer 12 | - Fan 13 | - Hood 14 | - Microwave 15 | - Range 16 | - Refrigerator 17 | - Styler 18 | - Tower Washer-Dryer 19 | - Washer 20 | - Water Heater 21 | 22 | **Important**: The component will **not work if you have logged into the ThinQ application and registered your devices using a social network account** (Google, Facebook or Amazon). In order to use the component you need to create a new independent LG account and make sure you log into the ThinQ app and associate your devices with it. 23 | If during configuration you receive the message "No SmartThinQ devices found", probably your devices are still associated with the social network account. To solve the problem perform the following step: 24 | 25 | - remove your devices from the ThinQ app 26 | - logout from the app and login again with the independent LG account 27 | - reconnect the devices in the app 28 | 29 | **Important 2**: If you receive an "Invalid Credential" error during component configuration/startup, check in the LG mobile app if is requested to accept new Term Of Service. 30 | 31 | **Note**: some device status may not be correctly detected, this depends on the model. I'm working to map all possible status developing the component in a way to allow to configure model option in the simplest possible way and provide update using Pull Requests. I will provide a guide on how update this information. 32 | 33 | ## Component configuration 34 | 35 | Once the component has been installed, you need to configure it using the web interface in order to make it work. 36 | 37 | 1. Go to "Settings->Devices & Services". 38 | 2. Hit shift-reload in your browser (this is important!). 39 | 3. Click "+ Add Integration". 40 | 4. Search for "SmartThinQ LGE Sensors" 41 | 5. Select the integration and **Follow setup workflow** 42 | 43 | **Important**: use your country and language code: SmartThinQ accounts are associated with a specific locale, 44 | so be sure to use the country and language you originally created your account with. 45 | Reference for valid code: 46 | 47 | - Country code: [ISO 3166-1 alpha-2 code][ISO-3166-1-alpha-2] 48 | - Language code: [ISO 639-1 code][ISO-639-1] 49 | 50 | ## Be kind 51 | 52 | If you like the component, why don't you support me by buying me a coffee? 53 | It would certainly motivate me to further improve this work. 54 | 55 | [![Buy me a coffee!](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/ollo69) 56 | 57 | Credits 58 | 59 | ------- 60 | 61 | This component is developed by [Ollo69][ollo69] based on [WideQ API][wideq]. 62 | Original WideQ API was developed by [Adrian Sampson][adrian] under license [MIT][]. 63 | 64 | [ollo69]: https://github.com/ollo69 65 | [wideq]: https://github.com/sampsyo/wideq 66 | [adrian]: https://github.com/sampsyo 67 | [mit]: https://opensource.org/licenses/MIT 68 | [ISO-3166-1-alpha-2]: https://en.wikipedia.org/wiki/ISO_3166-2 69 | [ISO-639-1]: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes 70 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/core_exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions""" 2 | 3 | 4 | class APIError(Exception): 5 | """An error reported by the API.""" 6 | 7 | def __init__(self, message="LG ThinQ API Error", code=None): 8 | self.message = message 9 | self.code = code 10 | if code: 11 | msg = f"{code} - {message}" 12 | else: 13 | msg = message 14 | super().__init__(msg) 15 | 16 | 17 | class ClientDisconnected(APIError): 18 | """Client connection was closed.""" 19 | 20 | def __init__(self): 21 | super().__init__("Client connection was closed") 22 | 23 | 24 | class NotLoggedInError(APIError): 25 | """The session is not valid or expired.""" 26 | 27 | 28 | class NotConnectedError(APIError): 29 | """The service can't contact the specified device.""" 30 | 31 | 32 | class FailedRequestError(APIError): 33 | """A failed request typically indicates an unsupported control on a device.""" 34 | 35 | 36 | class InvalidRequestError(APIError): 37 | """The server rejected a request as invalid.""" 38 | 39 | 40 | class InvalidResponseError(APIError): 41 | """The server provide an invalid response.""" 42 | 43 | def __init__(self, resp_msg): 44 | super().__init__(f"Received response: {resp_msg}") 45 | 46 | 47 | class InvalidCredentialError(APIError): 48 | """The server rejected connection.""" 49 | 50 | 51 | class DelayedResponseError(APIError): 52 | """The device delay in the response.""" 53 | 54 | 55 | class TokenError(APIError): 56 | """An authentication token was rejected.""" 57 | 58 | def __init__(self): 59 | super().__init__("Token Error") 60 | 61 | 62 | class DeviceNotFound(APIError): 63 | """Device ID not valid.""" 64 | 65 | 66 | class MonitorError(APIError): 67 | """Monitoring a device failed, possibly because the monitoring 68 | session failed and needs to be restarted. 69 | """ 70 | 71 | def __init__(self, device_id, code): 72 | self.device_id = device_id 73 | super().__init__(f"Monitor Error for device {device_id}", code) 74 | 75 | 76 | class InvalidDeviceStatus(Exception): 77 | """Device exception occurred when status of device is not valid.""" 78 | 79 | 80 | class AuthenticationError(Exception): 81 | """API exception occurred when fail to authenticate.""" 82 | 83 | def __init__(self, message=None): 84 | if not message: 85 | self.message = "Authentication Error" 86 | else: 87 | self.message = message 88 | super().__init__(self.message) 89 | 90 | 91 | class MonitorRefreshError(Exception): 92 | """Refresh a device status failed.""" 93 | 94 | def __init__(self, device_id, message): 95 | self.device_id = device_id 96 | self.message = message 97 | super().__init__(self.message) 98 | 99 | 100 | class MonitorUnavailableError(Exception): 101 | """Refresh a device status failed because connection unavailable.""" 102 | 103 | def __init__(self, device_id, message): 104 | self.device_id = device_id 105 | self.message = message 106 | super().__init__(self.message) 107 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Kun en enkelt SmartThinQ LGE Sensors konfiguration er tilladt.", 5 | "no_smartthinq_devices": "Der blev ikke fundet nogen SmartThinQ enheder. Komponent-setup annulleres.", 6 | "unsupported_version": "Denne integration kræver mindst HomeAssistant version {req_ver}, du kører version {run_ver}.", 7 | "reconfigured": "Konfigurationen blev gennemført med succes." 8 | }, 9 | "error": { 10 | "error_connect": "Kunne ikke forbinde til SmartThinQ. Prøv igen og sikr dig at du har adgang til ThinQ app'en på din telefon.", 11 | "error_url": "Kunne ikke hente login URL fra ThinQ.", 12 | "invalid_region": "Ukendt regionsformat.", 13 | "invalid_language": "Ukendt sprogformat.", 14 | "invalid_url": "Ukendt videresendelses URL. Sikr dig at du har adgang til ThinQ app'en på din telefon.", 15 | "invalid_credentials": "Ugyldige SmartThinQ brugeroplysninger. Brug LG app'en på din mobile enhed til at sikre at alle Brugsbetingelser er accepterede. Konti baseret på sociale netværk kan ikke bruges og fungerer oftest ikke med denne integration.", 16 | "invalid_config": "Ugyldig konfiguration fundet - konfigurer venligst forfra.", 17 | "no_user_info": "Brugernavn og Kodeord er påkrævede.", 18 | "unknown": "Ukendt fejl." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "LG konto brugernavn", 24 | "password": "Kodeord", 25 | "region": "Landekode", 26 | "language": "Sprogkode", 27 | "use_redirect": "Brug URL videresendelses som godkendelsesmetode", 28 | "use_ha_session": "Del Home Assistant HTTP forbindelse (kan give SSL fejl)", 29 | "use_tls_v1": "Kræv brug af TLSv1 protokol (nogengange påkrævet)", 30 | "exclude_dh": "Brug ikke DH cipher (hvis du får 'dh key too small' fejl)" 31 | }, 32 | "description": "Indtast dine SmartThinQ kontoinformationer." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "SmartThinQ login URL", 37 | "callback_url": "Videresendelses URL" 38 | }, 39 | "description": "Brug URL'en i det første felt for at logge ind i SmartThinQ med dine informationer og indsæt bagefter URL'en til der, hvor din browser blev videresendt til efter login i det andet felt. Del feedback omkring dit setup [her](https://git.io/JU166).", 40 | "title": "SmartThinQ LGE Sensors - Godkendelse" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Refresh Token" 45 | }, 46 | "description": "Gem den genererede token til fremtidig brug, og bekræft herefter for at færdiggøre konfigurationen.", 47 | "title": "SmartThinQ LGE Sensors - Ny refresh token" 48 | } 49 | }, 50 | "title": "SmartThinQ LGE Sensors" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Es ist nur eine einzige Konfiguration von SmartThinQ LGE-Sensoren erlaubt.", 5 | "no_smartthinq_devices": "Keine SmartThinQ-Geräte gefunden. Setup abgebrochen.", 6 | "unsupported_version": "Diese Integration setzt mindestens HomeAssistant Version {req_ver} voraus. Aktuell ist Version {run_ver} installiert.", 7 | "reconfigured": "Konfiguration erfolgreich abgeschlossen." 8 | }, 9 | "error": { 10 | "error_connect": "Verbindung zu SmartThinQ fehlgeschlagen. Try again, and make sure you can access the ThinQ app on your phone.", 11 | "error_url": "Fehler beim Abruf der Login-URL von ThinQ.", 12 | "invalid_region": "Ungültiges Regionsformat.", 13 | "invalid_language": "Ungültiges Sprachformat.", 14 | "invalid_url": "Ungültige Weiterleitungs-URL. Stelle sicher, dass die ThinQ App auf Deinem Smartphone erreichbar ist.", 15 | "invalid_credentials": "Ungültige SmartThinQ Anmeldedaten. Verwende die LG App auf Deinem Mobilgerät um zu prüfen ob Du zuerst Nutzungsbedingungen akzeptieren musst. Socia Media-Accounts werden nicht unterstützt und funktionieren in den meisten Fällen nicht mit dieser Integration.", 16 | "invalid_config": "Ungültige Konfiguration, bitte erneut konfigurieren.", 17 | "no_user_info": "Kein Name und Passwort eingegeben.", 18 | "unknown": "Unbekannter Fehler." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Benutzername des LG Accounts", 24 | "password": "Passwort", 25 | "region": "Landescode", 26 | "language": "Sprachcode", 27 | "use_redirect": "Verwende die Anmeldemethode 'URL Weiterleitung'", 28 | "use_ha_session": "Teile Deine Home Assistant HTTP Verbindung (kann SSL-Fehler verursachen)", 29 | "use_tls_v1": "Erzwinge TLSv1 (gelegentlich notwendig)", 30 | "exclude_dh": "Verwende nicht DH Cypher (wenn der Fehler 'dh key too small' angezeigt wird)" 31 | }, 32 | "description": "Trage Deine Anmeldedaten für Deine SmartThinQ Account hier ein." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "SmartThinQ Login URL", 37 | "callback_url": "Weiterleitungs URL" 38 | }, 39 | "description": "Melde Dich mit der URL im ersten Feld mit Deinen bekannten Anmeldedaten bei SmartThinQ an und kopiere die URL, auf die Du nach dem Login weitergeleitet wirst in das zweite Feld. Du kannst Dein Feedback über das Setup [hier](https://git.io/JU166) teilen.", 40 | "title": "SmartThinQ LGE Sensors - Anmelden" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Token aktualisieren" 45 | }, 46 | "description": "Speichere den generierten Token an einem sicheren Ort und bestätige die Konfiguration.", 47 | "title": "SmartThinQ LGE Sensors - New refresh token" 48 | } 49 | }, 50 | "title": "SmartThinQ LGE Sensors" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Only a single configuration of SmartThinQ LGE Sensors is allowed.", 5 | "no_smartthinq_devices": "No SmartThinQ devices found. Component setup aborted.", 6 | "unsupported_version": "This integration require at least HomeAssistant version {req_ver}, you are running version {run_ver}.", 7 | "reauth_successful": "Configuration successfully completed.", 8 | "reconfigured": "Configuration successfully completed." 9 | }, 10 | "error": { 11 | "error_connect": "Error connecting to SmartThinQ. Try again, and make sure you can access the ThinQ app on your phone.", 12 | "error_url": "Error retrieving login URL from ThinQ.", 13 | "invalid_region": "Invalid region format.", 14 | "invalid_language": "Invalid language format.", 15 | "invalid_url": "Invalid redirection URL. Make sure you can access the ThinQ app on your phone.", 16 | "invalid_credentials": "Invalid SmartThinQ credentials. Use the LG App on your mobile device to verify if there are Term of Service to accept. Account based on social network are not supported and in most case do not work with this integration.", 17 | "invalid_config": "Found invalid configuration, please reconfigure.", 18 | "no_user_info": "User Name and Password are required.", 19 | "unknown": "Unknown error." 20 | }, 21 | "step": { 22 | "user": { 23 | "data": { 24 | "username": "LG account user name", 25 | "password": "Password", 26 | "region": "Country Code", 27 | "language": "Language Code", 28 | "use_redirect": "Use URL redirect authentication method", 29 | "use_ha_session": "Share Home Assistant HTTP connection (may cause SSL errors)", 30 | "use_tls_v1": "Force use of protocol TLSv1 (sometimes required)", 31 | "exclude_dh": "Do not use DH cypher (in case 'dh key too small' error)" 32 | }, 33 | "description": "Insert your SmartThinQ account access information." 34 | }, 35 | "url": { 36 | "data": { 37 | "login_url": "SmartThinQ login URL", 38 | "callback_url": "Redirection URL" 39 | }, 40 | "description": "Use the URL in the first field to perform login to SmartThinQ with your credentials, then paste the URL where the browser is redirected after the login in the second field. Share feedback about your setup [here](https://git.io/JU166).", 41 | "title": "SmartThinQ LGE Sensors - Authentication" 42 | }, 43 | "token": { 44 | "data": { 45 | "token": "Refresh Token" 46 | }, 47 | "description": "Save the generated token for future use, then confirm to complete the configuration.", 48 | "title": "SmartThinQ LGE Sensors - New refresh token" 49 | }, 50 | "reauth_confirm": { 51 | "data": { 52 | "reauth_cred": "Check this option if you need to enter new ThinQ credential" 53 | }, 54 | "title": "SmartThinQ LGE Sensors - Reauth", 55 | "description": "Invalid ThinQ credential error, integration setup aborted. Please use the LG App on your mobile device and check if there are new Term of Service to accept, then press ok to reload integration." 56 | } 57 | }, 58 | "title": "SmartThinQ LGE Sensors" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Somente uma única configuração de sensores SmartThinQ LGE é permitida.", 5 | "no_smartthinq_devices": "Nenhum dispositivo SmartThinQ encontrado. Configuração do componente abortada.", 6 | "unsupported_version": "Esta integração requer pelo menos a versão {req_ver} do HomeAssistant, você está executando a versão {run_ver}.", 7 | "reconfigured": "Configuração concluída com sucesso." 8 | }, 9 | "error": { 10 | "error_connect": "Erro ao conectar ao SmartThinQ. Tente novamente e certifique-se de que você pode acessar o aplicativo ThinQ no seu telefone.", 11 | "error_url": "Erro ao recuperar URL de login do ThinQ.", 12 | "invalid_region": "Formato de região inválido.", 13 | "invalid_language": "Formato de idioma inválido.", 14 | "invalid_url": "URL de redirecionamento inválida. Certifique-se de que você pode acessar o aplicativo ThinQ no seu telefone.", 15 | "invalid_credentials": "Credenciais SmartThinQ inválidas. Use o aplicativo LG no seu dispositivo móvel para verificar se há Termos de Serviço para aceitar. Contas baseadas em redes sociais não são suportadas e, na maioria dos casos, não funcionam com essa integração.", 16 | "invalid_config": "Configuração inválida encontrada, reconfigure.", 17 | "no_user_info": "Usuário e senha são obrigatórios.", 18 | "unknown": "Erro desconhecido." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Usuário da conta LG", 24 | "password": "Senha", 25 | "region": "Código do país", 26 | "language": "Código do idioma", 27 | "use_redirect": "Use o método de autenticação de URL de redirecionamento.", 28 | "use_ha_session": "Compartilhe a conexão HTTP do Home Assistant (pode causar erros de SSL)", 29 | "use_tls_v1": "Forçar o uso do protocolo TLSv1 (às vezes é necessário)", 30 | "exclude_dh": "Não use a cifra DH (no caso do erro 'chave dh muito pequena')" 31 | }, 32 | "description": "Insira as informações de acesso da sua conta SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "URL de login SmartThinQ", 37 | "callback_url": "URL de redirecionamento" 38 | }, 39 | "description": "Use a URL no primeiro campo para fazer login no SmartThinQ com suas credenciais, depois cole a URL para onde o navegador é redirecionado após o login no segundo campo. Compartilhe feedback sobre sua configuração [aqui](https://git.io/JU166).", 40 | "title": "Sensores SmartThinQ LGE - Autenticação" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Atualizar Token" 45 | }, 46 | "description": "Salve o token gerado para uso futuro e confirme para concluir a configuração.", 47 | "title": "Sensores SmartThinQ LGE - Atualizar token" 48 | } 49 | }, 50 | "title": "Sensores SmartThinQ LGE" 51 | } 52 | } -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Apenas é permitida uma única configuração do SmartThinQ LGE Sensors.", 5 | "no_smartthinq_devices": "Não foram encontrados dispositivos SmartThinQ. Configuração do componente interrompida.", 6 | "unsupported_version": "Esta integração requer pelo menos a versão {req_ver} do HomeAssistant, está a executar a versão {run_ver}.", 7 | "reconfigured": "Configuração concluída com sucesso." 8 | }, 9 | "error": { 10 | "error_connect": "Erro ao conectar ao SmartThinQ. Tente novamente e certifique-se que tem acesso à app ThinQ através do smartphone.", 11 | "error_url": "Erro ao obter URL de autenticação do ThinQ.", 12 | "invalid_region": "Formato da região inválido.", 13 | "invalid_language": "Formato do idioma inválido.", 14 | "invalid_url": "URL de redirecionamento inválido. Certifique-se de que tem acesso à app ThinQ através do smartphone.", 15 | "invalid_credentials": "Credenciais SmartThinQ inválidas. Utilize a App da LG no seu dispositivo móvel para verificar se existem Termos de Serviço para aceitar. Contas baseadas em redes sociais não são suportadas e na maioria dos casos não funcionam com esta integração.", 16 | "invalid_config": "Encontrada configuração inválida, por favor reconfigure.", 17 | "no_user_info": "O nome de utilizador e senha são obrigatórios.", 18 | "unknown": "Erro desconhecido." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Utilizador da conta LG", 24 | "password": "Senha", 25 | "region": "Código do país", 26 | "language": "Código do idioma", 27 | "use_redirect": "Usar o método de autenticação de redirecionamento de URL", 28 | "use_ha_session": "Partilhar ligação HTTP do Home Assistant (pode causar erros SSL)", 29 | "use_tls_v1": "Forçar o uso do protocolo TLSv1 (por vezes necessário)", 30 | "exclude_dh": "Não usar a cifra DH (no caso de erro 'chave dh muito pequena')" 31 | }, 32 | "description": "Insira as informações de acesso à sua conta SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "URL de acesso ao SmartThinQ", 37 | "callback_url": "URL de redirecionamento" 38 | }, 39 | "description": "Use o URL do primeiro campo para iniciar sessão no SmartThinQ, depois cole o URL para onde o navegador é redirecionado após autenticação no segundo campo. Deixe a sua opinião acerca da configuração [aqui](https://git.io/JU166).", 40 | "title": "SmartThinQ LGE Sensors - Autenticação" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Refresh Token" 45 | }, 46 | "description": "Guarde o token gerado para uso futuro, depois confirme para concluir a configuração.", 47 | "title": "SmartThinQ LGE Sensors - Novo refresh Token" 48 | } 49 | }, 50 | "title": "Sensores SmartThinQ LGE" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/fr-BE.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Seulement une configuration de Capteurs SmartThinQ LGE est permise.", 5 | "no_smartthinq_devices": "Aucun appareil SmartThinQ trouvé. Configuration du composant abandonné.", 6 | "unsupported_version": "Cette inégration requiert au minimum HomeAssistant version {req_ver}, vous utilisez la version {run_ver}.", 7 | "reconfigured": "Configuration terminée avec succès." 8 | }, 9 | "error": { 10 | "error_connect": "Erreur de connexion à SmartThinQ. Réessayez et assurez-vous que vous pouvez accéder à l'application LG ThinQ sur votre GSM.", 11 | "error_url": "Erreur lors de la récupération de l'URL de connexion de ThinQ.", 12 | "invalid_region": "Format de région invalide.", 13 | "invalid_language": "Format de language invalide.", 14 | "invalid_url": "URL de redirection invalide. Assurez-vous que vous pouvez accéder à l'application LG ThinQ sur votre GSM.", 15 | "invalid_credentials": "Informations de compte SmartThinQ invalides. Utilisez l'application LG ThinQ sur votre GSM pour vérifier s'il y a des conditions d'utilisation à accepter. Les comptes créés à partir de réseaux sociaux ne sont pas pris en charge et, dans la plupart des cas, ne fonctionnent pas avec cette intégration.", 16 | "invalid_config": "Configuration invalide détectée, veuillez reconfigurer.", 17 | "no_user_info": "Le nom d'utilisateur et le mot de passe sont requis.", 18 | "unknown": "Erreur inconnue." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Nom d'utilisateur du compte LG", 24 | "password": "Mot de passe", 25 | "region": "Code du Pays", 26 | "language": "Code du Language", 27 | "use_redirect": "Utilise la méthode d'authentification de redirection d'URL", 28 | "use_ha_session": "Partage la connexion HTTP de Home Assistant (peut provoquer des erreurs SSL)", 29 | "use_tls_v1": "Force l'utilisation du protocole TLSv1 (parfois requis)", 30 | "exclude_dh": "Ne pas utiliser le chiffrement DH (en cas d'erreur 'dh key too small')" 31 | }, 32 | "description": "Insérez vos informations d'accès à votre compte SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "URL de connexion SmartThinQ", 37 | "callback_url": "URL de redirection" 38 | }, 39 | "description": "Utilisez l'URL dans le premier champ pour vous connecter à SmartThinQ avec vos informations d'identification, puis collez l'URL où le navigateur est redirigé après la connexion dans le deuxième champ. Partagez vos commentaires sur votre configuration [ici](https://git.io/JU166).", 40 | "title": "Capteurs SmartThinQ LGE - Authentification" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Actualiser le jeton" 45 | }, 46 | "description": "Enregistrez le jeton généré pour une utilisation future, puis confirmez pour terminer la configuration.", 47 | "title": "Capteurs SmartThinQ LGE - Nouveau jeton d'actualisation" 48 | } 49 | }, 50 | "title": "Capteurs SmartThinQ LGE" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Seulement une configuration de Capteurs SmartThinQ LGE est permise.", 5 | "no_smartthinq_devices": "Aucun appareil SmartThinQ trouvé. Configuration du composant abandonné.", 6 | "unsupported_version": "Cette inégration requiert au minimum HomeAssistant version {req_ver}, vous utilisez la version {run_ver}.", 7 | "reconfigured": "Configuration terminée avec succès." 8 | }, 9 | "error": { 10 | "error_connect": "Erreur de connexion à SmartThinQ. Réessayez et assurez-vous que vous pouvez accéder à l'application LG ThinQ sur votre portable.", 11 | "error_url": "Erreur lors de la récupération de l'URL de connexion de ThinQ.", 12 | "invalid_region": "Format de région invalide.", 13 | "invalid_language": "Format de language invalide.", 14 | "invalid_url": "URL de redirection invalide. Assurez-vous que vous pouvez accéder à l'application LG ThinQ sur votre portable.", 15 | "invalid_credentials": "Informations de compte SmartThinQ invalides. Utilisez l'application LG ThinQ sur votre téléphone pour vérifier s'il y a des conditions d'utilisation à accepter. Les comptes créés à partir de réseaux sociaux ne sont pas pris en charge et, dans la plupart des cas, ne fonctionnent pas avec cette intégration.", 16 | "invalid_config": "Configuration invalide détectée, veuillez reconfigurer.", 17 | "no_user_info": "Le nom d'utilisateur et le mot de passe sont requis.", 18 | "unknown": "Erreur inconnue." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Nom d'utilisateur du compte LG", 24 | "password": "Mot de passe", 25 | "region": "Code du Pays", 26 | "language": "Code du Language", 27 | "use_redirect": "Utilise la méthode d'authentification de redirection d'URL", 28 | "use_ha_session": "Partage la connexion HTTP de Home Assistant (peut provoquer des erreurs SSL)", 29 | "use_tls_v1": "Force l'utilisation du protocole TLSv1 (parfois requis)", 30 | "exclude_dh": "Ne pas utiliser le chiffrement DH (en cas d'erreur 'dh key too small')" 31 | }, 32 | "description": "Insérez vos informations d'accès à votre compte SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "URL de connexion SmartThinQ", 37 | "callback_url": "URL de redirection" 38 | }, 39 | "description": "Utilisez l'URL dans le premier champ pour vous connecter à SmartThinQ avec vos informations d'identification, puis collez l'URL où le navigateur est redirigé après la connexion dans le deuxième champ. Partagez vos commentaires sur votre configuration [ici](https://git.io/JU166).", 40 | "title": "Capteurs SmartThinQ LGE - Authentification" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Actualiser le jeton" 45 | }, 46 | "description": "Enregistrez le jeton généré pour une utilisation future, puis confirmez pour terminer la configuration.", 47 | "title": "Capteurs SmartThinQ LGE - Nouveau jeton d'actualisation" 48 | } 49 | }, 50 | "title": "Capteurs SmartThinQ LGE" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/fr-CA.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Seulement une configuration de SmartThinQ LGE Sensors est permise.", 5 | "no_smartthinq_devices": "Aucun appareil SmartThinQ trouvé. Configuration du composant abandonné.", 6 | "unsupported_version": "Cette inégration demande au minimum HomeAssistant version {req_ver}, vous utilisez la version {run_ver}.", 7 | "reconfigured": "Configuration terminée avec succès." 8 | }, 9 | "error": { 10 | "error_connect": "Erreur de connexion à SmartThinQ. Réessayez et assurez-vous que vous pouvez accéder à l'application LG ThinQ sur votre cellulaire.", 11 | "error_url": "Erreur lors de la récupération de l'URL de connexion de ThinQ.", 12 | "invalid_region": "Format de région invalide.", 13 | "invalid_language": "Format de language invalide.", 14 | "invalid_url": "URL de redirection invalide. Assurez-vous que vous pouvez accéder à l'application LG ThinQ sur votre cellulaire.", 15 | "invalid_credentials": "Informations de compte SmartThinQ invalides. Utilisez l'application LG ThinQ sur votre cellulaire pour vérifier s'il y a des conditions d'utilisation à accepter. Les comptes créés à partir de réseaux sociaux ne sont pas pris en charge et, dans la plupart des cas, ne fonctionnent pas avec cette intégration.", 16 | "invalid_config": "Configuration invalide détectée, veuillez reconfigurer.", 17 | "no_user_info": "Le nom d'utilisateur et le mot de passe sont requis.", 18 | "unknown": "Erreur inconnue." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Nom d'utilisateur du compte LG", 24 | "password": "Mot de passe", 25 | "region": "Code du Pays", 26 | "language": "Code du Language", 27 | "use_redirect": "Utilise la méthode d'authentification de redirection d'URL", 28 | "use_ha_session": "Partage la connexion HTTP de Home Assistant (peut provoquer des erreurs SSL)", 29 | "use_tls_v1": "Force l'utilisation du protocole TLSv1 (parfois requis)", 30 | "exclude_dh": "Ne pas utiliser le chiffrement DH (en cas d'erreur 'dh key too small')" 31 | }, 32 | "description": "Insérez vos informations d'accès à votre compte SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "URL de connexion SmartThinQ", 37 | "callback_url": "URL de redirection" 38 | }, 39 | "description": "Utilisez l'URL dans le premier champ pour vous connecter à SmartThinQ avec vos informations d'identification, puis collez l'URL où le navigateur est redirigé après la connexion dans le deuxième champ. Partagez vos commentaires sur votre configuration [ici](https://git.io/JU166).", 40 | "title": "Capteurs SmartThinQ LGE - Authentification" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Actualiser le token" 45 | }, 46 | "description": "Enregistrez le token généré pour une utilisation future, puis confirmez pour terminer la configuration.", 47 | "title": "SmartThinQ LGE Sensors - Nouveau token d'actualisation" 48 | } 49 | }, 50 | "title": "SmartThinQ LGE Sensors" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "E' consentita una sola configurazione di SmartThinQ LGE Sensors.", 5 | "no_smartthinq_devices": "Nessun dispositivo SmartThinQ trovato. Component setup interrotto.", 6 | "unsupported_version": "Questa integrazione richiede almeno la versione {req_ver} di HomeAssistant, tu stai usando la versione {run_ver}.", 7 | "reauth_successful": "Configurazione completata con successo.", 8 | "reconfigured": "Configurazione completata con successo." 9 | }, 10 | "error": { 11 | "error_connect": "Errore di connessione a SmartThinQ. Riprova, assicurandoti prima di poter accedere dall'applicazione ThinQ sul telefono", 12 | "error_url": "Errore nel recuperare l'URL di login da ThinQ.", 13 | "invalid_region": "Formato Paese non valido.", 14 | "invalid_language": "Formato lingua non valido.", 15 | "invalid_url": "URL di reindizzamento non valido. Accertati di poter accedere alla app ThinQ sul tuo smartphone.", 16 | "invalid_credentials": "Credenziali SmartThinQ non valide. Utilizza l'applicazione ThinQ sul telefono per verificare che non siano presenti Condizioni di Servizio da accettare. Gli account basati su social network non sono supportati e nella maggior parte dei casi non funzionano con questa integrazione.", 17 | "invalid_config": "Trovata configurazione non valida, riconfigurare.", 18 | "no_user_info": "Nome utente e Password sono campi obbligatori.", 19 | "unknown": "Errore sconosciuto." 20 | }, 21 | "step": { 22 | "user": { 23 | "data": { 24 | "username": "Nome utente account LG", 25 | "password": "Password", 26 | "region": "Codice Paese", 27 | "language": "Codice Lingua", 28 | "use_redirect": "Usa l'autenticazione basata su URL redirect", 29 | "use_ha_session": "Utilizza la connessione HTTP di Home Assistant (può cause errori SSL)", 30 | "use_tls_v1": "Forza l'utilizzo del protocollo TLSv1 (richiesto in alcuni casi)", 31 | "exclude_dh": "Non utilizzare il cypher DH (in caso di errore 'dh key too small')" 32 | }, 33 | "description": "Imposta le informazioni di accesso del tuo account SmartThinQ." 34 | }, 35 | "url": { 36 | "data": { 37 | "login_url": "URL di accesso a SmartThinQ", 38 | "callback_url": "URL di reindirizzamento" 39 | }, 40 | "description": "Utilizzare l'URL del primo campo per eseguire l'accesso con le proprie credenziali a SmartThinQ, poi incollare l'URL in cui si viene reindirizzati con il browser dopo l'accesso nel secondo campo. Condividi i tuoi feedback sul setup [qui](https://git.io/JU166).", 41 | "title": "SmartThinQ LGE Sensors - Autenticazione" 42 | }, 43 | "token": { 44 | "data": { 45 | "token": "Aggiornamento Token" 46 | }, 47 | "description": "Salvare il token generato per un utilizzo futuro, poi confermare per completare la configurazione.", 48 | "title": "SmartThinQ LGE Sensors - Nuovo aggiornamento Token" 49 | }, 50 | "reauth_confirm": { 51 | "data": { 52 | "reauth_cred": "Seleziona questa opzione se devi reimpostare le credenziali ThinQ" 53 | }, 54 | "title": "SmartThinQ LGE Sensors - Reauth", 55 | "description": "Errore nelle credenziali ThinQ, avvio integrazione abortito. Utilizza l'app LG sul tuo dispositivo mobile e verifica se ci sono nuovi Termini di servizio da accettare, quindi premi OK per ricaricare l'integrazione." 56 | } 57 | }, 58 | "title": "SmartThinQ LGE Sensors" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/translations/el.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "single_instance_allowed": "Επιτρέπεται μόνο μία διαμόρφωση των αισθητήρων LGE SmartThinQ.", 5 | "no_smartthinq_devices": "Δεν βρέθηκαν συσκευές SmartThinQ. Η ρύθμιση στοιχείων ματαιώθηκε.", 6 | "unsupported_version": "Αυτή η ενσωμάτωση απαιτεί τουλάχιστον την έκδοση HomeAssistant {req_ver}, εκτελείτε την έκδοση {run_ver}.", 7 | "reconfigured": "Configuration successfully completed." 8 | }, 9 | "error": { 10 | "error_connect": "Σφάλμα σύνδεσης στο SmartThinQ. Δοκιμάστε ξανά και βεβαιωθείτε ότι έχετε πρόσβαση στην εφαρμογή ThinQ στο τηλέφωνό σας.", 11 | "error_url": "Σφάλμα κατά την ανάκτηση της διεύθυνσης URL σύνδεσης από το ThinQ.", 12 | "invalid_region": "Μη έγκυρη μορφή περιοχής.", 13 | "invalid_language": "Μη έγκυρη μορφή γλώσσας.", 14 | "invalid_url": "Μη έγκυρη διεύθυνση URL ανακατεύθυνσης. Βεβαιωθείτε ότι έχετε πρόσβαση στην εφαρμογή ThinQ στο τηλέφωνό σας.", 15 | "invalid_credentials": "Μη έγκυρα διαπιστευτήρια SmartThinQ. Χρησιμοποιήστε την εφαρμογή LG App στην κινητή συσκευή σας για να ελέγξετε αν υπάρχουν όροι παροχής υπηρεσιών που πρέπει να αποδεχτείτε. Οι λογαριασμοί που βασίζονται σε κοινωνικό δίκτυο δεν υποστηρίζονται και στις περισσότερες περιπτώσεις δεν λειτουργούν με αυτή την ενσωμάτωση.", 16 | "invalid_config": "Βρέθηκε άκυρη διαμόρφωση, παρακαλούμε επαναδιαμορφώστε την.", 17 | "no_user_info": "Απαιτείται όνομα χρήστη και κωδικός πρόσβασης.", 18 | "unknown": "Άγνωστο σφάλμα." 19 | }, 20 | "step": { 21 | "user": { 22 | "data": { 23 | "username": "Όνομα χρήστη λογαριασμού LG", 24 | "password": "Κωδικός πρόσβασης", 25 | "region": "Κωδικός χώρας", 26 | "language": "Κωδικός γλώσσας", 27 | "use_redirect": "Χρήση μεθόδου ελέγχου ταυτότητας ανακατεύθυνσης URL", 28 | "use_ha_session": "Μοιραστείτε τη σύνδεση HTTP του Home Assistant (μπορεί να προκαλέσει σφάλματα SSL)", 29 | "use_tls_v1": "Βίαιη χρήση του πρωτοκόλλου TLSv1 (μερικές φορές απαιτείται)", 30 | "exclude_dh": "Μην χρησιμοποιείτε το DH cypher (σε περίπτωση σφάλματος όπου 'το κλειδί dh είναι πολύ μικρό΄)" 31 | }, 32 | "description": "Επιλέξτε τις πληροφορίες τοπικής προσαρμογής του λογαριασμού SmartThinQ." 33 | }, 34 | "url": { 35 | "data": { 36 | "login_url": "Διεύθυνση URL σύνδεσης SmartThinQ", 37 | "callback_url": "Διεύθυνση URL ανακατεύθυνσης" 38 | }, 39 | "description": "Χρησιμοποιήστε τη διεύθυνση URL στο πρώτο πεδίο για να εκτελέσετε τη σύνδεση στο SmartThinQ με τα διαπιστευτήριά σας και, στη συνέχεια, επικολλήστε τη διεύθυνση URL όπου το πρόγραμμα περιήγησης ανακατευθύνεται μετά τη σύνδεση στο δεύτερο πεδίο. Μοιραστείτε σχόλια σχετικά με τη ρύθμιση [εδώ](https://git.io/JU166).", 40 | "title": "Αισθητήρες LGE SmartThinQ - Έλεγχος ταυτότητας" 41 | }, 42 | "token": { 43 | "data": { 44 | "token": "Ανανέωση διακριτικού" 45 | }, 46 | "description": "Αποθηκεύστε το διακριτικό που δημιουργήθηκε για μελλοντική χρήση και, στη συνέχεια, επιβεβαιώστε για να ολοκληρώσετε τη ρύθμιση παραμέτρων.", 47 | "title": "SmartThinQ LGE Αισθητήρες - Νέο διακριτικό ανανέωσης" 48 | } 49 | }, 50 | "title": "SmartThinQ LGE Αισθητήρες" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/button.py: -------------------------------------------------------------------------------- 1 | """Support for ThinQ device buttons.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | from typing import Any, Awaitable, Callable 8 | 9 | from homeassistant.components.button import ( 10 | ButtonDeviceClass, 11 | ButtonEntity, 12 | ButtonEntityDescription, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import HomeAssistant, callback 16 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from . import LGEDevice 21 | from .const import DOMAIN, LGE_DEVICES, LGE_DISCOVERY_NEW 22 | from .device_helpers import LGEBaseDevice 23 | from .wideq import WM_DEVICE_TYPES, WashDeviceFeatures 24 | 25 | # general button attributes 26 | ATTR_REMOTE_START = "remote_start" 27 | ATTR_PAUSE = "device_pause" 28 | 29 | _LOGGER = logging.getLogger(__name__) 30 | 31 | 32 | @dataclass 33 | class ThinQButtonDescriptionMixin: 34 | """Mixin to describe a Button entity.""" 35 | 36 | press_action_fn: Callable[[Any], Awaitable[None]] 37 | 38 | 39 | @dataclass 40 | class ThinQButtonEntityDescription( 41 | ButtonEntityDescription, ThinQButtonDescriptionMixin 42 | ): 43 | """A class that describes ThinQ button entities.""" 44 | 45 | available_fn: Callable[[Any], bool] | None = None 46 | related_feature: str | None = None 47 | 48 | 49 | WASH_DEV_BUTTON: tuple[ThinQButtonEntityDescription, ...] = ( 50 | ThinQButtonEntityDescription( 51 | key=ATTR_REMOTE_START, 52 | name="Remote Start", 53 | icon="mdi:play-circle-outline", 54 | device_class=ButtonDeviceClass.UPDATE, 55 | press_action_fn=lambda x: x.device.remote_start(), 56 | available_fn=lambda x: x.device.remote_start_enabled, 57 | related_feature=WashDeviceFeatures.REMOTESTART, 58 | ), 59 | ThinQButtonEntityDescription( 60 | key=ATTR_PAUSE, 61 | name="Pause", 62 | icon="mdi:pause-circle-outline", 63 | device_class=ButtonDeviceClass.UPDATE, 64 | press_action_fn=lambda x: x.device.pause(), 65 | available_fn=lambda x: x.device.pause_enabled, 66 | related_feature=WashDeviceFeatures.REMOTESTART, 67 | ), 68 | ) 69 | 70 | BUTTON_ENTITIES = { 71 | **{dev_type: WASH_DEV_BUTTON for dev_type in WM_DEVICE_TYPES}, 72 | } 73 | 74 | 75 | def _button_exist( 76 | lge_device: LGEDevice, button_desc: ThinQButtonEntityDescription 77 | ) -> bool: 78 | """Check if a button exist for device.""" 79 | feature = button_desc.related_feature 80 | if feature is None or feature in lge_device.available_features: 81 | return True 82 | 83 | return False 84 | 85 | 86 | async def async_setup_entry( 87 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 88 | ) -> None: 89 | """Set up the LGE buttons.""" 90 | entry_config = hass.data[DOMAIN] 91 | lge_cfg_devices = entry_config.get(LGE_DEVICES) 92 | 93 | _LOGGER.debug("Starting LGE ThinQ button setup...") 94 | 95 | @callback 96 | def _async_discover_device(lge_devices: dict) -> None: 97 | """Add entities for a discovered ThinQ device.""" 98 | 99 | if not lge_devices: 100 | return 101 | 102 | lge_button = [ 103 | LGEButton(lge_device, button_desc) 104 | for dev_type, button_descs in BUTTON_ENTITIES.items() 105 | for button_desc in button_descs 106 | for lge_device in lge_devices.get(dev_type, []) 107 | if _button_exist(lge_device, button_desc) 108 | ] 109 | 110 | async_add_entities(lge_button) 111 | 112 | _async_discover_device(lge_cfg_devices) 113 | 114 | entry.async_on_unload( 115 | async_dispatcher_connect(hass, LGE_DISCOVERY_NEW, _async_discover_device) 116 | ) 117 | 118 | 119 | class LGEButton(CoordinatorEntity, ButtonEntity): 120 | """Class to control buttons for LGE device""" 121 | 122 | entity_description: ThinQButtonEntityDescription 123 | _attr_has_entity_name = True 124 | 125 | def __init__( 126 | self, 127 | api: LGEDevice, 128 | description: ThinQButtonEntityDescription, 129 | ): 130 | """Initialize the button.""" 131 | super().__init__(api.coordinator) 132 | self._api = api 133 | self._wrap_device = LGEBaseDevice(api) 134 | self.entity_description = description 135 | self._attr_unique_id = f"{api.unique_id}-{description.key}-button" 136 | self._attr_device_info = api.device_info 137 | 138 | @property 139 | def available(self) -> bool: 140 | """Return True if entity is available.""" 141 | is_avail = True 142 | if self.entity_description.available_fn is not None: 143 | is_avail = self.entity_description.available_fn(self._wrap_device) 144 | return self._api.available and is_avail 145 | 146 | async def async_press(self) -> None: 147 | """Triggers service.""" 148 | await self.entity_description.press_action_fn(self._wrap_device) 149 | self._api.async_set_updated() 150 | -------------------------------------------------------------------------------- /tests/test_config_flow.py: -------------------------------------------------------------------------------- 1 | """Test the SmartThinQ sensors config flow.""" 2 | 3 | from unittest.mock import AsyncMock, patch 4 | 5 | import pytest 6 | from pytest_homeassistant_custom_component.common import MockConfigEntry 7 | 8 | from homeassistant import config_entries, data_entry_flow 9 | from homeassistant.const import ( 10 | CONF_BASE, 11 | CONF_CLIENT_ID, 12 | CONF_PASSWORD, 13 | CONF_REGION, 14 | CONF_TOKEN, 15 | CONF_USERNAME, 16 | ) 17 | 18 | from custom_components.smartthinq_sensors.const import ( 19 | CONF_LANGUAGE, 20 | CONF_OAUTH2_URL, 21 | CONF_USE_API_V2, 22 | CONF_USE_REDIRECT, 23 | DOMAIN, 24 | ) 25 | from custom_components.smartthinq_sensors.wideq.core_exceptions import ( 26 | AuthenticationError, 27 | InvalidCredentialError, 28 | ) 29 | 30 | TEST_USER = "test-email@test-domain.com" 31 | TEST_TOKEN = "test-token" 32 | TEST_URL = "test-url" 33 | TEST_CLIENT_ID = "abcde" 34 | 35 | CONFIG_DATA = { 36 | CONF_USERNAME: TEST_USER, 37 | CONF_PASSWORD: "test-password", 38 | CONF_REGION: "US", 39 | CONF_LANGUAGE: "en", 40 | CONF_USE_REDIRECT: False, 41 | } 42 | CONFIG_RESULT = { 43 | CONF_REGION: "US", 44 | CONF_LANGUAGE: "en-US", 45 | CONF_USE_API_V2: True, 46 | CONF_TOKEN: TEST_TOKEN, 47 | CONF_CLIENT_ID: TEST_CLIENT_ID, 48 | CONF_OAUTH2_URL: TEST_URL, 49 | } 50 | 51 | 52 | class MockClient: 53 | """Mock wideq ClientAsync.""" 54 | 55 | def __init__(self, has_devices=True): 56 | """Initialize a fake client to test config flow.""" 57 | self.has_devices = has_devices 58 | self.client_id = TEST_CLIENT_ID 59 | 60 | async def close(self): 61 | """Fake close method.""" 62 | return 63 | 64 | 65 | @pytest.fixture(name="connect") 66 | def mock_controller_connect(): 67 | """Mock a successful connection.""" 68 | with patch( 69 | "custom_components.smartthinq_sensors.config_flow.LGEAuthentication" 70 | ) as service_mock: 71 | service_mock.return_value.get_oauth_info_from_login = AsyncMock( 72 | return_value={"refresh_token": TEST_TOKEN, "oauth_url": TEST_URL} 73 | ) 74 | service_mock.return_value.create_client_from_token = AsyncMock( 75 | return_value=MockClient() 76 | ) 77 | yield service_mock 78 | 79 | 80 | PATCH_SETUP_ENTRY = patch( 81 | "custom_components.smartthinq_sensors.async_setup_entry", 82 | return_value=True, 83 | ) 84 | 85 | 86 | async def test_form(hass, connect): 87 | """Test we get the form.""" 88 | result = await hass.config_entries.flow.async_init( 89 | DOMAIN, context={"source": config_entries.SOURCE_USER} 90 | ) 91 | assert result["type"] == data_entry_flow.FlowResultType.FORM 92 | assert result["errors"] is None 93 | 94 | with PATCH_SETUP_ENTRY as mock_setup_entry: 95 | result2 = await hass.config_entries.flow.async_configure( 96 | result["flow_id"], user_input=CONFIG_DATA 97 | ) 98 | await hass.async_block_till_done() 99 | 100 | assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY 101 | assert result2["data"] == CONFIG_RESULT 102 | assert len(mock_setup_entry.mock_calls) == 1 103 | 104 | 105 | @pytest.mark.parametrize( 106 | "error,reason", 107 | [ 108 | (AuthenticationError(), "invalid_credentials"), 109 | (InvalidCredentialError(), "invalid_credentials"), 110 | (Exception(), "error_connect"), 111 | ], 112 | ) 113 | async def test_form_errors(hass, connect, error, reason): 114 | """Test we handle cannot connect error.""" 115 | connect.return_value.create_client_from_token = AsyncMock(side_effect=error) 116 | 117 | result = await hass.config_entries.flow.async_init( 118 | DOMAIN, 119 | context={"source": config_entries.SOURCE_USER}, 120 | data=CONFIG_DATA, 121 | ) 122 | 123 | assert result["type"] == data_entry_flow.FlowResultType.FORM 124 | assert result["errors"] == {CONF_BASE: reason} 125 | 126 | 127 | @pytest.mark.parametrize( 128 | "login_result", 129 | [None, MockClient(False)], 130 | ) 131 | async def test_form_response_nodev(hass, connect, login_result): 132 | """Test we handle response errors.""" 133 | connect.return_value.create_client_from_token = AsyncMock(return_value=login_result) 134 | 135 | result = await hass.config_entries.flow.async_init( 136 | DOMAIN, 137 | context={"source": config_entries.SOURCE_USER}, 138 | data=CONFIG_DATA, 139 | ) 140 | 141 | assert result["type"] == data_entry_flow.FlowResultType.ABORT 142 | assert result["reason"] == "no_smartthinq_devices" 143 | 144 | 145 | async def test_token_refresh(hass, connect): 146 | """Re-configuration when config is invalid should refresh token.""" 147 | mock_entry = MockConfigEntry( 148 | domain=DOMAIN, 149 | data={**CONFIG_RESULT, CONF_TOKEN: "test-original-token"}, 150 | ) 151 | mock_entry.add_to_hass(hass) 152 | assert mock_entry.data[CONF_TOKEN] == "test-original-token" 153 | 154 | result = await hass.config_entries.flow.async_init( 155 | DOMAIN, 156 | context={"source": config_entries.SOURCE_IMPORT}, 157 | data=CONFIG_DATA, 158 | ) 159 | assert result["type"] == data_entry_flow.FlowResultType.FORM 160 | assert result["errors"] == {CONF_BASE: "invalid_config"} 161 | 162 | with PATCH_SETUP_ENTRY as mock_setup_entry: 163 | result2 = await hass.config_entries.flow.async_configure( 164 | result["flow_id"], user_input=CONFIG_DATA 165 | ) 166 | await hass.async_block_till_done() 167 | 168 | assert result2["type"] == data_entry_flow.FlowResultType.ABORT 169 | assert result2["reason"] == "reauth_successful" 170 | assert len(mock_setup_entry.mock_calls) == 1 171 | 172 | entries = hass.config_entries.async_entries(DOMAIN) 173 | assert len(entries) == 1 174 | 175 | entry = entries[0] 176 | assert entry.data[CONF_TOKEN] == TEST_TOKEN 177 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for LG ThinQ.""" 2 | 3 | from __future__ import annotations 4 | 5 | from homeassistant.components.diagnostics import REDACTED, async_redact_data 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.const import CONF_TOKEN 8 | from homeassistant.core import HomeAssistant, callback 9 | from homeassistant.helpers import device_registry as dr, entity_registry as er 10 | 11 | from . import UNSUPPORTED_DEVICES 12 | from .const import DOMAIN, LGE_DEVICES 13 | from .wideq.device import Device as ThinQDevice 14 | 15 | TO_REDACT = {CONF_TOKEN} 16 | TO_REDACT_DEV = {"macAddress", "ssid", "userNo"} 17 | TO_REDACT_STATE = {"macAddress", "ssid"} 18 | 19 | 20 | async def async_get_config_entry_diagnostics( 21 | hass: HomeAssistant, entry: ConfigEntry 22 | ) -> dict: 23 | """Return diagnostics for a config entry.""" 24 | return _async_get_diagnostics(hass, entry) 25 | 26 | 27 | async def async_get_device_diagnostics( 28 | hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry 29 | ) -> dict: 30 | """Return diagnostics for a device entry.""" 31 | return _async_get_diagnostics(hass, entry, device) 32 | 33 | 34 | @callback 35 | def _async_get_diagnostics( 36 | hass: HomeAssistant, 37 | entry: ConfigEntry, 38 | device: dr.DeviceEntry | None = None, 39 | ) -> dict: 40 | """Return diagnostics for a config or a device entry.""" 41 | diag_data = {"entry": async_redact_data(entry.as_dict(), TO_REDACT)} 42 | 43 | lg_device_id = None 44 | if device: 45 | lg_device_id = next(iter(device.identifiers))[1] 46 | 47 | devs_data = _async_devices_as_dict(hass, lg_device_id) 48 | diag_data[LGE_DEVICES] = devs_data 49 | 50 | if device: 51 | return diag_data 52 | 53 | # Get info for unsupported device if diagnostic is for the config entry 54 | unsup_devices = hass.data[DOMAIN].get(UNSUPPORTED_DEVICES, {}) 55 | unsup_data = {} 56 | for dev_type, devices in unsup_devices.items(): 57 | unsup_devs = [ 58 | async_redact_data(device.as_dict(), TO_REDACT_DEV) for device in devices 59 | ] 60 | unsup_data[dev_type.name] = unsup_devs 61 | 62 | if unsup_data: 63 | diag_data[UNSUPPORTED_DEVICES] = unsup_data 64 | 65 | return diag_data 66 | 67 | 68 | @callback 69 | def _async_devices_as_dict( 70 | hass: HomeAssistant, lg_device_id: str | None = None 71 | ) -> dict: 72 | """Represent a LGE devices as a dictionary.""" 73 | 74 | lge_devices = hass.data[DOMAIN].get(LGE_DEVICES, {}) 75 | devs_data = {} 76 | for dev_type, devices in lge_devices.items(): 77 | lge_devs = {} 78 | for lge_device in devices: 79 | device: ThinQDevice = lge_device.device 80 | if lg_device_id and device.device_info.device_id != lg_device_id: 81 | continue 82 | 83 | lge_devs[lge_device.unique_id] = { 84 | "device_info": async_redact_data( 85 | device.device_info.as_dict(), TO_REDACT_DEV 86 | ), 87 | "model_info": device.model_info.as_dict(), 88 | "device_status": device.status.as_dict if device.status else None, 89 | "home_assistant": _async_device_ha_info( 90 | hass, device.device_info.device_id 91 | ), 92 | } 93 | if lg_device_id: 94 | return {dev_type.name: lge_devs} 95 | 96 | if lge_devs: 97 | devs_data[dev_type.name] = lge_devs 98 | 99 | return devs_data 100 | 101 | 102 | @callback 103 | def _async_device_ha_info(hass: HomeAssistant, lg_device_id: str) -> dict | None: 104 | """Gather information how this ThinQ device is represented in Home Assistant.""" 105 | 106 | device_registry = dr.async_get(hass) 107 | entity_registry = er.async_get(hass) 108 | hass_device = device_registry.async_get_device(identifiers={(DOMAIN, lg_device_id)}) 109 | if not hass_device: 110 | return None 111 | 112 | data = { 113 | "name": hass_device.name, 114 | "name_by_user": hass_device.name_by_user, 115 | "model": hass_device.model, 116 | "manufacturer": hass_device.manufacturer, 117 | "sw_version": hass_device.sw_version, 118 | "disabled": hass_device.disabled, 119 | "disabled_by": hass_device.disabled_by, 120 | "entities": {}, 121 | } 122 | 123 | hass_entities = er.async_entries_for_device( 124 | entity_registry, 125 | device_id=hass_device.id, 126 | include_disabled_entities=True, 127 | ) 128 | 129 | for entity_entry in hass_entities: 130 | if entity_entry.platform != DOMAIN: 131 | continue 132 | state = hass.states.get(entity_entry.entity_id) 133 | state_dict = None 134 | if state: 135 | state_dict = dict(state.as_dict()) 136 | # The entity_id is already provided at root level. 137 | state_dict.pop("entity_id", None) 138 | # The context doesn't provide useful information in this case. 139 | state_dict.pop("context", None) 140 | 141 | if state_dict and "state" in state_dict: 142 | for to_redact in TO_REDACT_STATE: 143 | if entity_entry.entity_id.endswith(f"_{to_redact}"): 144 | state_dict["state"] = REDACTED 145 | break 146 | 147 | data["entities"][entity_entry.entity_id] = { 148 | "name": entity_entry.name, 149 | "original_name": entity_entry.original_name, 150 | "disabled": entity_entry.disabled, 151 | "disabled_by": entity_entry.disabled_by, 152 | "entity_category": entity_entry.entity_category, 153 | "device_class": entity_entry.device_class, 154 | "original_device_class": entity_entry.original_device_class, 155 | "icon": entity_entry.icon, 156 | "original_icon": entity_entry.original_icon, 157 | "unit_of_measurement": entity_entry.unit_of_measurement, 158 | "state": state_dict, 159 | } 160 | 161 | return data 162 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/const.py: -------------------------------------------------------------------------------- 1 | """LG SmartThinQ constants.""" 2 | 3 | from .backports.enum import StrEnum 4 | 5 | # default core settings 6 | DEFAULT_COUNTRY = "US" 7 | DEFAULT_LANGUAGE = "en-US" 8 | DEFAULT_TIMEOUT = 15 # seconds 9 | 10 | # bit status 11 | BIT_OFF = "OFF" 12 | BIT_ON = "ON" 13 | 14 | 15 | class TemperatureUnit(StrEnum): 16 | """LG ThinQ valid temperature unit.""" 17 | 18 | CELSIUS = "celsius" 19 | FAHRENHEIT = "fahrenheit" 20 | 21 | 22 | class StateOptions(StrEnum): 23 | """LG ThinQ valid states.""" 24 | 25 | NONE = "-" 26 | OFF = "off" 27 | ON = "on" 28 | UNKNOWN = "unknown" 29 | 30 | 31 | class AirConditionerFeatures(StrEnum): 32 | """Features for LG Air Conditioner devices.""" 33 | 34 | ENERGY_CURRENT = "energy_current" 35 | HOT_WATER_TEMP = "hot_water_temperature" 36 | HUMIDITY = "humidity" 37 | FILTER_MAIN_LIFE = "filter_main_life" 38 | FILTER_MAIN_MAX = "filter_main_max" 39 | FILTER_MAIN_USE = "filter_main_use" 40 | LIGHTING_DISPLAY = "lighting_display" 41 | MODE_AIRCLEAN = "mode_airclean" 42 | MODE_AWHP_SILENT = "mode_awhp_silent" 43 | MODE_JET = "mode_jet" 44 | PM1 = "pm1" 45 | PM10 = "pm10" 46 | PM25 = "pm25" 47 | RESERVATION_SLEEP_TIME = "reservation_sleep_time" 48 | ROOM_TEMP = "room_temperature" 49 | WATER_IN_TEMP = "water_in_temperature" 50 | WATER_OUT_TEMP = "water_out_temperature" 51 | 52 | 53 | class AirPurifierFeatures(StrEnum): 54 | """Features for LG Air Purifier devices.""" 55 | 56 | FILTER_BOTTOM_LIFE = "filter_bottom_life" 57 | FILTER_BOTTOM_MAX = "filter_bottom_max" 58 | FILTER_BOTTOM_USE = "filter_bottom_use" 59 | FILTER_DUST_LIFE = "filter_dust_life" 60 | FILTER_DUST_MAX = "filter_dust_max" 61 | FILTER_DUST_USE = "filter_dust_use" 62 | FILTER_MAIN_LIFE = "filter_main_life" 63 | FILTER_MAIN_MAX = "filter_main_max" 64 | FILTER_MAIN_USE = "filter_main_use" 65 | FILTER_MID_LIFE = "filter_mid_life" 66 | FILTER_MID_MAX = "filter_mid_max" 67 | FILTER_MID_USE = "filter_mid_use" 68 | FILTER_TOP_LIFE = "filter_top_life" 69 | FILTER_TOP_MAX = "filter_top_max" 70 | FILTER_TOP_USE = "filter_top_use" 71 | HUMIDITY = "humidity" 72 | PM1 = "pm1" 73 | PM10 = "pm10" 74 | PM25 = "pm25" 75 | 76 | 77 | class DehumidifierFeatures(StrEnum): 78 | """Features for LG Dehumidifier devices.""" 79 | 80 | HUMIDITY = "humidity" 81 | TARGET_HUMIDITY = "target_humidity" 82 | WATER_TANK_FULL = "water_tank_full" 83 | 84 | 85 | class RangeFeatures(StrEnum): 86 | """Features for LG Range devices.""" 87 | 88 | COOKTOP_CENTER_STATE = "cooktop_center_state" 89 | COOKTOP_LEFT_FRONT_STATE = "cooktop_left_front_state" 90 | COOKTOP_LEFT_REAR_STATE = "cooktop_left_rear_state" 91 | COOKTOP_RIGHT_FRONT_STATE = "cooktop_right_front_state" 92 | COOKTOP_RIGHT_REAR_STATE = "cooktop_right_rear_state" 93 | OVEN_LOWER_CURRENT_TEMP = "oven_lower_current_temp" 94 | OVEN_LOWER_MODE = "oven_lower_mode" 95 | OVEN_LOWER_STATE = "oven_lower_state" 96 | OVEN_UPPER_CURRENT_TEMP = "oven_upper_current_temp" 97 | OVEN_UPPER_MODE = "oven_upper_mode" 98 | OVEN_UPPER_STATE = "oven_upper_state" 99 | 100 | 101 | class RefrigeratorFeatures(StrEnum): 102 | """Features for LG Refrigerator devices.""" 103 | 104 | ECOFRIENDLY = "eco_friendly" 105 | EXPRESSMODE = "express_mode" 106 | EXPRESSFRIDGE = "express_fridge" 107 | FRESHAIRFILTER = "fresh_air_filter" 108 | FRESHAIRFILTER_REMAIN_PERC = "fresh_air_filter_remain_perc" 109 | ICEPLUS = "ice_plus" 110 | SMARTSAVINGMODE = "smart_saving_mode" 111 | WATERFILTERUSED_MONTH = "water_filter_used_month" 112 | WATERFILTER_REMAIN_PERC = "water_filter_remain_perc" 113 | 114 | 115 | class WashDeviceFeatures(StrEnum): 116 | """Features for LG Wash devices.""" 117 | 118 | ANTICREASE = "anti_crease" 119 | AUTODOOR = "auto_door" 120 | CHILDLOCK = "child_lock" 121 | CREASECARE = "crease_care" 122 | DAMPDRYBEEP = "damp_dry_beep" 123 | DELAYSTART = "delay_start" 124 | DETERGENT = "detergent" 125 | DETERGENTLOW = "detergent_low" 126 | DOORLOCK = "door_lock" 127 | DOOROPEN = "door_open" 128 | DRYLEVEL = "dry_level" 129 | DUALZONE = "dual_zone" 130 | ECOHYBRID = "eco_hybrid" 131 | ENERGYSAVER = "energy_saver" 132 | ERROR_MSG = "error_message" 133 | EXTRADRY = "extra_dry" 134 | HALFLOAD = "half_load" 135 | HANDIRON = "hand_iron" 136 | HIGHTEMP = "high_temp" 137 | MEDICRINSE = "medic_rinse" 138 | NIGHTDRY = "night_dry" 139 | PRESTEAM = "pre_steam" 140 | PREWASH = "pre_wash" 141 | PRE_STATE = "pre_state" 142 | PROCESS_STATE = "process_state" 143 | REMOTESTART = "remote_start" 144 | RESERVATION = "reservation" 145 | RINSEMODE = "rinse_mode" 146 | RINSEREFILL = "rinse_refill" 147 | RUN_STATE = "run_state" 148 | SALTREFILL = "salt_refill" 149 | SELFCLEAN = "self_clean" 150 | SOFTENER = "softener" 151 | SOFTENERLOW = "softener_low" 152 | SPINSPEED = "spin_speed" 153 | STANDBY = "standby" 154 | STEAM = "steam" 155 | STEAMSOFTENER = "steam_softener" 156 | TEMPCONTROL = "temp_control" 157 | TIMEDRY = "time_dry" 158 | TUBCLEAN_COUNT = "tubclean_count" 159 | TURBOWASH = "turbo_wash" 160 | WATERTEMP = "water_temp" 161 | 162 | 163 | class WaterHeaterFeatures(StrEnum): 164 | """Features for LG Water Heater devices.""" 165 | 166 | ENERGY_CURRENT = "energy_current" 167 | HOT_WATER_TEMP = "hot_water_temperature" 168 | 169 | 170 | class MicroWaveFeatures(StrEnum): 171 | """Features for LG MicroWave devices.""" 172 | 173 | CLOCK_DISPLAY = "clock_display" 174 | DISPLAY_SCROLL_SPEED = "display_scroll_speed" 175 | LIGHT_MODE = "light_mode" 176 | OVEN_UPPER_STATE = "oven_upper_state" 177 | OVEN_UPPER_MODE = "oven_upper_mode" 178 | SOUND = "sound" 179 | VENT_SPEED = "vent_speed" 180 | WEIGHT_UNIT = "weight_unit" 181 | 182 | 183 | class HoodFeatures(StrEnum): 184 | """Features for LG Hood devices.""" 185 | 186 | LIGHT_MODE = "light_mode" 187 | HOOD_STATE = "hood_state" 188 | VENT_SPEED = "vent_speed" 189 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/select.py: -------------------------------------------------------------------------------- 1 | """Support for ThinQ device selects.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | from typing import Any, Awaitable, Callable 8 | 9 | from homeassistant.components.select import SelectEntity, SelectEntityDescription 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant, callback 12 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 13 | from homeassistant.helpers.entity import EntityCategory 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from . import LGEDevice 18 | from .const import DOMAIN, LGE_DEVICES, LGE_DISCOVERY_NEW 19 | from .wideq import WM_DEVICE_TYPES, DeviceType, MicroWaveFeatures 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | @dataclass 25 | class ThinQSelectRequiredKeysMixin: 26 | """Mixin for required keys.""" 27 | 28 | options_fn: Callable[[Any], list[str]] 29 | select_option_fn: Callable[[Any], Awaitable[None]] 30 | 31 | 32 | @dataclass 33 | class ThinQSelectEntityDescription( 34 | SelectEntityDescription, ThinQSelectRequiredKeysMixin 35 | ): 36 | """A class that describes ThinQ select entities.""" 37 | 38 | available_fn: Callable[[Any], bool] | None = None 39 | value_fn: Callable[[Any], str] | None = None 40 | 41 | 42 | WASH_DEV_SELECT: tuple[ThinQSelectEntityDescription, ...] = ( 43 | ThinQSelectEntityDescription( 44 | key="course_selection", 45 | name="Course selection", 46 | icon="mdi:tune-vertical-variant", 47 | options_fn=lambda x: x.device.course_list, 48 | select_option_fn=lambda x, option: x.device.select_start_course(option), 49 | available_fn=lambda x: x.device.select_course_enabled, 50 | value_fn=lambda x: x.device.selected_course, 51 | ), 52 | ) 53 | MICROWAVE_SELECT: tuple[ThinQSelectEntityDescription, ...] = ( 54 | ThinQSelectEntityDescription( 55 | key=MicroWaveFeatures.DISPLAY_SCROLL_SPEED, 56 | name="Display scroll speed", 57 | icon="mdi:format-pilcrow-arrow-right", 58 | entity_category=EntityCategory.CONFIG, 59 | options_fn=lambda x: x.device.display_scroll_speeds, 60 | select_option_fn=lambda x, option: x.device.set_display_scroll_speed(option), 61 | ), 62 | ThinQSelectEntityDescription( 63 | key=MicroWaveFeatures.WEIGHT_UNIT, 64 | name="Weight unit", 65 | icon="mdi:weight", 66 | entity_category=EntityCategory.CONFIG, 67 | options_fn=lambda x: x.device.defrost_weight_units, 68 | select_option_fn=lambda x, option: x.device.set_defrost_weight_unit(option), 69 | ), 70 | ) 71 | 72 | SELECT_ENTITIES = { 73 | DeviceType.MICROWAVE: MICROWAVE_SELECT, 74 | **{dev_type: WASH_DEV_SELECT for dev_type in WM_DEVICE_TYPES}, 75 | } 76 | 77 | 78 | def _select_exist( 79 | lge_device: LGEDevice, select_desc: ThinQSelectEntityDescription 80 | ) -> bool: 81 | """Check if a select exist for device.""" 82 | if select_desc.value_fn is not None: 83 | return True 84 | 85 | feature = select_desc.key 86 | if feature in lge_device.available_features: 87 | return True 88 | 89 | return False 90 | 91 | 92 | async def async_setup_entry( 93 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 94 | ) -> None: 95 | """Set up the LGE selects.""" 96 | entry_config = hass.data[DOMAIN] 97 | lge_cfg_devices = entry_config.get(LGE_DEVICES) 98 | 99 | _LOGGER.debug("Starting LGE ThinQ select setup...") 100 | 101 | @callback 102 | def _async_discover_device(lge_devices: dict) -> None: 103 | """Add entities for a discovered ThinQ device.""" 104 | 105 | if not lge_devices: 106 | return 107 | 108 | lge_select = [ 109 | LGESelect(lge_device, select_desc) 110 | for dev_type, select_descs in SELECT_ENTITIES.items() 111 | for select_desc in select_descs 112 | for lge_device in lge_devices.get(dev_type, []) 113 | if _select_exist(lge_device, select_desc) 114 | ] 115 | 116 | async_add_entities(lge_select) 117 | 118 | _async_discover_device(lge_cfg_devices) 119 | 120 | entry.async_on_unload( 121 | async_dispatcher_connect(hass, LGE_DISCOVERY_NEW, _async_discover_device) 122 | ) 123 | 124 | 125 | class LGESelect(CoordinatorEntity, SelectEntity): 126 | """Class to control selects for LGE device""" 127 | 128 | entity_description: ThinQSelectEntityDescription 129 | _attr_has_entity_name = True 130 | 131 | def __init__( 132 | self, 133 | api: LGEDevice, 134 | description: ThinQSelectEntityDescription, 135 | ): 136 | """Initialize the select.""" 137 | super().__init__(api.coordinator) 138 | self._api = api 139 | self.entity_description = description 140 | self._attr_unique_id = f"{api.unique_id}-{description.key}-select" 141 | self._attr_device_info = api.device_info 142 | self._attr_options = self.entity_description.options_fn(self._api) 143 | 144 | async def async_select_option(self, option: str) -> None: 145 | """Change the selected option.""" 146 | await self.entity_description.select_option_fn(self._api, option) 147 | self._api.async_set_updated() 148 | 149 | @property 150 | def current_option(self) -> str | None: 151 | """Return the selected entity option to represent the entity state.""" 152 | if self.entity_description.value_fn is not None: 153 | return self.entity_description.value_fn(self._api) 154 | 155 | if self._api.state: 156 | feature = self.entity_description.key 157 | return self._api.state.device_features.get(feature) 158 | 159 | return None 160 | 161 | @property 162 | def available(self) -> bool: 163 | """Return True if entity is available.""" 164 | is_avail = True 165 | if self.entity_description.available_fn is not None: 166 | is_avail = self.entity_description.available_fn(self._api) 167 | return self._api.available and is_avail 168 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/devices/fan.py: -------------------------------------------------------------------------------- 1 | """------------------for Fan""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | import logging 7 | 8 | from ..backports.functools import cached_property 9 | from ..core_async import ClientAsync 10 | from ..device import Device, DeviceStatus 11 | from ..device_info import DeviceInfo 12 | 13 | CTRL_BASIC = ["Control", "basicCtrl"] 14 | 15 | SUPPORT_OPERATION_MODE = ["SupportOpMode", "support.airState.opMode"] 16 | SUPPORT_WIND_STRENGTH = ["SupportWindStrength", "support.airState.windStrength"] 17 | 18 | STATE_OPERATION = ["Operation", "airState.operation"] 19 | STATE_OPERATION_MODE = ["OpMode", "airState.opMode"] 20 | STATE_WIND_STRENGTH = ["WindStrength", "airState.windStrength"] 21 | 22 | CMD_STATE_OPERATION = [CTRL_BASIC, "Set", STATE_OPERATION] 23 | CMD_STATE_OP_MODE = [CTRL_BASIC, "Set", STATE_OPERATION_MODE] 24 | CMD_STATE_WIND_STRENGTH = [CTRL_BASIC, "Set", STATE_WIND_STRENGTH] 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | 29 | class FanOp(Enum): 30 | """Whether a device is on or off.""" 31 | 32 | OFF = "@OFF" 33 | ON = "@ON" 34 | 35 | 36 | class FanMode(Enum): 37 | """The operation mode for a Fan device.""" 38 | 39 | NORMAL = "@FAN_MAIN_OPERATION_MODE_NORMAL_W" 40 | 41 | 42 | class FanSpeed(Enum): 43 | """The fan speed for a Fan device.""" 44 | 45 | LOWEST_LOW = "@LOWST_LOW" 46 | LOWEST = "@LOWST" 47 | LOW = "@LOW" 48 | LOW_MID = "@LOW_MED" 49 | MID = "@MED" 50 | MID_HIGH = "@MED_HIGH" 51 | HIGH = "@HIGH" 52 | TURBO = "@TURBO" 53 | 54 | 55 | class FanDevice(Device): 56 | """A higher-level interface for Fan.""" 57 | 58 | def __init__(self, client: ClientAsync, device_info: DeviceInfo): 59 | super().__init__(client, device_info, FanStatus(self)) 60 | 61 | @cached_property 62 | def fan_speeds(self) -> list: 63 | """Available fan speeds.""" 64 | return self._get_property_values(SUPPORT_WIND_STRENGTH, FanSpeed) 65 | 66 | @property 67 | def fan_presets(self) -> list: 68 | """Available fan presets.""" 69 | return [] 70 | 71 | async def power(self, turn_on): 72 | """Turn on or off the device (according to a boolean).""" 73 | 74 | op_mode = FanOp.ON if turn_on else FanOp.OFF 75 | keys = self._get_cmd_keys(CMD_STATE_OPERATION) 76 | op_value = self.model_info.enum_value(keys[2], op_mode.value) 77 | if self._should_poll: 78 | # different power command for ThinQ1 devices 79 | cmd = "Start" if turn_on else "Stop" 80 | await self.set(keys[0], keys[2], key=None, value=cmd) 81 | self._status.update_status(keys[2], op_value) 82 | return 83 | await self.set(keys[0], keys[1], key=keys[2], value=op_value) 84 | 85 | async def set_fan_speed(self, speed): 86 | """Set the fan speed to a value from the `FanSpeed` enum.""" 87 | 88 | if speed not in self.fan_speeds: 89 | raise ValueError(f"Invalid fan speed: {speed}") 90 | keys = self._get_cmd_keys(CMD_STATE_WIND_STRENGTH) 91 | speed_value = self.model_info.enum_value(keys[2], FanSpeed[speed].value) 92 | await self.set(keys[0], keys[1], key=keys[2], value=speed_value) 93 | 94 | async def set_fan_preset(self, preset): 95 | """Set the fan preset to a value from the `FanPreset` enum.""" 96 | 97 | raise ValueError(f"Invalid fan preset: {preset}") 98 | 99 | async def set( 100 | self, ctrl_key, command, *, key=None, value=None, data=None, ctrl_path=None 101 | ): 102 | """Set a device's control for `key` to `value`.""" 103 | await super().set( 104 | ctrl_key, command, key=key, value=value, data=data, ctrl_path=ctrl_path 105 | ) 106 | if key is not None and self._status: 107 | self._status.update_status(key, value) 108 | 109 | def reset_status(self): 110 | self._status = FanStatus(self) 111 | return self._status 112 | 113 | async def poll(self) -> FanStatus | None: 114 | """Poll the device's current state.""" 115 | 116 | res = await self._device_poll() 117 | if not res: 118 | return None 119 | 120 | self._status = FanStatus(self, res) 121 | 122 | return self._status 123 | 124 | 125 | class FanStatus(DeviceStatus): 126 | """Higher-level information about a Fan's current status.""" 127 | 128 | _device: FanDevice 129 | 130 | def __init__(self, device: FanDevice, data: dict | None = None): 131 | """Initialize device status.""" 132 | super().__init__(device, data) 133 | self._operation = None 134 | 135 | def _get_operation(self): 136 | """Get current operation.""" 137 | if self._operation is None: 138 | key = self._get_state_key(STATE_OPERATION) 139 | operation = self.lookup_enum(key, True) 140 | if not operation: 141 | return None 142 | self._operation = operation 143 | try: 144 | return FanOp(self._operation) 145 | except ValueError: 146 | return None 147 | 148 | def update_status(self, key, value): 149 | """Update device status.""" 150 | if not super().update_status(key, value): 151 | return False 152 | if key in STATE_OPERATION: 153 | self._operation = None 154 | return True 155 | 156 | @property 157 | def is_on(self): 158 | """Return if device is on.""" 159 | op_mode = self._get_operation() 160 | if not op_mode: 161 | return False 162 | return op_mode != FanOp.OFF 163 | 164 | @property 165 | def operation(self): 166 | """Return current device operation.""" 167 | op_mode = self._get_operation() 168 | if not op_mode: 169 | return None 170 | return op_mode.name 171 | 172 | @property 173 | def fan_speed(self): 174 | """Return current fan speed.""" 175 | key = self._get_state_key(STATE_WIND_STRENGTH) 176 | if (value := self.lookup_enum(key, True)) is None: 177 | return None 178 | try: 179 | return FanSpeed(value).name 180 | except ValueError: 181 | return None 182 | 183 | @property 184 | def fan_preset(self): 185 | """Return current fan preset.""" 186 | return None 187 | 188 | def _update_features(self): 189 | return 190 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/humidifier.py: -------------------------------------------------------------------------------- 1 | """Platform for LGE humidifier integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | import voluptuous as vol 8 | 9 | from homeassistant.components.humidifier import HumidifierDeviceClass, HumidifierEntity 10 | from homeassistant.components.humidifier.const import ( 11 | DEFAULT_MAX_HUMIDITY, 12 | DEFAULT_MIN_HUMIDITY, 13 | HumidifierEntityFeature, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.core import HomeAssistant, callback 17 | from homeassistant.helpers import config_validation as cv 18 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback, current_platform 20 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 21 | 22 | from . import LGEDevice 23 | from .const import DOMAIN, LGE_DEVICES, LGE_DISCOVERY_NEW 24 | from .wideq import DehumidifierFeatures, DeviceType 25 | from .wideq.devices.dehumidifier import DeHumidifierDevice 26 | 27 | ATTR_CURRENT_HUMIDITY = "current_humidity" 28 | ATTR_FAN_MODE = "fan_mode" 29 | ATTR_FAN_MODES = "fan_modes" 30 | SERVICE_SET_FAN_MODE = "set_fan_mode" 31 | 32 | _LOGGER = logging.getLogger(__name__) 33 | 34 | 35 | async def async_setup_entry( 36 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 37 | ) -> None: 38 | """Set up LGE device humidifier based on config_entry.""" 39 | entry_config = hass.data[DOMAIN] 40 | lge_cfg_devices = entry_config.get(LGE_DEVICES) 41 | 42 | _LOGGER.debug("Starting LGE ThinQ humidifier setup...") 43 | 44 | @callback 45 | def _async_discover_device(lge_devices: dict) -> None: 46 | """Add entities for a discovered ThinQ device.""" 47 | 48 | if not lge_devices: 49 | return 50 | 51 | # DeHumidifier devices 52 | lge_humidifier = [ 53 | LGEDeHumidifier(lge_device) 54 | for lge_device in lge_devices.get(DeviceType.DEHUMIDIFIER, []) 55 | ] 56 | 57 | async_add_entities(lge_humidifier) 58 | 59 | _async_discover_device(lge_cfg_devices) 60 | 61 | entry.async_on_unload( 62 | async_dispatcher_connect(hass, LGE_DISCOVERY_NEW, _async_discover_device) 63 | ) 64 | 65 | # register services 66 | platform = current_platform.get() 67 | platform.async_register_entity_service( 68 | SERVICE_SET_FAN_MODE, 69 | {vol.Required(ATTR_FAN_MODE): cv.string}, 70 | "async_set_fan_mode", 71 | ) 72 | 73 | 74 | class LGEBaseHumidifier(CoordinatorEntity, HumidifierEntity): 75 | """Base humidifier device.""" 76 | 77 | def __init__(self, api: LGEDevice): 78 | """Initialize the humidifier.""" 79 | super().__init__(api.coordinator) 80 | self._api = api 81 | self._attr_device_info = api.device_info 82 | 83 | @property 84 | def available(self) -> bool: 85 | """Return True if entity is available.""" 86 | return self._api.available 87 | 88 | 89 | class LGEDeHumidifier(LGEBaseHumidifier): 90 | """LG DeHumidifier device.""" 91 | 92 | _attr_has_entity_name = True 93 | _attr_name = None 94 | 95 | def __init__(self, api: LGEDevice) -> None: 96 | """Initialize the dehumidifier.""" 97 | super().__init__(api) 98 | self._device: DeHumidifierDevice = api.device 99 | self._attr_unique_id = f"{api.unique_id}-DEHUM" 100 | self._attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER 101 | 102 | self._use_fan_modes = False 103 | self._attr_available_modes = None 104 | if len(self._device.op_modes) > 1: 105 | self._attr_available_modes = self._device.op_modes 106 | elif len(self._device.fan_speeds) > 1: 107 | self._attr_available_modes = self._device.fan_speeds 108 | self._use_fan_modes = True 109 | 110 | @property 111 | def supported_features(self) -> HumidifierEntityFeature: 112 | """Return the list of supported features.""" 113 | if self.available_modes: 114 | return HumidifierEntityFeature.MODES 115 | return HumidifierEntityFeature(0) 116 | 117 | @property 118 | def extra_state_attributes(self): 119 | """Return the optional state attributes with device specific additions.""" 120 | state = {} 121 | if humidity := self._api.state.device_features.get( 122 | DehumidifierFeatures.HUMIDITY 123 | ): 124 | state[ATTR_CURRENT_HUMIDITY] = humidity 125 | if fan_modes := self._device.fan_speeds: 126 | state[ATTR_FAN_MODES] = fan_modes 127 | if fan_mode := self._api.state.fan_speed: 128 | state[ATTR_FAN_MODE] = fan_mode 129 | 130 | return state 131 | 132 | @property 133 | def is_on(self) -> bool | None: 134 | """Return True if entity is on.""" 135 | return self._api.state.is_on 136 | 137 | @property 138 | def mode(self) -> str | None: 139 | """Return current operation.""" 140 | if self._use_fan_modes: 141 | return self._api.state.fan_speed 142 | return self._api.state.operation_mode 143 | 144 | async def async_set_mode(self, mode: str) -> None: 145 | """Set new target mode.""" 146 | if not self.available_modes: 147 | raise NotImplementedError() 148 | if mode not in self.available_modes: 149 | raise ValueError(f"Invalid mode [{mode}]") 150 | if self._use_fan_modes: 151 | await self._device.set_fan_speed(mode) 152 | else: 153 | await self._device.set_op_mode(mode) 154 | self._api.async_set_updated() 155 | 156 | @property 157 | def target_humidity(self) -> int | None: 158 | """Return the humidity we try to reach.""" 159 | return self._api.state.device_features.get(DehumidifierFeatures.TARGET_HUMIDITY) 160 | 161 | async def async_set_humidity(self, humidity: int) -> None: 162 | """Set new target humidity.""" 163 | humidity_step = self._device.target_humidity_step or 1 164 | target_humidity = humidity + (humidity % humidity_step) 165 | await self._device.set_target_humidity(target_humidity) 166 | self._api.async_set_updated() 167 | 168 | async def async_turn_on(self, **kwargs) -> None: 169 | """Turn the entity on.""" 170 | await self._device.power(True) 171 | self._api.async_set_updated() 172 | 173 | async def async_turn_off(self, **kwargs) -> None: 174 | """Turn the entity off.""" 175 | await self._device.power(False) 176 | self._api.async_set_updated() 177 | 178 | @property 179 | def min_humidity(self) -> int: 180 | """Return the minimum humidity.""" 181 | if (min_value := self._device.target_humidity_min) is not None: 182 | return min_value 183 | return DEFAULT_MIN_HUMIDITY 184 | 185 | @property 186 | def max_humidity(self) -> int: 187 | """Return the maximum humidity.""" 188 | if (max_value := self._device.target_humidity_max) is not None: 189 | return max_value 190 | return DEFAULT_MAX_HUMIDITY 191 | 192 | async def async_set_fan_mode(self, fan_mode: str) -> None: 193 | """Set new fan mode.""" 194 | if fan_mode not in self._device.fan_speeds: 195 | raise ValueError(f"Invalid fan mode [{fan_mode}]") 196 | await self._device.set_fan_speed(fan_mode) 197 | self._api.async_set_updated() 198 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/light.py: -------------------------------------------------------------------------------- 1 | """Support for ThinQ light devices.""" 2 | 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass 6 | import logging 7 | from typing import Any, Awaitable, Callable 8 | 9 | from homeassistant.components.light import ( 10 | ATTR_EFFECT, 11 | ColorMode, 12 | LightEntity, 13 | LightEntityDescription, 14 | LightEntityFeature, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.core import HomeAssistant, callback 18 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 21 | 22 | from . import LGEDevice 23 | from .const import DOMAIN, LGE_DEVICES, LGE_DISCOVERY_NEW 24 | from .device_helpers import LGEBaseDevice 25 | from .wideq import DeviceType, HoodFeatures, MicroWaveFeatures 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | @dataclass 31 | class ThinQLightEntityDescription(LightEntityDescription): 32 | """A class that describes ThinQ light entities.""" 33 | 34 | value_fn: Callable[[Any], str] | None = None 35 | effects_fn: Callable[[Any], list[str]] | None = None 36 | set_effect_fn: Callable[[Any], Awaitable[None]] | None = None 37 | turn_off_fn: Callable[[Any], Awaitable[None]] | None = None 38 | turn_on_fn: Callable[[Any], Awaitable[None]] | None = None 39 | 40 | 41 | HOOD_LIGHT: tuple[ThinQLightEntityDescription, ...] = ( 42 | ThinQLightEntityDescription( 43 | key=HoodFeatures.LIGHT_MODE, 44 | name="Light", 45 | effects_fn=lambda x: x.device.light_modes, 46 | set_effect_fn=lambda x, option: x.device.set_light_mode(option), 47 | ), 48 | ) 49 | MICROWAVE_LIGHT: tuple[ThinQLightEntityDescription, ...] = ( 50 | ThinQLightEntityDescription( 51 | key=MicroWaveFeatures.LIGHT_MODE, 52 | name="Light", 53 | effects_fn=lambda x: x.device.light_modes, 54 | set_effect_fn=lambda x, option: x.device.set_light_mode(option), 55 | ), 56 | ) 57 | 58 | LIGHT_ENTITIES = { 59 | DeviceType.HOOD: HOOD_LIGHT, 60 | DeviceType.MICROWAVE: MICROWAVE_LIGHT, 61 | } 62 | 63 | 64 | def _light_exist( 65 | lge_device: LGEDevice, light_desc: ThinQLightEntityDescription 66 | ) -> bool: 67 | """Check if a light exist for device.""" 68 | if light_desc.value_fn is not None: 69 | return True 70 | 71 | feature = light_desc.key 72 | if feature in lge_device.available_features: 73 | return True 74 | 75 | return False 76 | 77 | 78 | async def async_setup_entry( 79 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 80 | ) -> None: 81 | """Set up the LGE selects.""" 82 | entry_config = hass.data[DOMAIN] 83 | lge_cfg_devices = entry_config.get(LGE_DEVICES) 84 | 85 | _LOGGER.debug("Starting LGE ThinQ light setup...") 86 | 87 | @callback 88 | def _async_discover_device(lge_devices: dict) -> None: 89 | """Add entities for a discovered ThinQ device.""" 90 | 91 | if not lge_devices: 92 | return 93 | 94 | lge_light = [ 95 | LGELight(lge_device, light_desc) 96 | for dev_type, light_descs in LIGHT_ENTITIES.items() 97 | for light_desc in light_descs 98 | for lge_device in lge_devices.get(dev_type, []) 99 | if _light_exist(lge_device, light_desc) 100 | ] 101 | 102 | async_add_entities(lge_light) 103 | 104 | _async_discover_device(lge_cfg_devices) 105 | 106 | entry.async_on_unload( 107 | async_dispatcher_connect(hass, LGE_DISCOVERY_NEW, _async_discover_device) 108 | ) 109 | 110 | 111 | class LGELight(CoordinatorEntity, LightEntity): 112 | """Class to control lights for LGE device""" 113 | 114 | entity_description: ThinQLightEntityDescription 115 | _attr_has_entity_name = True 116 | _attr_supported_color_modes = set(ColorMode.ONOFF) 117 | _attr_color_mode = ColorMode.ONOFF 118 | 119 | def __init__( 120 | self, 121 | api: LGEDevice, 122 | description: ThinQLightEntityDescription, 123 | ): 124 | """Initialize the light.""" 125 | super().__init__(api.coordinator) 126 | self._api = api 127 | self._wrap_device = LGEBaseDevice(api) 128 | self.entity_description = description 129 | self._attr_unique_id = f"{api.unique_id}-{description.key}-light" 130 | self._attr_device_info = api.device_info 131 | self._turn_off_effect = None 132 | self._last_effect = None 133 | self._attr_effect_list = self._get_light_effects() 134 | 135 | def _get_light_effects(self) -> list[str]: 136 | """Get available light effects.""" 137 | if self.entity_description.effects_fn is None: 138 | return None 139 | avl_effects = self.entity_description.effects_fn(self._api).copy() 140 | if self.entity_description.turn_off_fn is None: 141 | self._turn_off_effect = avl_effects.pop(0) 142 | return avl_effects 143 | 144 | @property 145 | def available(self) -> bool: 146 | """Return True if entity is available.""" 147 | return self._api.available 148 | 149 | @property 150 | def supported_features(self) -> LightEntityFeature: 151 | """Return the list of supported features.""" 152 | if self.effect_list and len(self.effect_list) > 1: 153 | return LightEntityFeature.EFFECT 154 | return LightEntityFeature(0) 155 | 156 | @property 157 | def effect(self) -> str | None: 158 | """Return the current effect.""" 159 | if self.entity_description.value_fn is not None: 160 | effect = self.entity_description.value_fn(self._api) 161 | else: 162 | effect = self._api.state.device_features.get(self.entity_description.key) 163 | 164 | off_effect = self._turn_off_effect 165 | if not effect or (off_effect and effect == off_effect): 166 | return None 167 | return effect 168 | 169 | @property 170 | def is_on(self) -> bool: 171 | """Return if light is on.""" 172 | if self._turn_off_effect is not None: 173 | return self.effect is not None 174 | return self._api.state.is_on 175 | 176 | async def async_turn_on(self, **kwargs: Any) -> None: 177 | """Turn the entity on.""" 178 | effect = kwargs.get(ATTR_EFFECT) 179 | is_on = self.is_on 180 | if self.entity_description.turn_on_fn is not None: 181 | if not is_on: 182 | await self.entity_description.turn_on_fn(self._api) 183 | elif effect is None: 184 | effect = self._last_effect or self.effect_list[0] 185 | 186 | if not effect and is_on: 187 | return 188 | 189 | if effect and self.entity_description.set_effect_fn: 190 | await self.entity_description.set_effect_fn(self._api, effect) 191 | self._api.async_set_updated() 192 | 193 | async def async_turn_off(self, **kwargs: Any) -> None: 194 | """Turn the entity off.""" 195 | if not self.is_on: 196 | return 197 | effect = self._turn_off_effect 198 | if self.entity_description.turn_off_fn is not None: 199 | await self.entity_description.turn_off_fn(self._api) 200 | elif effect and self.entity_description.set_effect_fn is not None: 201 | self._last_effect = self.effect 202 | await self.entity_description.set_effect_fn(self._api, effect) 203 | else: 204 | raise NotImplementedError() 205 | self._api.async_set_updated() 206 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/devices/styler.py: -------------------------------------------------------------------------------- 1 | """------------------for Styler""" 2 | 3 | from __future__ import annotations 4 | 5 | from ..const import StateOptions, WashDeviceFeatures 6 | from ..core_async import ClientAsync 7 | from ..device import Device, DeviceStatus 8 | from ..device_info import DeviceInfo 9 | 10 | STATE_STYLER_POWER_OFF = "STATE_POWER_OFF" 11 | STATE_STYLER_END = ["STATE_END", "STATE_COMPLETE"] 12 | STATE_STYLER_ERROR_OFF = "OFF" 13 | STATE_STYLER_ERROR_NO_ERROR = [ 14 | "ERROR_NOERROR", 15 | "ERROR_NOERROR_TITLE", 16 | "No Error", 17 | "No_Error", 18 | ] 19 | 20 | BIT_FEATURES = { 21 | WashDeviceFeatures.CHILDLOCK: ["ChildLock", "childLock"], 22 | WashDeviceFeatures.NIGHTDRY: ["NightDry", "nightDry"], 23 | WashDeviceFeatures.REMOTESTART: ["RemoteStart", "remoteStart"], 24 | } 25 | 26 | 27 | class StylerDevice(Device): 28 | """A higher-level interface for a styler.""" 29 | 30 | def __init__(self, client: ClientAsync, device_info: DeviceInfo): 31 | super().__init__(client, device_info, StylerStatus(self)) 32 | 33 | @property 34 | def is_run_completed(self) -> bool: 35 | """Return device run completed state.""" 36 | return self._status.is_run_completed if self._status else False 37 | 38 | def reset_status(self): 39 | self._status = StylerStatus(self) 40 | return self._status 41 | 42 | async def poll(self) -> StylerStatus | None: 43 | """Poll the device's current state.""" 44 | 45 | res = await self._device_poll("styler") 46 | if not res: 47 | return None 48 | 49 | self._status = StylerStatus(self, res) 50 | return self._status 51 | 52 | 53 | class StylerStatus(DeviceStatus): 54 | """ 55 | Higher-level information about a styler's current status. 56 | 57 | :param device: The Device instance. 58 | :param data: JSON data from the API. 59 | """ 60 | 61 | _device: StylerDevice 62 | 63 | def __init__(self, device: StylerDevice, data: dict | None = None): 64 | """Initialize device status.""" 65 | super().__init__(device, data) 66 | self._run_state = None 67 | self._pre_state = None 68 | self._error = None 69 | 70 | def _get_run_state(self): 71 | """Get current run state.""" 72 | if not self._run_state: 73 | state = self.lookup_enum(["State", "state"]) 74 | if not state: 75 | self._run_state = STATE_STYLER_POWER_OFF 76 | else: 77 | self._run_state = state 78 | return self._run_state 79 | 80 | def _get_pre_state(self): 81 | """Get previous run state.""" 82 | if not self._pre_state: 83 | state = self.lookup_enum(["PreState", "preState"]) 84 | if not state: 85 | self._pre_state = STATE_STYLER_POWER_OFF 86 | else: 87 | self._pre_state = state 88 | return self._pre_state 89 | 90 | def _get_error(self): 91 | """Get current error.""" 92 | if not self._error: 93 | error = self.lookup_reference(["Error", "error"], ref_key="title") 94 | if not error: 95 | self._error = STATE_STYLER_ERROR_OFF 96 | else: 97 | self._error = error 98 | return self._error 99 | 100 | def update_status(self, key, value): 101 | """Update device status.""" 102 | if not super().update_status(key, value): 103 | return False 104 | self._run_state = None 105 | return True 106 | 107 | @property 108 | def is_on(self): 109 | """Return if device is on.""" 110 | run_state = self._get_run_state() 111 | return STATE_STYLER_POWER_OFF not in run_state 112 | 113 | @property 114 | def is_run_completed(self): 115 | """Return if run is completed.""" 116 | run_state = self._get_run_state() 117 | pre_state = self._get_pre_state() 118 | if any(state in run_state for state in STATE_STYLER_END) or ( 119 | STATE_STYLER_POWER_OFF in run_state 120 | and any(state in pre_state for state in STATE_STYLER_END) 121 | ): 122 | return True 123 | return False 124 | 125 | @property 126 | def is_error(self): 127 | """Return if an error is present.""" 128 | if not self.is_on: 129 | return False 130 | error = self._get_error() 131 | if error in STATE_STYLER_ERROR_NO_ERROR or error == STATE_STYLER_ERROR_OFF: 132 | return False 133 | return True 134 | 135 | @property 136 | def current_course(self): 137 | """Return current course.""" 138 | if self.is_info_v2: 139 | course_key = self._device.model_info.config_value("courseType") 140 | else: 141 | course_key = ["APCourse", "Course"] 142 | course = self.lookup_reference(course_key, ref_key="name") 143 | return self._device.get_enum_text(course) 144 | 145 | @property 146 | def current_smartcourse(self): 147 | """Return current smartcourse.""" 148 | if self.is_info_v2: 149 | course_key = self._device.model_info.config_value("smartCourseType") 150 | else: 151 | course_key = "SmartCourse" 152 | smart_course = self.lookup_reference(course_key, ref_key="name") 153 | return self._device.get_enum_text(smart_course) 154 | 155 | def _get_time_info(self, keys: list[str]): 156 | """Return time info for specific key.""" 157 | if self.is_info_v2: 158 | if not self.is_on: 159 | return 0 160 | return self.int_or_none(self._data.get(keys[1])) 161 | return self._data.get(keys[0]) 162 | 163 | @property 164 | def initialtime_hour(self): 165 | """Return hour initial time.""" 166 | return self._get_time_info(["Initial_Time_H", "initialTimeHour"]) 167 | 168 | @property 169 | def initialtime_min(self): 170 | """Return minute initial time.""" 171 | return self._get_time_info(["Initial_Time_M", "initialTimeMinute"]) 172 | 173 | @property 174 | def remaintime_hour(self): 175 | """Return hour remaining time.""" 176 | return self._get_time_info(["Remain_Time_H", "remainTimeHour"]) 177 | 178 | @property 179 | def remaintime_min(self): 180 | """Return minute remaining time.""" 181 | return self._get_time_info(["Remain_Time_M", "remainTimeMinute"]) 182 | 183 | @property 184 | def reservetime_hour(self): 185 | """Return hour reserved time.""" 186 | return self._get_time_info(["Reserve_Time_H", "reserveTimeHour"]) 187 | 188 | @property 189 | def reservetime_min(self): 190 | """Return minute reserved time.""" 191 | return self._get_time_info(["Reserve_Time_M", "reserveTimeMinute"]) 192 | 193 | @property 194 | def run_state(self): 195 | """Return current run state.""" 196 | run_state = self._get_run_state() 197 | if STATE_STYLER_POWER_OFF in run_state: 198 | run_state = StateOptions.NONE 199 | return self._update_feature(WashDeviceFeatures.RUN_STATE, run_state) 200 | 201 | @property 202 | def pre_state(self): 203 | """Return previous run state.""" 204 | pre_state = self._get_pre_state() 205 | if STATE_STYLER_POWER_OFF in pre_state: 206 | pre_state = StateOptions.NONE 207 | return self._update_feature(WashDeviceFeatures.PRE_STATE, pre_state) 208 | 209 | @property 210 | def error_msg(self): 211 | """Return current error message.""" 212 | if not self.is_error: 213 | error = StateOptions.NONE 214 | else: 215 | error = self._get_error() 216 | return self._update_feature(WashDeviceFeatures.ERROR_MSG, error) 217 | 218 | def _update_bit_features(self): 219 | """Update features related to bit status.""" 220 | index = 1 if self.is_info_v2 else 0 221 | for feature, keys in BIT_FEATURES.items(): 222 | status = self.lookup_bit(keys[index]) 223 | self._update_feature(feature, status, False) 224 | 225 | def _update_features(self): 226 | _ = [ 227 | self.run_state, 228 | self.pre_state, 229 | self.error_msg, 230 | ] 231 | self._update_bit_features() 232 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/device_info.py: -------------------------------------------------------------------------------- 1 | """Definition for SmartThinQ device type and information.""" 2 | 3 | from enum import Enum 4 | import logging 5 | from typing import Any 6 | 7 | from .const import StateOptions 8 | 9 | KEY_DEVICE_ID = "deviceId" 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | 14 | class DeviceType(Enum): 15 | """The category of device.""" 16 | 17 | REFRIGERATOR = 101 18 | KIMCHI_REFRIGERATOR = 102 19 | WATER_PURIFIER = 103 20 | WASHER = 201 21 | DRYER = 202 22 | STYLER = 203 23 | DISHWASHER = 204 24 | TOWER_WASHER = 221 25 | TOWER_DRYER = 222 26 | TOWER_WASHERDRYER = 223 27 | RANGE = 301 28 | MICROWAVE = 302 29 | COOKTOP = 303 30 | HOOD = 304 31 | AC = 401 32 | AIR_PURIFIER = 402 33 | DEHUMIDIFIER = 403 34 | FAN = 405 35 | WATER_HEATER = 406 36 | AIR_PURIFIER_FAN = 410 37 | ROBOT_VACUUM = 501 38 | STICK_VACUUM = 504 39 | CLOUD_GATEWAY = 603 40 | TV = 701 41 | BOILER = 801 42 | SPEAKER = 901 43 | HOMEVU = 902 44 | ARCH = 1001 45 | MISSG = 3001 46 | SENSOR = 3002 47 | SOLAR_SENSOR = 3102 48 | IOT_LIGHTING = 3003 49 | IOT_MOTION_SENSOR = 3004 50 | IOT_SMART_PLUG = 3005 51 | IOT_DUST_SENSOR = 3006 52 | EMS_AIR_STATION = 4001 53 | AIR_SENSOR = 4003 54 | PURICARE_AIR_DETECTOR = 4004 55 | V2PHONE = 6001 56 | HOMEROBOT = 9000 57 | UNKNOWN = StateOptions.UNKNOWN 58 | 59 | 60 | WM_DEVICE_TYPES = [ 61 | DeviceType.DRYER, 62 | DeviceType.TOWER_DRYER, 63 | DeviceType.TOWER_WASHER, 64 | DeviceType.TOWER_WASHERDRYER, 65 | DeviceType.WASHER, 66 | ] 67 | 68 | WM_COMPLEX_DEVICES = {DeviceType.TOWER_WASHERDRYER: ["washer", "dryer"]} 69 | 70 | SET_TIME_DEVICE_TYPES = [ 71 | DeviceType.MICROWAVE, 72 | ] 73 | 74 | 75 | class PlatformType(Enum): 76 | """The category of device.""" 77 | 78 | THINQ1 = "thinq1" 79 | THINQ2 = "thinq2" 80 | UNKNOWN = StateOptions.UNKNOWN 81 | 82 | 83 | class NetworkType(Enum): 84 | """The type of network.""" 85 | 86 | WIFI = "02" 87 | NFC3 = "03" 88 | NFC4 = "04" 89 | UNKNOWN = StateOptions.UNKNOWN 90 | 91 | 92 | class DeviceInfo: 93 | """ 94 | Details about a user's device. 95 | This is populated from a JSON dictionary provided by the API. 96 | """ 97 | 98 | def __init__(self, data: dict[str, Any]) -> None: 99 | """Initialize the object.""" 100 | self._data = data 101 | self._device_id = None 102 | self._device_type = None 103 | self._platform_type = None 104 | self._network_type = None 105 | 106 | def as_dict(self): 107 | """Return the data dictionary""" 108 | if not self._data: 109 | return {} 110 | return self._data.copy() 111 | 112 | def _get_data_key(self, keys): 113 | """Get valid key from a list of possible keys.""" 114 | for key in keys: 115 | if key in self._data: 116 | return key 117 | return "" 118 | 119 | def _get_data_value(self, key, default: Any = StateOptions.UNKNOWN): 120 | """Get data value for a specific key or list of keys.""" 121 | if isinstance(key, list): 122 | vkey = self._get_data_key(key) 123 | else: 124 | vkey = key 125 | 126 | return self._data.get(vkey, default) 127 | 128 | @property 129 | def model_id(self) -> str: 130 | """Return the model name.""" 131 | return self._get_data_value(["modelName", "modelNm"]) 132 | 133 | @property 134 | def device_id(self) -> str: 135 | """Return the device id.""" 136 | if self._device_id is None: 137 | self._device_id = self._data.get(KEY_DEVICE_ID, StateOptions.UNKNOWN) 138 | return self._device_id 139 | 140 | @property 141 | def name(self) -> str: 142 | """Return the device name.""" 143 | return self._data.get("alias", self.device_id) 144 | 145 | @property 146 | def model_info_url(self) -> str: 147 | """Return the url used to retrieve model info.""" 148 | return self._get_data_value(["modelJsonUrl", "modelJsonUri"], default=None) 149 | 150 | @property 151 | def model_lang_pack_url(self) -> str: 152 | """Return the url used to retrieve model language pack.""" 153 | return self._get_data_value( 154 | ["langPackModelUrl", "langPackModelUri"], default=None 155 | ) 156 | 157 | @property 158 | def product_lang_pack_url(self) -> str: 159 | """Return the url used to retrieve product info.""" 160 | return self._get_data_value( 161 | ["langPackProductTypeUrl", "langPackProductTypeUri"], default=None 162 | ) 163 | 164 | @property 165 | def model_name(self) -> str: 166 | """Return the model name for the device.""" 167 | return self._get_data_value(["modelName", "modelNm"]) 168 | 169 | @property 170 | def macaddress(self) -> str | None: 171 | """Return the device mac address.""" 172 | return self._data.get("macAddress") 173 | 174 | @property 175 | def firmware(self) -> str | None: 176 | """Return the device firmware version.""" 177 | if fw_ver := self._data.get("fwVer"): 178 | return fw_ver 179 | if (fw_ver := self._data.get("modemInfo")) is not None: 180 | if isinstance(fw_ver, dict): 181 | return fw_ver.get("appVersion") 182 | return fw_ver 183 | return None 184 | 185 | @property 186 | def devicestate(self) -> str: 187 | """The kind of device, as a `DeviceType` value.""" 188 | return self._get_data_value("deviceState") 189 | 190 | @property 191 | def isonline(self) -> bool: 192 | """The kind of device, as a `DeviceType` value.""" 193 | return self._data.get("online", False) 194 | 195 | @property 196 | def type(self) -> DeviceType: 197 | """The kind of device, as a `DeviceType` value.""" 198 | if self._device_type is None: 199 | device_type = self._get_data_value("deviceType") 200 | try: 201 | ret_val = DeviceType(device_type) 202 | except ValueError: 203 | _LOGGER.warning( 204 | "Device %s: unknown device type with id %s", 205 | self.device_id, 206 | device_type, 207 | ) 208 | ret_val = DeviceType.UNKNOWN 209 | self._device_type = ret_val 210 | return self._device_type 211 | 212 | @property 213 | def platform_type(self) -> PlatformType: 214 | """The kind of platform, as a `PlatformType` value.""" 215 | if self._platform_type is None: 216 | # for the moment if unavailable set THINQ1, probably not available in APIv1 217 | plat_type = self._data.get("platformType", PlatformType.THINQ1.value) 218 | try: 219 | ret_val = PlatformType(plat_type) 220 | except ValueError: 221 | _LOGGER.warning( 222 | "Device %s: unknown platform type with id %s", 223 | self.device_id, 224 | plat_type, 225 | ) 226 | ret_val = PlatformType.UNKNOWN 227 | self._platform_type = ret_val 228 | return self._platform_type 229 | 230 | @property 231 | def network_type(self) -> NetworkType: 232 | """The kind of network, as a `NetworkType` value.""" 233 | if self._network_type is None: 234 | # for the moment we set WIFI if not available 235 | net_type = self._data.get("networkType", NetworkType.WIFI.value) 236 | try: 237 | ret_val = NetworkType(net_type) 238 | except ValueError: 239 | _LOGGER.warning( 240 | "Device %s: unknown network type with id %s", 241 | self.device_id, 242 | net_type, 243 | ) 244 | # for the moment we set WIFI if unknown 245 | ret_val = NetworkType.WIFI 246 | self._network_type = ret_val 247 | return self._network_type 248 | 249 | @property 250 | def device_state(self) -> str | None: 251 | """Return the status associated to the device.""" 252 | return self._data.get("deviceState") 253 | 254 | @property 255 | def ssid(self) -> str | None: 256 | """Return the network ssid associated to the device.""" 257 | return self._data.get("ssid") 258 | 259 | @property 260 | def snapshot(self) -> dict[str, Any] | None: 261 | """Return the snapshot data associated to the device.""" 262 | return self._data.get("snapshot") 263 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/devices/hood.py: -------------------------------------------------------------------------------- 1 | """------------------for Hood""" 2 | 3 | from __future__ import annotations 4 | 5 | from copy import deepcopy 6 | from enum import Enum 7 | 8 | from ..backports.functools import cached_property 9 | from ..const import BIT_OFF, HoodFeatures, StateOptions 10 | from ..core_async import ClientAsync 11 | from ..device import Device, DeviceStatus 12 | from ..device_info import DeviceInfo 13 | 14 | ITEM_STATE_OFF = "@OV_STATE_INITIAL_W" 15 | 16 | STATE_LAMPLEVEL = "LampLevel" 17 | STATE_VENTLEVEL = "VentLevel" 18 | 19 | CMD_LAMPMODE = "lampOnOff" 20 | CMD_LAMPLEVEL = "lampLevel" 21 | CMD_VENTMODE = "ventOnOff" 22 | CMD_VENTLEVEL = "ventLevel" 23 | CMD_VENTTIMER = "ventTimer" 24 | 25 | CMD_SET_VENTLAMP = "setCookStart" 26 | 27 | KEY_DATASET = "dataSetList" 28 | KEY_HOODSTATE = "hoodState" 29 | 30 | CMD_VENTLAMP_V1_DICT = { 31 | "cmd": "Control", 32 | "cmdOpt": "Operation", 33 | "value": "Start", 34 | "data": "", 35 | } 36 | 37 | CMD_VENTLAMP_V2_DICT = { 38 | "command": "Set", 39 | "ctrlKey": CMD_SET_VENTLAMP, 40 | KEY_DATASET: { 41 | KEY_HOODSTATE: { 42 | "contentType": 34, 43 | "dataLength": 5, 44 | CMD_VENTTIMER: 0, 45 | } 46 | }, 47 | } 48 | 49 | HOOD_CMD = { 50 | CMD_SET_VENTLAMP: CMD_VENTLAMP_V2_DICT, 51 | } 52 | 53 | MODE_ENABLE = "ENABLE" 54 | MODE_DISABLE = "DISABLE" 55 | 56 | 57 | class LightLevel(Enum): 58 | """The light level for a Hood device.""" 59 | 60 | OFF = "0" 61 | LOW = "1" 62 | HIGH = "2" 63 | 64 | 65 | class VentSpeed(Enum): 66 | """The vent speed for a Hood device.""" 67 | 68 | OFF = "0" 69 | LOW = "1" 70 | MID = "2" 71 | HIGH = "3" 72 | TURBO = "4" 73 | MAX = "5" 74 | 75 | 76 | class HoodDevice(Device): 77 | """A higher-level interface for a hood.""" 78 | 79 | def __init__(self, client: ClientAsync, device_info: DeviceInfo): 80 | """Init the device.""" 81 | super().__init__(client, device_info, HoodStatus(self)) 82 | 83 | def reset_status(self): 84 | self._status = HoodStatus(self) 85 | return self._status 86 | 87 | # Settings 88 | def _prepare_command_ventlamp_v1(self, command): 89 | """Prepare vent / lamp command for API V1 devices.""" 90 | if not self._status: 91 | return {} 92 | 93 | status_data = self._status.as_dict 94 | if (vent_level := command.get(CMD_VENTLEVEL)) is None: 95 | vent_level = status_data.get(STATE_VENTLEVEL, "0") 96 | if (lamp_level := command.get(CMD_LAMPLEVEL)) is None: 97 | lamp_level = status_data.get(STATE_LAMPLEVEL, "0") 98 | vent_state = "01" if int(vent_level) != 0 else "00" 99 | lamp_state = "01" if int(lamp_level) != 0 else "00" 100 | data = ( 101 | f"2205{vent_state}{int(vent_level):02d}{lamp_state}{int(lamp_level):02d}00" 102 | ) 103 | 104 | return {**CMD_VENTLAMP_V1_DICT, "data": data} 105 | 106 | def _prepare_command_ventlamp_v2(self): 107 | """Prepare vent / lamp command for API V2 devices.""" 108 | if not self._status: 109 | return {} 110 | 111 | status_data = self._status.as_dict 112 | vent_level = status_data.get(STATE_VENTLEVEL, "0") 113 | lamp_level = status_data.get(STATE_LAMPLEVEL, "0") 114 | return { 115 | CMD_VENTMODE: MODE_ENABLE if int(vent_level) != 0 else MODE_DISABLE, 116 | CMD_VENTLEVEL: int(vent_level), 117 | CMD_LAMPMODE: MODE_ENABLE if int(lamp_level) != 0 else MODE_DISABLE, 118 | CMD_LAMPLEVEL: int(lamp_level), 119 | } 120 | 121 | def _prepare_command_v1(self, ctrl_key, command, key, value): 122 | """ 123 | Prepare command for specific API V1 device. 124 | Overwrite for specific device settings. 125 | """ 126 | if ctrl_key == CMD_SET_VENTLAMP: 127 | return self._prepare_command_ventlamp_v1(command) 128 | return None 129 | 130 | def _prepare_command(self, ctrl_key, command, key, value): 131 | """ 132 | Prepare command for specific device. 133 | Overwrite for specific device settings. 134 | """ 135 | if self._should_poll: 136 | return self._prepare_command_v1(ctrl_key, command, key, value) 137 | 138 | if (cmd_key := HOOD_CMD.get(ctrl_key)) is None: 139 | return None 140 | 141 | if ctrl_key == CMD_SET_VENTLAMP: 142 | full_cmd = self._prepare_command_ventlamp_v2() 143 | else: 144 | full_cmd = {} 145 | 146 | cmd = deepcopy(cmd_key) 147 | def_cmd = cmd[KEY_DATASET].get(KEY_HOODSTATE, {}) 148 | cmd[KEY_DATASET][KEY_HOODSTATE] = {**def_cmd, **full_cmd, **command} 149 | 150 | return cmd 151 | 152 | # Light 153 | @cached_property 154 | def _supported_light_modes(self) -> dict[str, str]: 155 | """Get display scroll speed list.""" 156 | key = self._get_state_key(STATE_LAMPLEVEL) 157 | if not (mapping := self.model_info.enum_range_values(key)): 158 | return {} 159 | mode_list = [e.value for e in LightLevel] 160 | return {LightLevel(k).name: k for k in mapping if k in mode_list} 161 | 162 | @property 163 | def light_modes(self) -> list[str]: 164 | """Get display scroll speed list.""" 165 | return list(self._supported_light_modes) 166 | 167 | async def set_light_mode(self, mode: str): 168 | """Set light mode.""" 169 | if mode not in self.light_modes: 170 | raise ValueError(f"Invalid light mode: {mode}") 171 | 172 | level = self._supported_light_modes[mode] 173 | status = MODE_ENABLE if level != "0" else MODE_DISABLE 174 | cmd = {CMD_LAMPMODE: status, CMD_LAMPLEVEL: int(level)} 175 | 176 | await self.set_val(CMD_SET_VENTLAMP, cmd, key=STATE_LAMPLEVEL, value=level) 177 | 178 | # Vent 179 | @cached_property 180 | def _supported_vent_speeds(self) -> dict[str, str]: 181 | """Get vent speed.""" 182 | key = self._get_state_key(STATE_VENTLEVEL) 183 | if not (mapping := self.model_info.enum_range_values(key)): 184 | return {} 185 | mode_list = [e.value for e in VentSpeed] 186 | return {VentSpeed(k).name: k for k in mapping if k in mode_list} 187 | 188 | @property 189 | def vent_speeds(self) -> list[str]: 190 | """Get vent speed list.""" 191 | return list(self._supported_vent_speeds) 192 | 193 | async def set_vent_speed(self, option: str): 194 | """Set vent speed.""" 195 | if option not in self.vent_speeds: 196 | raise ValueError(f"Invalid vent mode: {option}") 197 | 198 | level = self._supported_vent_speeds[option] 199 | mode = MODE_ENABLE if level != "0" else MODE_DISABLE 200 | cmd = {CMD_VENTMODE: mode, CMD_VENTLEVEL: int(level)} 201 | 202 | await self.set_val(CMD_SET_VENTLAMP, cmd, key=STATE_VENTLEVEL, value=level) 203 | 204 | async def set_val(self, ctrl_key, command, key=None, value=None): 205 | """Set a device's control for hood and update status.""" 206 | await self.set(ctrl_key, command) 207 | if self._status and key is not None: 208 | self._status.update_status(key, value) 209 | 210 | async def poll(self) -> HoodStatus | None: 211 | """Poll the device's current state.""" 212 | res = await self._device_poll() 213 | if not res: 214 | return None 215 | 216 | self._status = HoodStatus(self, res) 217 | return self._status 218 | 219 | 220 | class HoodStatus(DeviceStatus): 221 | """ 222 | Higher-level information about a hood current status. 223 | 224 | :param device: The Device instance. 225 | :param data: JSON data from the API. 226 | """ 227 | 228 | _device: HoodDevice 229 | 230 | @property 231 | def hood_state(self): 232 | """Return hood state.""" 233 | status = self.lookup_enum("HoodState") 234 | if status is None: 235 | return None 236 | if status == ITEM_STATE_OFF: 237 | status = BIT_OFF 238 | return self._update_feature(HoodFeatures.HOOD_STATE, status) 239 | 240 | @property 241 | def is_on(self): 242 | """Return if device is on.""" 243 | res = self.device_features.get(HoodFeatures.HOOD_STATE) 244 | if res and res != StateOptions.OFF: 245 | return True 246 | return False 247 | 248 | @property 249 | def light_mode(self): 250 | """Get light mode.""" 251 | if (value := self.lookup_range(STATE_LAMPLEVEL)) is None: 252 | return None 253 | try: 254 | status = LightLevel(value).name 255 | except ValueError: 256 | return None 257 | return self._update_feature(HoodFeatures.LIGHT_MODE, status, False) 258 | 259 | @property 260 | def vent_speed(self): 261 | """Get vent speed.""" 262 | if (value := self.lookup_range(STATE_VENTLEVEL)) is None: 263 | return None 264 | try: 265 | status = VentSpeed(value).name 266 | except ValueError: 267 | return None 268 | return self._update_feature(HoodFeatures.VENT_SPEED, status, False) 269 | 270 | def _update_features(self): 271 | _ = [ 272 | self.hood_state, 273 | self.light_mode, 274 | self.vent_speed, 275 | ] 276 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/devices/dishwasher.py: -------------------------------------------------------------------------------- 1 | """------------------for DishWasher""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from ..const import StateOptions, WashDeviceFeatures 8 | from ..core_async import ClientAsync 9 | from ..device import Device, DeviceStatus 10 | from ..device_info import DeviceInfo 11 | 12 | STATE_DISHWASHER_POWER_OFF = "STATE_POWER_OFF" 13 | STATE_DISHWASHER_END = ["STATE_END", "STATE_COMPLETE"] 14 | STATE_DISHWASHER_ERROR_OFF = "OFF" 15 | STATE_DISHWASHER_ERROR_NO_ERROR = [ 16 | "ERROR_NOERROR", 17 | "ERROR_NOERROR_TITLE", 18 | "No Error", 19 | "No_Error", 20 | ] 21 | 22 | BIT_FEATURES = { 23 | WashDeviceFeatures.AUTODOOR: ["AutoDoor", "autoDoor"], 24 | WashDeviceFeatures.CHILDLOCK: ["ChildLock", "childLock"], 25 | WashDeviceFeatures.DELAYSTART: ["DelayStart", "delayStart"], 26 | WashDeviceFeatures.DOOROPEN: ["Door", "door"], 27 | WashDeviceFeatures.DUALZONE: ["DualZone", "dualZone"], 28 | WashDeviceFeatures.ENERGYSAVER: ["EnergySaver", "energySaver"], 29 | WashDeviceFeatures.EXTRADRY: ["ExtraDry", "extraDry"], 30 | WashDeviceFeatures.HIGHTEMP: ["HighTemp", "highTemp"], 31 | WashDeviceFeatures.NIGHTDRY: ["NightDry", "nightDry"], 32 | WashDeviceFeatures.PRESTEAM: ["PreSteam", "preSteam"], 33 | WashDeviceFeatures.RINSEREFILL: ["RinseRefill", "rinseRefill"], 34 | WashDeviceFeatures.SALTREFILL: ["SaltRefill", "saltRefill"], 35 | WashDeviceFeatures.STEAM: ["Steam", "steam"], 36 | } 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | 41 | class DishWasherDevice(Device): 42 | """A higher-level interface for a dishwasher.""" 43 | 44 | def __init__(self, client: ClientAsync, device_info: DeviceInfo): 45 | super().__init__(client, device_info, DishWasherStatus(self)) 46 | 47 | @property 48 | def is_run_completed(self) -> bool: 49 | """Return device run completed state.""" 50 | return self._status.is_run_completed if self._status else False 51 | 52 | def reset_status(self): 53 | self._status = DishWasherStatus(self) 54 | return self._status 55 | 56 | async def poll(self) -> DishWasherStatus | None: 57 | """Poll the device's current state.""" 58 | 59 | res = await self._device_poll("dishwasher") 60 | if not res: 61 | return None 62 | 63 | self._status = DishWasherStatus(self, res) 64 | return self._status 65 | 66 | 67 | class DishWasherStatus(DeviceStatus): 68 | """ 69 | Higher-level information about a dishwasher's current status. 70 | 71 | :param device: The Device instance. 72 | :param data: JSON data from the API. 73 | """ 74 | 75 | _device: DishWasherDevice 76 | 77 | def __init__(self, device: DishWasherDevice, data: dict | None = None): 78 | """Initialize device status.""" 79 | super().__init__(device, data) 80 | self._run_state = None 81 | self._process = None 82 | self._error = None 83 | 84 | def _get_run_state(self): 85 | """Get current run state.""" 86 | if not self._run_state: 87 | state = self.lookup_enum(["State", "state"]) 88 | if not state: 89 | self._run_state = STATE_DISHWASHER_POWER_OFF 90 | else: 91 | self._run_state = state 92 | return self._run_state 93 | 94 | def _get_process(self): 95 | """Get current process.""" 96 | if not self._process: 97 | process = self.lookup_enum(["Process", "process"]) 98 | if not process: 99 | self._process = StateOptions.NONE 100 | else: 101 | self._process = process 102 | return self._process 103 | 104 | def _get_error(self): 105 | """Get current error.""" 106 | if not self._error: 107 | error = self.lookup_reference(["Error", "error"], ref_key="title") 108 | if not error: 109 | self._error = STATE_DISHWASHER_ERROR_OFF 110 | else: 111 | self._error = error 112 | return self._error 113 | 114 | @property 115 | def is_on(self): 116 | """Return if device is on.""" 117 | run_state = self._get_run_state() 118 | return STATE_DISHWASHER_POWER_OFF not in run_state 119 | 120 | @property 121 | def is_run_completed(self): 122 | """Return if run is completed.""" 123 | run_state = self._get_run_state() 124 | process = self._get_process() 125 | if any(state in run_state for state in STATE_DISHWASHER_END) or ( 126 | STATE_DISHWASHER_POWER_OFF in run_state 127 | and any(state in process for state in STATE_DISHWASHER_END) 128 | ): 129 | return True 130 | return False 131 | 132 | @property 133 | def is_error(self): 134 | """Return if an error is present.""" 135 | if not self.is_on: 136 | return False 137 | error = self._get_error() 138 | if ( 139 | error in STATE_DISHWASHER_ERROR_NO_ERROR 140 | or error == STATE_DISHWASHER_ERROR_OFF 141 | ): 142 | return False 143 | return True 144 | 145 | @property 146 | def current_course(self): 147 | """Return current course.""" 148 | if self.is_info_v2: 149 | course_key = self._device.model_info.config_value("courseType") 150 | else: 151 | course_key = ["APCourse", "Course"] 152 | course = self.lookup_reference(course_key, ref_key="name") 153 | return self._device.get_enum_text(course) 154 | 155 | @property 156 | def current_smartcourse(self): 157 | """Return current smartcourse.""" 158 | if self.is_info_v2: 159 | course_key = self._device.model_info.config_value("smartCourseType") 160 | else: 161 | course_key = "SmartCourse" 162 | smart_course = self.lookup_reference(course_key, ref_key="name") 163 | return self._device.get_enum_text(smart_course) 164 | 165 | def _get_time_info(self, keys: list[str]): 166 | """Return time info for specific key.""" 167 | if self.is_info_v2: 168 | if not self.is_on: 169 | return 0 170 | return self.int_or_none(self._data.get(keys[1])) 171 | return self._data.get(keys[0]) 172 | 173 | @property 174 | def initialtime_hour(self): 175 | """Return hour initial time.""" 176 | return self._get_time_info(["Initial_Time_H", "initialTimeHour"]) 177 | 178 | @property 179 | def initialtime_min(self): 180 | """Return minute initial time.""" 181 | return self._get_time_info(["Initial_Time_M", "initialTimeMinute"]) 182 | 183 | @property 184 | def remaintime_hour(self): 185 | """Return hour remaining time.""" 186 | return self._get_time_info(["Remain_Time_H", "remainTimeHour"]) 187 | 188 | @property 189 | def remaintime_min(self): 190 | """Return minute remaining time.""" 191 | return self._get_time_info(["Remain_Time_M", "remainTimeMinute"]) 192 | 193 | @property 194 | def reservetime_hour(self): 195 | """Return hour reserved time.""" 196 | return self._get_time_info(["Reserve_Time_H", "reserveTimeHour"]) 197 | 198 | @property 199 | def reservetime_min(self): 200 | """Return minute reserved time.""" 201 | return self._get_time_info(["Reserve_Time_M", "reserveTimeMinute"]) 202 | 203 | @property 204 | def run_state(self): 205 | """Return current run state.""" 206 | run_state = self._get_run_state() 207 | if STATE_DISHWASHER_POWER_OFF in run_state: 208 | run_state = StateOptions.NONE 209 | return self._update_feature(WashDeviceFeatures.RUN_STATE, run_state) 210 | 211 | @property 212 | def process_state(self): 213 | """Return current process state.""" 214 | process = self._get_process() 215 | if not self.is_on: 216 | process = StateOptions.NONE 217 | return self._update_feature(WashDeviceFeatures.PROCESS_STATE, process) 218 | 219 | @property 220 | def halfload_state(self): 221 | """Return half load state.""" 222 | if self.is_info_v2: 223 | half_load = self.lookup_bit_enum("halfLoad") 224 | else: 225 | half_load = self.lookup_bit_enum("HalfLoad") 226 | if not half_load: 227 | half_load = StateOptions.NONE 228 | return self._update_feature(WashDeviceFeatures.HALFLOAD, half_load) 229 | 230 | @property 231 | def error_msg(self): 232 | """Return current error message.""" 233 | if not self.is_error: 234 | error = StateOptions.NONE 235 | else: 236 | error = self._get_error() 237 | return self._update_feature(WashDeviceFeatures.ERROR_MSG, error) 238 | 239 | @property 240 | def tubclean_count(self): 241 | """Return tub clean counter.""" 242 | if self.is_info_v2: 243 | result = DeviceStatus.int_or_none(self._data.get("tclCount")) 244 | else: 245 | result = self._data.get("TclCount") 246 | if result is None: 247 | result = "N/A" 248 | return self._update_feature(WashDeviceFeatures.TUBCLEAN_COUNT, result, False) 249 | 250 | def _update_bit_features(self): 251 | """Update features related to bit status.""" 252 | index = 1 if self.is_info_v2 else 0 253 | for feature, keys in BIT_FEATURES.items(): 254 | status = self.lookup_bit(keys[index]) 255 | self._update_feature(feature, status, False) 256 | 257 | def _update_features(self): 258 | _ = [ 259 | self.run_state, 260 | self.process_state, 261 | self.halfload_state, 262 | self.error_msg, 263 | self.tubclean_count, 264 | ] 265 | self._update_bit_features() 266 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/water_heater.py: -------------------------------------------------------------------------------- 1 | """Platform for LGE water heater integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import Any 7 | 8 | from homeassistant.components.water_heater import ( 9 | STATE_ECO, 10 | STATE_HEAT_PUMP, 11 | STATE_PERFORMANCE, 12 | WaterHeaterEntity, 13 | WaterHeaterEntityFeature, 14 | ) 15 | from homeassistant.config_entries import ConfigEntry 16 | from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature 17 | from homeassistant.core import HomeAssistant, callback 18 | from homeassistant.helpers.dispatcher import async_dispatcher_connect 19 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 20 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 21 | 22 | from . import LGEDevice 23 | from .const import DOMAIN, LGE_DEVICES, LGE_DISCOVERY_NEW 24 | from .wideq import ( 25 | AirConditionerFeatures, 26 | DeviceType, 27 | TemperatureUnit, 28 | WaterHeaterFeatures, 29 | ) 30 | from .wideq.devices.ac import AWHP_MAX_TEMP, AWHP_MIN_TEMP, AirConditionerDevice 31 | from .wideq.devices.waterheater import ( 32 | DEFAULT_MAX_TEMP as WH_MAX_TEMP, 33 | DEFAULT_MIN_TEMP as WH_MIN_TEMP, 34 | WaterHeaterDevice, 35 | WHMode, 36 | ) 37 | 38 | LGEAC_SUPPORT_FLAGS = ( 39 | WaterHeaterEntityFeature.TARGET_TEMPERATURE 40 | | WaterHeaterEntityFeature.OPERATION_MODE 41 | ) 42 | 43 | LGEWH_AWAY_MODE = WHMode.VACATION.name 44 | LGEWH_STATE_TO_HA = { 45 | WHMode.AUTO.name: STATE_ECO, 46 | WHMode.HEAT_PUMP.name: STATE_HEAT_PUMP, 47 | WHMode.TURBO.name: STATE_PERFORMANCE, 48 | WHMode.VACATION.name: STATE_OFF, 49 | } 50 | 51 | _LOGGER = logging.getLogger(__name__) 52 | 53 | 54 | async def async_setup_entry( 55 | hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback 56 | ) -> None: 57 | """Set up LGE device water heater based on config_entry.""" 58 | entry_config = hass.data[DOMAIN] 59 | lge_cfg_devices = entry_config.get(LGE_DEVICES) 60 | 61 | _LOGGER.debug("Starting LGE ThinQ water heater setup...") 62 | 63 | @callback 64 | def _async_discover_device(lge_devices: dict) -> None: 65 | """Add entities for a discovered ThinQ device.""" 66 | 67 | if not lge_devices: 68 | return 69 | 70 | # WH devices 71 | lge_water_heater = [ 72 | LGEWHWaterHeater(lge_device) 73 | for lge_device in lge_devices.get(DeviceType.WATER_HEATER, []) 74 | ] 75 | 76 | # AC devices 77 | lge_water_heater.extend( 78 | [ 79 | LGEACWaterHeater(lge_device) 80 | for lge_device in lge_devices.get(DeviceType.AC, []) 81 | if lge_device.device.is_water_heater_supported 82 | ] 83 | ) 84 | 85 | async_add_entities(lge_water_heater) 86 | 87 | _async_discover_device(lge_cfg_devices) 88 | 89 | entry.async_on_unload( 90 | async_dispatcher_connect(hass, LGE_DISCOVERY_NEW, _async_discover_device) 91 | ) 92 | 93 | 94 | class LGEWaterHeater(CoordinatorEntity, WaterHeaterEntity): 95 | """Base water heater device.""" 96 | 97 | def __init__(self, api: LGEDevice): 98 | """Initialize the climate.""" 99 | super().__init__(api.coordinator) 100 | self._api = api 101 | self._attr_device_info = api.device_info 102 | 103 | @property 104 | def available(self) -> bool: 105 | """Return True if entity is available.""" 106 | return self._api.available 107 | 108 | 109 | class LGEWHWaterHeater(LGEWaterHeater): 110 | """LGE AWHP water heater.""" 111 | 112 | _attr_has_entity_name = True 113 | _attr_name = None 114 | 115 | def __init__(self, api: LGEDevice) -> None: 116 | """Initialize the device.""" 117 | super().__init__(api) 118 | self._device: WaterHeaterDevice = api.device 119 | self._attr_unique_id = f"{api.unique_id}-WH" 120 | self._supported_features = None 121 | self._modes_lookup = None 122 | 123 | def _available_modes(self) -> dict[str, str]: 124 | """Return available modes from lookup dict.""" 125 | if self._modes_lookup is None: 126 | self._modes_lookup = { 127 | key: mode 128 | for key, mode in LGEWH_STATE_TO_HA.items() 129 | if key in self._device.op_modes 130 | } 131 | return self._modes_lookup 132 | 133 | @property 134 | def supported_features(self) -> WaterHeaterEntityFeature: 135 | """Return the list of supported features.""" 136 | if self._supported_features is None: 137 | features = WaterHeaterEntityFeature.TARGET_TEMPERATURE 138 | if self.operation_list is not None: 139 | features |= WaterHeaterEntityFeature.OPERATION_MODE 140 | self._supported_features = features 141 | return self._supported_features 142 | 143 | @property 144 | def temperature_unit(self) -> str: 145 | """Return the unit of measurement used by the platform.""" 146 | if self._device.temperature_unit == TemperatureUnit.FAHRENHEIT: 147 | return UnitOfTemperature.FAHRENHEIT 148 | return UnitOfTemperature.CELSIUS 149 | 150 | @property 151 | def current_operation(self) -> str | None: 152 | """Return current operation.""" 153 | op_mode: str | None = self._api.state.operation_mode 154 | if op_mode is None: 155 | return STATE_OFF 156 | modes = self._available_modes() 157 | return modes.get(op_mode) 158 | 159 | @property 160 | def operation_list(self) -> list[str] | None: 161 | """Return the list of available hvac operation modes.""" 162 | if not (modes := self._available_modes()): 163 | return None 164 | return list(modes.values()) 165 | 166 | async def async_set_temperature(self, **kwargs) -> None: 167 | """Set new target temperature.""" 168 | if new_temp := kwargs.get(ATTR_TEMPERATURE): 169 | await self._device.set_target_temp(int(new_temp)) 170 | self._api.async_set_updated() 171 | 172 | async def async_set_operation_mode(self, operation_mode: str) -> None: 173 | """Set operation mode.""" 174 | modes = self._available_modes() 175 | reverse_lookup = {v: k for k, v in modes.items()} 176 | if (new_mode := reverse_lookup.get(operation_mode)) is None: 177 | raise ValueError(f"Invalid operation_mode [{operation_mode}]") 178 | await self._device.set_op_mode(new_mode) 179 | self._api.async_set_updated() 180 | 181 | async def async_turn_on(self, **kwargs: Any) -> None: 182 | """Turn the water heater on.""" 183 | await self.async_set_operation_mode(STATE_HEAT_PUMP) 184 | 185 | async def async_turn_off(self, **kwargs: Any) -> None: 186 | """Turn the water heater off.""" 187 | await self.async_set_operation_mode(STATE_OFF) 188 | 189 | @property 190 | def current_temperature(self) -> float | None: 191 | """Return the current temperature.""" 192 | return self._api.state.device_features.get(WaterHeaterFeatures.HOT_WATER_TEMP) 193 | 194 | @property 195 | def target_temperature(self) -> float | None: 196 | """Return the temperature we try to reach.""" 197 | return self._api.state.target_temp 198 | 199 | @property 200 | def min_temp(self) -> float: 201 | """Return the minimum temperature.""" 202 | if (min_value := self._device.target_temperature_min) is not None: 203 | return min_value 204 | return self._device.conv_temp_unit(WH_MIN_TEMP) 205 | 206 | @property 207 | def max_temp(self) -> float: 208 | """Return the maximum temperature.""" 209 | if (max_value := self._device.target_temperature_max) is not None: 210 | return max_value 211 | return self._device.conv_temp_unit(WH_MAX_TEMP) 212 | 213 | 214 | class LGEACWaterHeater(LGEWaterHeater): 215 | """LGE AWHP water heater AC device based.""" 216 | 217 | _attr_has_entity_name = True 218 | _attr_name = "Water Heater" 219 | 220 | def __init__(self, api: LGEDevice) -> None: 221 | """Initialize the device.""" 222 | super().__init__(api) 223 | self._device: AirConditionerDevice = api.device 224 | self._attr_unique_id = f"{api.unique_id}-AC-WH" 225 | self._attr_supported_features = LGEAC_SUPPORT_FLAGS 226 | self._attr_operation_list = [STATE_OFF, STATE_HEAT_PUMP] 227 | # self._attr_precision = self._device.hot_water_target_temperature_step 228 | 229 | @property 230 | def temperature_unit(self) -> str: 231 | """Return the unit of measurement used by the platform.""" 232 | if self._device.temperature_unit == TemperatureUnit.FAHRENHEIT: 233 | return UnitOfTemperature.FAHRENHEIT 234 | return UnitOfTemperature.CELSIUS 235 | 236 | @property 237 | def current_operation(self) -> str | None: 238 | """Return current operation.""" 239 | if self._api.state.is_hot_water_on: 240 | return STATE_HEAT_PUMP 241 | return STATE_OFF 242 | 243 | async def async_set_temperature(self, **kwargs) -> None: 244 | """Set new target temperature.""" 245 | if new_temp := kwargs.get(ATTR_TEMPERATURE): 246 | await self._device.set_hot_water_target_temp(int(new_temp)) 247 | self._api.async_set_updated() 248 | 249 | async def async_set_operation_mode(self, operation_mode: str) -> None: 250 | """Set operation mode.""" 251 | if operation_mode not in self.operation_list: 252 | raise ValueError(f"Invalid operation mode [{operation_mode}]") 253 | if operation_mode == self.current_operation: 254 | return 255 | await self._device.hot_water_mode(operation_mode == STATE_HEAT_PUMP) 256 | self._api.async_set_updated() 257 | 258 | async def async_turn_on(self, **kwargs: Any) -> None: 259 | """Turn the water heater on.""" 260 | await self.async_set_operation_mode(STATE_HEAT_PUMP) 261 | 262 | async def async_turn_off(self, **kwargs: Any) -> None: 263 | """Turn the water heater off.""" 264 | await self.async_set_operation_mode(STATE_OFF) 265 | 266 | @property 267 | def current_temperature(self) -> float | None: 268 | """Return the current temperature.""" 269 | return self._api.state.device_features.get( 270 | AirConditionerFeatures.HOT_WATER_TEMP 271 | ) 272 | 273 | @property 274 | def target_temperature(self) -> float | None: 275 | """Return the temperature we try to reach.""" 276 | return self._api.state.hot_water_target_temp 277 | 278 | @property 279 | def min_temp(self) -> float: 280 | """Return the minimum temperature.""" 281 | if (min_value := self._device.hot_water_target_temperature_min) is not None: 282 | return min_value 283 | return self._device.conv_temp_unit(AWHP_MIN_TEMP) 284 | 285 | @property 286 | def max_temp(self) -> float: 287 | """Return the maximum temperature.""" 288 | if (max_value := self._device.hot_water_target_temperature_max) is not None: 289 | return max_value 290 | return self._device.conv_temp_unit(AWHP_MAX_TEMP) 291 | -------------------------------------------------------------------------------- /custom_components/smartthinq_sensors/wideq/devices/waterheater.py: -------------------------------------------------------------------------------- 1 | """------------------for WATER HEATER""" 2 | 3 | from __future__ import annotations 4 | 5 | from enum import Enum 6 | 7 | from ..backports.functools import cached_property 8 | from ..const import TemperatureUnit, WaterHeaterFeatures 9 | from ..core_async import ClientAsync 10 | from ..core_exceptions import InvalidRequestError 11 | from ..core_util import TempUnitConversion 12 | from ..device import Device, DeviceStatus 13 | from ..device_info import DeviceInfo 14 | 15 | CTRL_BASIC = ["Control", "basicCtrl"] 16 | 17 | STATE_POWER_V1 = "InOutInstantPower" 18 | 19 | SUPPORT_OPERATION_MODE = ["SupportOpModeExt2", "support.airState.opModeExt2"] 20 | 21 | # AC Section 22 | STATE_OPERATION = ["Operation", "airState.operation"] 23 | STATE_OPERATION_MODE = ["OpMode", "airState.opMode"] 24 | STATE_CURRENT_TEMP = ["TempCur", "airState.tempState.hotWaterCurrent"] 25 | STATE_TARGET_TEMP = ["TempCfg", "airState.tempState.hotWaterTarget"] 26 | STATE_POWER = [STATE_POWER_V1, "airState.energy.onCurrent"] 27 | 28 | CMD_STATE_OP_MODE = [CTRL_BASIC, "Set", STATE_OPERATION_MODE] 29 | CMD_STATE_TARGET_TEMP = [CTRL_BASIC, "Set", STATE_TARGET_TEMP] 30 | 31 | CMD_ENABLE_EVENT_V2 = ["allEventEnable", "Set", "airState.mon.timeout"] 32 | 33 | DEFAULT_MIN_TEMP = 35 34 | DEFAULT_MAX_TEMP = 60 35 | 36 | TEMP_STEP_WHOLE = 1.0 37 | TEMP_STEP_HALF = 0.5 38 | 39 | ADD_FEAT_POLL_INTERVAL = 300 # 5 minutes 40 | 41 | 42 | class ACOp(Enum): 43 | """Whether a device is on or off.""" 44 | 45 | OFF = "@AC_MAIN_OPERATION_OFF_W" 46 | ON = "@AC_MAIN_OPERATION_ON_W" 47 | RIGHT_ON = "@AC_MAIN_OPERATION_RIGHT_ON_W" # Right fan only. 48 | LEFT_ON = "@AC_MAIN_OPERATION_LEFT_ON_W" # Left fan only. 49 | ALL_ON = "@AC_MAIN_OPERATION_ALL_ON_W" # Both fans (or only fan) on. 50 | 51 | 52 | class WHMode(Enum): 53 | """The operation mode for an WH device.""" 54 | 55 | HEAT_PUMP = "@WH_MODE_HEAT_PUMP_W" 56 | AUTO = "@WH_MODE_AUTO_W" 57 | TURBO = "@WH_MODE_TURBO_W" 58 | VACATION = "@WH_MODE_VACATION_W" 59 | 60 | 61 | class WaterHeaterDevice(Device): 62 | """A higher-level interface for a Water Heater.""" 63 | 64 | def __init__( 65 | self, 66 | client: ClientAsync, 67 | device_info: DeviceInfo, 68 | temp_unit=TemperatureUnit.CELSIUS, 69 | ): 70 | """Initialize WaterHeaterDevice object.""" 71 | super().__init__(client, device_info, WaterHeaterStatus(self)) 72 | self._temperature_unit = ( 73 | TemperatureUnit.FAHRENHEIT 74 | if temp_unit == TemperatureUnit.FAHRENHEIT 75 | else TemperatureUnit.CELSIUS 76 | ) 77 | 78 | self._current_power = 0 79 | self._current_power_supported = True 80 | 81 | self._unit_conv = TempUnitConversion() 82 | 83 | def _f2c(self, value): 84 | """Convert Fahrenheit to Celsius temperatures for this device if required.""" 85 | if self._temperature_unit == TemperatureUnit.CELSIUS: 86 | return value 87 | return self._unit_conv.f2c(value, self.model_info) 88 | 89 | def conv_temp_unit(self, value): 90 | """Convert Celsius to Fahrenheit temperatures for this device if required.""" 91 | if self._temperature_unit == TemperatureUnit.CELSIUS: 92 | return float(value) 93 | return self._unit_conv.c2f(value, self.model_info) 94 | 95 | @cached_property 96 | def _temperature_range(self): 97 | """Get valid temperature range for model.""" 98 | key = self._get_state_key(STATE_TARGET_TEMP) 99 | range_info = self.model_info.value(key) 100 | if not range_info: 101 | min_temp = DEFAULT_MIN_TEMP 102 | max_temp = DEFAULT_MAX_TEMP 103 | else: 104 | min_temp = min(range_info.min, DEFAULT_MIN_TEMP) 105 | max_temp = max(range_info.max, DEFAULT_MAX_TEMP) 106 | return [min_temp, max_temp] 107 | 108 | @cached_property 109 | def op_modes(self): 110 | """Return a list of available operation modes.""" 111 | return self._get_property_values(SUPPORT_OPERATION_MODE, WHMode) 112 | 113 | @property 114 | def temperature_unit(self): 115 | """Return the unit used for temperature.""" 116 | return self._temperature_unit 117 | 118 | @property 119 | def target_temperature_step(self): 120 | """Return target temperature step used.""" 121 | return TEMP_STEP_WHOLE 122 | 123 | @property 124 | def target_temperature_min(self): 125 | """Return minimum value for target temperature.""" 126 | temp_range = self._temperature_range 127 | return self.conv_temp_unit(temp_range[0]) 128 | 129 | @property 130 | def target_temperature_max(self): 131 | """Return maximum value for target temperature.""" 132 | temp_range = self._temperature_range 133 | return self.conv_temp_unit(temp_range[1]) 134 | 135 | async def set_op_mode(self, mode): 136 | """Set the device's operating mode to an `OpMode` value.""" 137 | if mode not in self.op_modes: 138 | raise ValueError(f"Invalid operating mode: {mode}") 139 | keys = self._get_cmd_keys(CMD_STATE_OP_MODE) 140 | mode_value = self.model_info.enum_value(keys[2], WHMode[mode].value) 141 | await self.set(keys[0], keys[1], key=keys[2], value=mode_value) 142 | 143 | async def set_target_temp(self, temp): 144 | """Set the device's target temperature in Celsius degrees.""" 145 | range_info = self._temperature_range 146 | conv_temp = self._f2c(temp) 147 | if range_info and not (range_info[0] <= conv_temp <= range_info[1]): 148 | raise ValueError(f"Target temperature out of range: {temp}") 149 | keys = self._get_cmd_keys(CMD_STATE_TARGET_TEMP) 150 | await self.set(keys[0], keys[1], key=keys[2], value=conv_temp) 151 | 152 | async def get_power(self): 153 | """Get the instant power usage in watts of the whole unit.""" 154 | if not self._current_power_supported: 155 | return 0 156 | try: 157 | value = await self._get_config(STATE_POWER_V1) 158 | return value[STATE_POWER_V1] 159 | except (ValueError, InvalidRequestError): 160 | # Device does not support whole unit instant power usage 161 | self._current_power_supported = False 162 | return 0 163 | 164 | async def set( 165 | self, ctrl_key, command, *, key=None, value=None, data=None, ctrl_path=None 166 | ): 167 | """Set a device's control for `key` to `value`.""" 168 | await super().set( 169 | ctrl_key, command, key=key, value=value, data=data, ctrl_path=ctrl_path 170 | ) 171 | if self._status: 172 | self._status.update_status(key, value) 173 | 174 | def reset_status(self): 175 | """Reset the device's status""" 176 | self._status = WaterHeaterStatus(self) 177 | return self._status 178 | 179 | # async def _get_device_info(self): 180 | # """ 181 | # Call additional method to get device information for API v1. 182 | # Called by 'device_poll' method using a lower poll rate. 183 | # """ 184 | # # this command is to get power usage on V1 device 185 | # self._current_power = await self.get_power() 186 | 187 | async def _pre_update_v2(self): 188 | """Call additional methods before data update for v2 API.""" 189 | # this command is to get power and temp info on V2 device 190 | keys = self._get_cmd_keys(CMD_ENABLE_EVENT_V2) 191 | await self.set(keys[0], keys[1], key=keys[2], value="70", ctrl_path="control") 192 | 193 | async def poll(self) -> WaterHeaterStatus | None: 194 | """Poll the device's current state.""" 195 | res = await self._device_poll( 196 | # additional_poll_interval_v1=ADD_FEAT_POLL_INTERVAL, 197 | thinq2_query_device=True, 198 | ) 199 | if not res: 200 | return None 201 | # if self._should_poll: 202 | # res[STATE_POWER_V1] = self._current_power 203 | 204 | self._status = WaterHeaterStatus(self, res) 205 | 206 | return self._status 207 | 208 | 209 | class WaterHeaterStatus(DeviceStatus): 210 | """Higher-level information about a Water Heater's current status.""" 211 | 212 | _device: WaterHeaterDevice 213 | 214 | def __init__(self, device: WaterHeaterDevice, data: dict | None = None): 215 | """Initialize device status.""" 216 | super().__init__(device, data) 217 | self._operation = None 218 | 219 | def _str_to_temp(self, str_temp): 220 | """Convert a string to either an `int` or a `float` temperature.""" 221 | temp = self._str_to_num(str_temp) 222 | if not temp: # value 0 return None!!! 223 | return None 224 | return self._device.conv_temp_unit(temp) 225 | 226 | def _get_operation(self): 227 | """Get current operation.""" 228 | if self._operation is None: 229 | key = self._get_state_key(STATE_OPERATION) 230 | operation = self.lookup_enum(key, True) 231 | if not operation: 232 | return None 233 | self._operation = operation 234 | try: 235 | return ACOp(self._operation) 236 | except ValueError: 237 | return None 238 | 239 | def update_status(self, key, value): 240 | """Update device status.""" 241 | if not super().update_status(key, value): 242 | return False 243 | if key in STATE_OPERATION: 244 | self._operation = None 245 | return True 246 | 247 | @property 248 | def is_on(self): 249 | """Return if device is on.""" 250 | if not (operation := self._get_operation()): 251 | return False 252 | return operation != ACOp.OFF 253 | 254 | @property 255 | def operation(self): 256 | """Return current device operation.""" 257 | if not (operation := self._get_operation()): 258 | return None 259 | return operation.name 260 | 261 | @property 262 | def operation_mode(self): 263 | """Return current device operation mode.""" 264 | key = self._get_state_key(STATE_OPERATION_MODE) 265 | if (value := self.lookup_enum(key, True)) is None: 266 | return None 267 | try: 268 | return WHMode(value).name 269 | except ValueError: 270 | return None 271 | 272 | @property 273 | def current_temp(self): 274 | """Return current temperature.""" 275 | key = self._get_state_key(STATE_CURRENT_TEMP) 276 | value = self._str_to_temp(self._data.get(key)) 277 | return self._update_feature(WaterHeaterFeatures.HOT_WATER_TEMP, value, False) 278 | 279 | @property 280 | def target_temp(self): 281 | """Return target temperature.""" 282 | key = self._get_state_key(STATE_TARGET_TEMP) 283 | return self._str_to_temp(self._data.get(key)) 284 | 285 | @property 286 | def energy_current(self): 287 | """Return current energy usage.""" 288 | key = self._get_state_key(STATE_POWER) 289 | if (value := self.to_int_or_none(self._data.get(key))) is None: 290 | return None 291 | if value <= 50: 292 | # decrease power for devices that always return 50 when standby 293 | value = 5 294 | return self._update_feature(WaterHeaterFeatures.ENERGY_CURRENT, value, False) 295 | 296 | def _update_features(self): 297 | _ = [ 298 | self.current_temp, 299 | self.energy_current, 300 | ] 301 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------