├── .devcontainer └── devcontainer.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── report_bug.yml └── workflows │ ├── ci.yml │ ├── draft_release.yml │ ├── mkdocs.yml │ ├── stale.yml │ └── validate.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── custom_components └── localtuya │ ├── __init__.py │ ├── alarm_control_panel.py │ ├── binary_sensor.py │ ├── button.py │ ├── climate.py │ ├── config_flow.py │ ├── const.py │ ├── coordinator.py │ ├── core │ ├── __init__.py │ ├── cloud_api.py │ ├── ha_entities │ │ ├── __init__.py │ │ ├── alarm_control_panels.py │ │ ├── base.py │ │ ├── binary_sensors.py │ │ ├── buttons.py │ │ ├── climates.py │ │ ├── covers.py │ │ ├── fans.py │ │ ├── humidifiers.py │ │ ├── lights.py │ │ ├── locks.py │ │ ├── numbers.py │ │ ├── remotes.py │ │ ├── selects.py │ │ ├── sensors.py │ │ ├── sirens.py │ │ ├── switches.py │ │ ├── vacuums.py │ │ └── water_heaters.py │ ├── helpers.py │ └── pytuya │ │ └── __init__.py │ ├── cover.py │ ├── diagnostics.py │ ├── discovery.py │ ├── entity.py │ ├── fan.py │ ├── humidifier.py │ ├── light.py │ ├── lock.py │ ├── manifest.json │ ├── number.py │ ├── remote.py │ ├── select.py │ ├── sensor.py │ ├── services.yaml │ ├── siren.py │ ├── strings.json │ ├── switch.py │ ├── templates │ ├── __init__.py │ ├── sample_2g_switch.yaml │ └── sample_lights_bulb.yaml │ ├── translations │ ├── ar.json │ ├── en.json │ ├── it.json │ ├── pl.json │ ├── pt-BR.json │ ├── tr.json │ └── zh-Hans.json │ ├── vacuum.py │ └── water_heater.py ├── documentation ├── docs │ ├── auto_configure.md │ ├── cloud_api.md │ ├── faq │ │ └── index.md │ ├── ha_events.md │ ├── ha_services.md │ ├── images │ │ ├── cloud_link_account.png │ │ ├── configure.png │ │ ├── delete_device.png │ │ ├── dev │ │ │ ├── device_diagnostics.png │ │ │ ├── entry_diagnostics.png │ │ │ └── ha_entities_dir.png │ │ ├── dp_list_explain.png │ │ ├── dps_list_ex.png │ │ ├── init.png │ │ ├── opt_add_devices.png │ │ ├── opt_configure_device.png │ │ ├── opt_configure_entity.png │ │ ├── opt_configure_more.png │ │ ├── opt_configure_switch_ex.png │ │ ├── opt_reconfigure_device.png │ │ ├── opt_reconfigure_device_entity_check.png │ │ ├── options.png │ │ ├── report_bug_enable_debug_ui.png │ │ ├── report_bug_enable_device_debug.png │ │ ├── templates.png │ │ └── tuya_iot_overview.png │ ├── index.md │ ├── report_issue.md │ ├── style │ │ └── extra.css │ └── usage │ │ ├── configure_add_device.md │ │ ├── configure_edit_device.md │ │ ├── devices_delete.md │ │ └── installation.md └── mkdocs.yml ├── hacs.json ├── pylint.rc ├── pyproject.toml ├── requirements_test.txt ├── setup.cfg ├── tests ├── __init__.py ├── test_alarm_control_panel.py ├── test_auto_configure.py ├── test_binary_sensor.py ├── test_button.py ├── test_climate.py ├── test_cover.py ├── test_discovery.py ├── test_fan.py ├── test_humidifier.py ├── test_light.py ├── test_lock.py ├── test_number.py ├── test_remote.py ├── test_select.py ├── test_siren.py ├── test_switch.py ├── test_vacuum.py └── test_water_heater.py └── tuyadebug.tgz /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image":"mcr.microsoft.com/devcontainers/universal:2", 3 | "postCreateCommand": "pip install -r requirements_test.txt" 4 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | buy_me_a_coffee: mrbanderx3 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | # - name: Feature Request 4 | # url: https://github.com/xZetsubou/hass-localtuya/discussions 5 | # about: If this isn't a bug instead open a discussions!. 6 | 7 | - name: I have a question or need support 8 | url: https://community.home-assistant.io/t/local-tuya-control-tuya-devices-locally-fork-from-localtuya/634334?u=umu_ugg 9 | about: If you have any questions, feel free to ask in Home Assistant fourm or open a discussion 10 | 11 | - name: Frequently Asked Questions 12 | url: https://xzetsubou.github.io/hass-localtuya/faq/ 13 | about: Your answer may already be there 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/report_bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: A bug in the integration. 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | - type: input 11 | id: lt-version 12 | attributes: 13 | label: LocalTuya Version 14 | description: The LocalTuya version that you're running. 15 | placeholder: ex. 3.2.5 16 | validations: 17 | - type: input 18 | id: ha-version 19 | attributes: 20 | label: Home Assistant Version 21 | description: The HA version that you're running. 22 | placeholder: ex. 2024.3.0 23 | validations: 24 | required: true 25 | - type: checkboxes 26 | id: user-info 27 | attributes: 28 | label: Environment 29 | options: 30 | - label: Does the device work using the Home Assistant Tuya Cloud component? 31 | - label: Is this device connected to another local integration, including Home Assistant and any other tools? 32 | - label: The devices are within the same HA subnet, and they get discovered automatically when I add them 33 | - type: textarea 34 | id: what-happened 35 | attributes: 36 | label: What happened? 37 | description: Also tell us, what actually happened? 38 | placeholder: Tell us what you see! 39 | value: A bug happened! 40 | - type: textarea 41 | id: steps-reproduce 42 | attributes: 43 | label: Steps to reproduce. 44 | description: Step by step how did this happened. 45 | placeholder: > 46 | 1. Used Add new device option. 47 | 48 | 2. ... 49 | validations: 50 | required: true 51 | - type: textarea 52 | id: logs 53 | attributes: 54 | label: Relevant log output 55 | description: > 56 | Please copy and paste any relevant log output. Home Assisstant -> Settings -> System -> Logs. * Search for tuya. 57 | 58 | Check [report an issue](https://xzetsubou.github.io/hass-localtuya/report_issue/) 59 | render: shell 60 | - type: textarea 61 | attributes: 62 | label: Diagnostics information. 63 | placeholder: "drag-and-drop the diagnostics data file here (do not copy-and-paste the content)" 64 | description: >- 65 | If the issue is related to a device or the entry. 66 | [entry diagnostics](https://github.com/xZetsubou/hass-localtuya/assets/46300268/be7a70f9-86ce-4a39-9360-761426f96b9c). 67 | or 68 | [device diagnostics](https://github.com/xZetsubou/hass-localtuya/assets/46300268/e37ddc4e-4d1c-4250-9a41-8217ecc3607d) 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master, dev] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | env: 10 | PY_VERSION: "3.13" 11 | CACHE_VERSION: 1 12 | 13 | jobs: 14 | base: 15 | name: Prepare dependences 16 | runs-on: ubuntu-latest 17 | outputs: 18 | PY_PATH: ${{ steps.setup-outsputs.outputs.PY_PATH }} 19 | steps: 20 | - name: "Checkout" 21 | uses: actions/checkout@v4 22 | 23 | - name: "Setup Python" 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{env.PY_VERSION}} 27 | 28 | - name: "Setup outputs" 29 | id: setup-outsputs 30 | run: echo "PY_PATH=${{env.pythonLocation}}" >> $GITHUB_OUTPUT 31 | 32 | - name: "Cache dependencies" 33 | id: cache-dependencies 34 | uses: actions/cache@v4.2.0 35 | with: 36 | path: ${{env.pythonLocation}} 37 | key: ${{ runner.os }}-pip-dependencies-${{ hashFiles('requirements.txt') }}_${{env.CACHE_VERSION}} 38 | 39 | - name: "Install dependencies" 40 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 41 | run: pip install -r requirements_test.txt 42 | 43 | 44 | 45 | black: 46 | name: Black codestyle 47 | runs-on: ubuntu-latest 48 | needs: base 49 | steps: 50 | - name: "Checkout" 51 | uses: actions/checkout@v4 52 | 53 | - name: Restore Python 54 | uses: actions/cache/restore@v4.2.0 55 | with: 56 | path: ${{needs.base.outputs.PY_PATH}} 57 | fail-on-cache-miss: true 58 | key: ${{ runner.os }}-pip-dependencies-${{ hashFiles('requirements.txt') }}_${{env.CACHE_VERSION}} 59 | 60 | - name: "Black codestyle check" 61 | run: ${{needs.base.outputs.PY_PATH}}/bin/black --check --diff . 62 | 63 | codespell: 64 | name: "Codespell check" 65 | runs-on: ubuntu-latest 66 | needs: base 67 | steps: 68 | - name: "Checkout" 69 | uses: actions/checkout@v4 70 | 71 | - name: Restore Python 72 | uses: actions/cache/restore@v4.2.0 73 | with: 74 | path: ${{needs.base.outputs.PY_PATH}} 75 | fail-on-cache-miss: true 76 | key: ${{ runner.os }}-pip-dependencies-${{ hashFiles('requirements.txt') }}_${{env.CACHE_VERSION}} 77 | 78 | - name: "Codespell check" 79 | run: ${{needs.base.outputs.PY_PATH}}/bin/codespell 80 | 81 | pytest: 82 | name: "Pytest" 83 | runs-on: ubuntu-latest 84 | needs: base 85 | steps: 86 | - name: "Checkout" 87 | uses: actions/checkout@v4 88 | 89 | - name: Restore Python 90 | uses: actions/cache/restore@v4.2.0 91 | with: 92 | path: ${{needs.base.outputs.PY_PATH}} 93 | fail-on-cache-miss: true 94 | key: ${{ runner.os }}-pip-dependencies-${{ hashFiles('requirements.txt') }}_${{env.CACHE_VERSION}} 95 | 96 | - name: "Pytest" 97 | run: | 98 | ${{needs.base.outputs.PY_PATH}}/bin/pytest --cov --disable-warnings -s 99 | -------------------------------------------------------------------------------- /.github/workflows/draft_release.yml: -------------------------------------------------------------------------------- 1 | ########################################################### 2 | # Author: xZetsubou aka Bander # 3 | # # 4 | # Generate draft release of commits since latest version. # 5 | ########################################################### 6 | 7 | name: Generate Draft Release 8 | 9 | on: 10 | push: 11 | workflow_dispatch: 12 | branches: 13 | - master 14 | 15 | permissions: 16 | contents: write 17 | 18 | env: 19 | release_commits: | 20 | {"section": "breaking_changes", "prefix": "break", "header": "💥 Breaking Changes"} 21 | {"section": "fixes", "prefix": "fix", "header": "🐛 Fixes" } 22 | {"section": "feats", "prefix": "feat", "header": "✨ Feats" } 23 | {"section": "improvements", "prefix": "perf", "header": "⚡ Improvements" } 24 | {"section": "refactors", "prefix": "refactor", "header": "♻️ Refactors" } 25 | {"section": "chores", "prefix": "chore", "header": "🧹 Chores" } 26 | {"section": "docs", "prefix": "doc", "header": "📚 Docs" } 27 | {"section": "revert", "prefix": "revert", "header": "🌀 Reverts" } 28 | {"section": "cis", "prefix": "ci", "header": "🛠️ CI" } 29 | {"section": "tests", "prefix": "test", "header": "✅ Tests" } 30 | {"section": "more", "prefix": "", "header": "➕ More" } 31 | 32 | jobs: 33 | generate-release-draft: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v3 39 | with: 40 | fetch-depth: 0 # Fetch all tags and history 41 | 42 | - name: Get latest release tag 43 | id: latest-release 44 | run: | 45 | latest_tag=$(curl --silent --fail \ 46 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 47 | "https://api.github.com/repos/${{ github.repository }}/releases/latest" \ 48 | | jq -r .tag_name) 49 | echo "Latest release tag: $latest_tag" 50 | echo "latest_tag=$latest_tag" >> $GITHUB_ENV 51 | 52 | - name: Get commits from latest release to HEAD 53 | run: | 54 | git fetch --tags 55 | git log ${{ env.latest_tag }}..HEAD --pretty=format:'%s by @%an,%ae,%cL in [`%h`](https://github.com/${{github.repository}}/commit/%H)' --reverse > commits.md 56 | echo "" >> commits.md 57 | 58 | declare -A cache_nickname; 59 | while IFS= read -r line; do 60 | author_info=$(echo "$line" | grep -oP 'by @\K.*?(?= in)') 61 | commit=$(echo "$line" | grep -oP '/commit/\K[a-f0-9]+') 62 | IFS=$',' read -r name email mail_name _ <<< "$author_info" 63 | 64 | if [[ "$email" != *"noreply"* ]]; then 65 | if [[ -v cache_nickname[$email] ]]; then 66 | username="${cache_nickname[$email]}" 67 | else 68 | username=$(curl --silent --fail \ 69 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 70 | "https://api.github.com/search/commits?q=$commit" \ 71 | | jq -r '.items[0].author.login') 72 | cache_nickname[$email]=$username 73 | fi 74 | if [[ $username != null ]]; then 75 | sed -i "s#$author_info#$username#g" "commits.md" 76 | continue 77 | fi 78 | fi 79 | 80 | if [[ "$email" =~ (^[0-9]+)?\+?([a-zA-Z0-9]+)@ ]]; then 81 | id="${BASH_REMATCH[1]}"; mail_name="${BASH_REMATCH[2]}"; 82 | username=$name && [[ -n $id ]] && username=$mail_name 83 | fi 84 | sed -i "s#$author_info#$username#g" "commits.md" 85 | done < commits.md 86 | 87 | - name: Parse commit messages and categorize 88 | id: parse_commits 89 | run: | 90 | declare -A commits_map; 91 | 92 | while IFS= read -r line; do 93 | first_lower=$(echo "$line" | awk '{print $1}' | tr '[:upper:]' '[:lower:]') 94 | scope=$(echo "$line" | cut -d' ' -f1) 95 | commit_desc=$(echo "$line" | cut -d' ' -f2-) 96 | 97 | parsed_commit="- **$scope** $commit_desc\n" 98 | 99 | added=false 100 | while read -r item; do 101 | section=$(echo "$item" | jq -r '.section') 102 | prefix=$(echo "$item" | jq -r '.prefix') 103 | 104 | if [[ "$first_lower" == $prefix* ]]; then 105 | commits_map[$section]+=$parsed_commit && added=true && break 106 | fi 107 | done < <(echo '${{env.release_commits}}') 108 | [[ $added == false ]] && commits_map["more"]+=$parsed_commit 109 | 110 | done < commits.md 111 | 112 | jq -n '{}' commitsJson > tempJson && mv tempJson commitsJson 113 | for key in "${!commits_map[@]}"; do 114 | jq --arg key "$key" --arg value "${commits_map[$key]}" \ 115 | '. + {($key): $value}' commitsJson > tempJson && mv tempJson commitsJson 116 | done 117 | 118 | - name: Create Release Notes 119 | id: release-notes 120 | run: | 121 | notes="#### 🚨 **Note**: This draft has been released in $(date +'%Y.%m.%d - %H:%M:%S')\n\n" 122 | 123 | while read -r item; do 124 | section=$(echo "$item" | jq -r '.section') && [[ ! "$section" =~ [^[:space:]] ]] && continue 125 | header=$(echo "$item" | jq -r '.header') 126 | 127 | [[ ! "$section" =~ [^[:space:]] ]] && continue 128 | 129 | section_commits=$(jq -r --arg k "$section" '.[$k]' commitsJson) 130 | if [ -n "$section_commits" ] && [ "$section_commits" != null ]; then 131 | notes+="### $header \n $section_commits \n" 132 | fi 133 | 134 | done < <(echo '${{env.release_commits}}') 135 | 136 | set -f; echo -e $notes > commits.md; set +f 137 | 138 | - name: Remove old Draft 139 | id: remove_drafts 140 | continue-on-error: true 141 | env: 142 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 143 | run: 144 | gh release delete "Draft Latest version" 145 | 146 | - name: Create Release Draft 147 | uses: softprops/action-gh-release@v1 148 | with: 149 | name: "Draft Latest version" 150 | tag_name: "Draft Latest version" 151 | body_path: commits.md 152 | draft: true 153 | -------------------------------------------------------------------------------- /.github/workflows/mkdocs.yml: -------------------------------------------------------------------------------- 1 | name: "MkDocs" 2 | 3 | on: 4 | push: 5 | branches: [master, dev] 6 | paths: 7 | - .github/workflows/mkdocs.yml 8 | - documentation/** 9 | - documentation/mkdocs.yml 10 | workflow_dispatch: 11 | 12 | env: 13 | docs_dir: ./documentation 14 | 15 | permissions: 16 | contents: write 17 | 18 | jobs: 19 | deploy: 20 | name: "Deploy documentation" 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: "Checkout" 24 | uses: actions/checkout@v4 25 | 26 | - name: "Setup python" 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: 3.x 30 | 31 | - name: "Get cache ID" 32 | run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 33 | 34 | - name: "Cache" 35 | uses: actions/cache@v3 36 | with: 37 | key: mkdocs-material-${{ env.cache_id }} 38 | path: .cache 39 | restore-keys: | 40 | mkdocs-material- 41 | 42 | - name: "Install mkdocs" 43 | run: pip install mkdocs-material 44 | 45 | # - name: "Install dependencies" 46 | # run: pip install $docs_dir/requirements.txt 47 | 48 | - name: "Run mkdocs" 49 | run: | 50 | cd $docs_dir 51 | mkdocs gh-deploy --force 52 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: "Stale" 2 | 3 | on: 4 | release: 5 | types: [released] 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '30 1 * * *' 9 | 10 | env: 11 | release_tag: ${{ github.event.release.tag_name }} 12 | release_link: ${{ github.event.release.html_url }} 13 | 14 | jobs: 15 | close-fixed-issues: 16 | runs-on: ubuntu-latest 17 | if: | 18 | github.event_name == 'release' && !github.event.release.prerelease 19 | permissions: 20 | issues: write 21 | steps: 22 | # - name: "Dump release context" 23 | # run: echo "${{ toJSON(github.event.release) }}" 24 | 25 | - name: "Close Resolved issues" 26 | uses: actions/stale@v8 27 | with: 28 | only-labels: "master/next-release" 29 | labels-to-remove-when-stale: "master/next-release,stale" 30 | stale-issue-label: 'stale' 31 | stale-issue-message: > 32 | This issue was closed because it was resolved on the release: 33 | [${{env.release_tag}}](${{env.release_link}}) 34 | days-before-issue-close: 0 35 | days-before-issue-stale: 0 36 | operations-per-run: 250 37 | close-issue-reason: "completed" 38 | 39 | stale-issues: 40 | runs-on: ubuntu-latest 41 | permissions: 42 | issues: write 43 | steps: 44 | - name: "Stale issues" 45 | uses: actions/stale@v8 46 | with: 47 | stale-issue-message: 'This issue is stale because it has been open 14 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 48 | exempt-issue-labels: "master/next-release,enhancement,help wanted" 49 | days-before-stale: 14 50 | days-before-close: 5 51 | stale-issue-label: 'stale' 52 | 53 | - name: "Stale unknown issues" 54 | uses: actions/stale@v8 55 | with: 56 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 10 days.' 57 | only-labels: "enhancement,help wanted" 58 | days-before-stale: 30 59 | days-before-close: 40 60 | stale-issue-label: 'stale' 61 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | branches: [master, dev] 6 | pull_request: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | validate-hacs: 13 | runs-on: "ubuntu-latest" 14 | steps: 15 | - name: "Checkout" 16 | uses: actions/checkout@v3 17 | 18 | - name: "HACS validation" 19 | uses: "hacs/action@main" 20 | with: 21 | category: "integration" 22 | 23 | - name: "Hassfest validation" 24 | uses: home-assistant/actions/hassfest@master 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | __pycache__ 3 | tuyadebug/ 4 | .idea/ 5 | .pre-commit-config.yaml 6 | .coverage -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Quick start 2 | 3 | Fork the repository, and start a new codespace via the github UI. 4 | 5 | ![image](https://github.com/user-attachments/assets/aee1e022-b607-4cf9-8bb2-d5ba5ec56232) 6 | 7 | Read the [manual](https://xzetsubou.github.io/hass-localtuya/). 8 | 9 | ## Manual setup 10 | 11 | As above. 12 | 13 | Python 3.12 or higher. 14 | 15 | `pip install -r requirements_test.txt` 16 | 17 | Validate your installation with: 18 | 19 | `pytest` 20 | 21 | ## Contributing 22 | 23 | - Run `black` and `codespell` to ensure your code passes CI. 24 | - Bonus points for adding test coverage. 25 | - If you utilise an AI agent, please disclose in your pull request. (They can be very useful, but also get things very wrong some times) 26 | - If your PR is a work in progress, has failing tests, or something you aren't sure about, mark as draft. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Buy Me A Coffee 2 | 3 | --- 4 | 5 | 6 | ![logo](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/logo-small.png) 7 | 8 | 9 | __A Home Assistant custom Integration for local handling of Tuya-based devices.__ 10 | 11 | ### **Usage and setup [Documentation](https://xzetsubou.github.io/hass-localtuya/)** 12 | 13 |
14 | 15 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?category=integration&repository=hass-localtuya&owner=xZetsubou) 16 | 17 | 18 | 19 | ## __𝐅𝐞𝐚𝐭𝐮𝐫𝐞𝐬__ 20 | - Supported Sub-devices - `Devices that function through gateways` 21 | - Remote entities - `Supports IR remotes through native remote entity` 22 | - Auto-configure devices - `Requires a cloud API setup` 23 | - Automatic insertion - `Some fields requires a cloud API setup` 24 | - Devices discovery - `Discovers Tuya devices on your network` 25 | - Cloud API - `Only to help you to setup devices, can work without it.` 26 | 27 | 28 | 29 |
30 | 31 | [𝐑𝐞𝐩𝐨𝐫𝐭𝐢𝐧𝐠 𝐚𝐧 𝐢𝐬𝐬𝐮𝐞](https://xzetsubou.github.io/hass-localtuya/report_issue/) 32 | 33 | 41 | 42 |
𝐂𝐫𝐞𝐝𝐢𝐭𝐬 43 |

44 | 45 | [rospogrigio](https://github.com/rospogrigio), the original maintainer of LocalTuya. This fork was created when the [upstream](https://github.com/rospogrigio/localtuya) version was at `v5.2.1`. 46 | 47 | [NameLessJedi](https://github.com/NameLessJedi/localtuya-homeassistant) and [mileperhour](https://github.com/mileperhour/localtuya-homeassistant) being the major sources of inspiration, and whose code for switches is substantially unchanged. 48 | 49 | [TradeFace](https://github.com/TradeFace), for being the only one to provide the correct code for communication with the cover (in particular, the 0x0d command for the status instead of the 0x0a, and related needs such as double reply to be received): 50 | 51 | sean6541, for the working (standard) Python Handler for Tuya devices. 52 | 53 | [jasonacox](https://github.com/jasonacox), for the [TinyTuya](https://github.com/jasonacox/tinytuya) project from where I got big help and references to upgrade integration. 54 | 55 | [uzlonewolf](https://github.com/uzlonewolf), for maintaining TinyTuya who improved the tool so much and introduced new features like new protocols, etc. 56 | 57 | [postlund](https://github.com/postlund), for the ideas, for coding 95% of the refactoring and boosting the quality of the upstream repository. 58 | 59 |

60 |
61 | -------------------------------------------------------------------------------- /custom_components/localtuya/alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a Alarm.""" 2 | 3 | from enum import StrEnum 4 | import logging 5 | from functools import partial 6 | from .config_flow import col_to_select 7 | 8 | import voluptuous as vol 9 | from homeassistant.helpers.selector import ObjectSelector 10 | from homeassistant.components.alarm_control_panel import ( 11 | DOMAIN, 12 | AlarmControlPanelEntity, 13 | CodeFormat, 14 | AlarmControlPanelEntityFeature, 15 | AlarmControlPanelState, 16 | ) 17 | 18 | from .entity import LocalTuyaEntity, async_setup_entry 19 | from .const import CONF_ALARM_SUPPORTED_STATES, DictSelector 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | DEFAULT_PRECISION = 2 24 | 25 | 26 | class TuyaMode(StrEnum): 27 | DISARMED = "disarmed" 28 | ARM = "arm" 29 | HOME = "home" 30 | SOS = "sos" 31 | 32 | 33 | DEFAULT_SUPPORTED_MODES = { 34 | AlarmControlPanelState.DISARMED: TuyaMode.DISARMED, 35 | AlarmControlPanelState.ARMED_AWAY: TuyaMode.ARM, 36 | AlarmControlPanelState.ARMED_HOME: TuyaMode.HOME, 37 | AlarmControlPanelState.TRIGGERED: TuyaMode.SOS, 38 | } 39 | 40 | 41 | def flow_schema(dps): 42 | """Return schema used in config flow.""" 43 | return { 44 | vol.Optional( 45 | CONF_ALARM_SUPPORTED_STATES, default=DEFAULT_SUPPORTED_MODES 46 | ): ObjectSelector(), 47 | } 48 | 49 | 50 | class LocalTuyaAlarmControlPanel(LocalTuyaEntity, AlarmControlPanelEntity): 51 | """Representation of a Tuya Alarm.""" 52 | 53 | _supported_modes = {} 54 | 55 | def __init__( 56 | self, 57 | device, 58 | config_entry, 59 | dpid, 60 | **kwargs, 61 | ): 62 | """Initialize the Tuya Alarm.""" 63 | super().__init__(device, config_entry, dpid, _LOGGER, **kwargs) 64 | self._state = None 65 | self._changed_by = None 66 | 67 | # supported modes 68 | if supported_modes := self._config.get(CONF_ALARM_SUPPORTED_STATES, {}): 69 | # Key is HA state and value is Tuya State. 70 | if AlarmControlPanelState.ARMED_AWAY in supported_modes: 71 | self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME 72 | if AlarmControlPanelState.ARMED_HOME in supported_modes: 73 | self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY 74 | if AlarmControlPanelState.TRIGGERED in supported_modes: 75 | self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER 76 | 77 | self._states = DictSelector(supported_modes, reverse=True) 78 | 79 | @property 80 | def alarm_state(self) -> AlarmControlPanelState | None: 81 | """Return the state of the device.""" 82 | return self._states.to_ha(self._state, None) 83 | 84 | @property 85 | def code_format(self) -> CodeFormat | None: 86 | """Code format or None if no code is required.""" 87 | return None # self._attr_code_format 88 | 89 | @property 90 | def changed_by(self) -> str | None: 91 | """Last change triggered by.""" 92 | return None # self._attr_changed_by 93 | 94 | @property 95 | def code_arm_required(self) -> bool: 96 | """Whether the code is required for arm actions.""" 97 | return True # self._attr_code_arm_required 98 | 99 | async def async_alarm_disarm(self, code: str | None = None) -> None: 100 | """Send disarm command.""" 101 | state = self._states.to_tuya(AlarmControlPanelState.DISARMED) 102 | await self._device.set_dp(state, self._dp_id) 103 | 104 | async def async_alarm_arm_home(self, code: str | None = None) -> None: 105 | """Send arm home command.""" 106 | state = self._states.to_tuya(AlarmControlPanelState.ARMED_HOME) 107 | await self._device.set_dp(state, self._dp_id) 108 | 109 | async def async_alarm_arm_away(self, code: str | None = None) -> None: 110 | """Send arm away command.""" 111 | state = self._states.to_tuya(AlarmControlPanelState.ARMED_AWAY) 112 | await self._device.set_dp(state, self._dp_id) 113 | 114 | async def async_alarm_trigger(self, code: str | None = None) -> None: 115 | """Send alarm trigger command.""" 116 | state = self._states.to_tuya(AlarmControlPanelState.TRIGGERED) 117 | await self._device.set_dp(state, self._dp_id) 118 | 119 | def status_updated(self): 120 | """Device status was updated.""" 121 | super().status_updated() 122 | 123 | # No need to restore state for a AlarmControlPanel 124 | async def restore_state_when_connected(self): 125 | """Do nothing for a AlarmControlPanel.""" 126 | return 127 | 128 | 129 | async_setup_entry = partial( 130 | async_setup_entry, DOMAIN, LocalTuyaAlarmControlPanel, flow_schema 131 | ) 132 | -------------------------------------------------------------------------------- /custom_components/localtuya/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a binary sensor.""" 2 | 3 | import logging 4 | import voluptuous as vol 5 | 6 | from functools import partial 7 | 8 | from homeassistant.helpers.selector import NumberSelector, NumberSelectorConfig 9 | from homeassistant.helpers.event import async_call_later 10 | from homeassistant.core import callback, CALLBACK_TYPE 11 | from homeassistant.const import CONF_DEVICE_CLASS 12 | from homeassistant.components.binary_sensor import ( 13 | DEVICE_CLASSES_SCHEMA, 14 | DOMAIN, 15 | BinarySensorEntity, 16 | ) 17 | 18 | from .entity import LocalTuyaEntity, async_setup_entry 19 | from .const import CONF_STATE_ON, CONF_RESET_TIMER 20 | 21 | 22 | CONF_STATE_OFF = "state_off" 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | 27 | def flow_schema(dps): 28 | """Return schema used in config flow.""" 29 | return { 30 | vol.Required(CONF_STATE_ON, default="True"): str, 31 | # vol.Required(CONF_STATE_OFF, default="False"): str, 32 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, 33 | vol.Optional(CONF_RESET_TIMER, default=0): NumberSelector( 34 | NumberSelectorConfig(min=0, unit_of_measurement="Seconds", mode="box") 35 | ), 36 | } 37 | 38 | 39 | class LocalTuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity): 40 | """Representation of a Tuya binary sensor.""" 41 | 42 | def __init__( 43 | self, 44 | device, 45 | config_entry, 46 | sensorid, 47 | **kwargs, 48 | ): 49 | """Initialize the Tuya binary sensor.""" 50 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 51 | self._is_on = False 52 | 53 | self._reset_timer: float = self._config.get(CONF_RESET_TIMER, 0) 54 | self._reset_timer_interval: CALLBACK_TYPE | None = None 55 | 56 | @property 57 | def is_on(self): 58 | """Return sensor state.""" 59 | return self._is_on 60 | 61 | def status_updated(self): 62 | """Device status was updated.""" 63 | super().status_updated() 64 | 65 | state = str(self.dp_value(self._dp_id)).lower() 66 | # users may set wrong on states, But we assume that must devices use this on states. 67 | possible_on_states = ["true", "1", "pir", "on"] 68 | if state == self._config[CONF_STATE_ON].lower() or state in possible_on_states: 69 | self._is_on = True 70 | else: 71 | self._is_on = False 72 | 73 | if self._reset_timer and self._is_on: 74 | if self._reset_timer_interval is not None: 75 | self._reset_timer_interval() 76 | self._reset_timer_interval = None 77 | 78 | @callback 79 | def async_reset_state(now): 80 | """Set the state of the entity to off.""" 81 | # "_update_handler" logic, if status hasn't changed "status_updated" will not be called. 82 | # Maybe we can find better solution then this workaround? 83 | self._status[self._dp_id] = "reset_state_binary_sensor" 84 | self._is_on = False 85 | self.async_write_ha_state() 86 | 87 | self._reset_timer_interval = async_call_later( 88 | self.hass, self._reset_timer, async_reset_state 89 | ) 90 | 91 | # No need to restore state for a sensor 92 | async def restore_state_when_connected(self): 93 | """Do nothing for a sensor.""" 94 | return 95 | 96 | 97 | async_setup_entry = partial( 98 | async_setup_entry, DOMAIN, LocalTuyaBinarySensor, flow_schema 99 | ) 100 | -------------------------------------------------------------------------------- /custom_components/localtuya/button.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based button devices.""" 2 | 3 | import logging 4 | from functools import partial 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.button import DOMAIN, ButtonEntity 8 | 9 | from .entity import LocalTuyaEntity, async_setup_entry 10 | from .const import CONF_PASSIVE_ENTITY 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | def flow_schema(dps): 16 | """Return schema used in config flow.""" 17 | return { 18 | # vol.Required(CONF_PASSIVE_ENTITY): bool, 19 | } 20 | 21 | 22 | class LocalTuyaButton(LocalTuyaEntity, ButtonEntity): 23 | """Representation of a Tuya button.""" 24 | 25 | def __init__( 26 | self, 27 | device, 28 | config_entry, 29 | buttonid, 30 | **kwargs, 31 | ): 32 | """Initialize the Tuya button.""" 33 | super().__init__(device, config_entry, buttonid, _LOGGER, **kwargs) 34 | self._state = None 35 | 36 | async def async_press(self): 37 | """Press the button.""" 38 | await self._device.set_dp(True, self._dp_id) 39 | 40 | 41 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaButton, flow_schema) 42 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/__init__.py: -------------------------------------------------------------------------------- 1 | """The core of localtuya""" 2 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/alarm_control_panels.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | Credits: official HA Tuya integration. 5 | Modified by: xZetsubou 6 | """ 7 | 8 | from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE 9 | from ...const import CONF_ALARM_SUPPORTED_STATES 10 | from homeassistant.components.alarm_control_panel import AlarmControlPanelState 11 | 12 | MAP_ALARM_STATES = { 13 | "disarmed": AlarmControlPanelState.DISARMED, 14 | "arm": AlarmControlPanelState.ARMED_AWAY, 15 | "home": AlarmControlPanelState.ARMED_HOME, 16 | "sos": AlarmControlPanelState.TRIGGERED, 17 | } 18 | 19 | 20 | def localtuya_alarm(states: dict): 21 | """Generate localtuya alarm configs""" 22 | data = { 23 | CONF_ALARM_SUPPORTED_STATES: CLOUD_VALUE( 24 | states, "id", "range", dict, MAP_ALARM_STATES, True 25 | ), 26 | } 27 | return data 28 | 29 | 30 | # All descriptions can be found here: 31 | # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 32 | ALARMS: dict[str, tuple[LocalTuyaEntity, ...]] = { 33 | # Alarm Host 34 | # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf 35 | "mal": ( 36 | LocalTuyaEntity( 37 | id=DPCode.MASTER_MODE, 38 | custom_configs=localtuya_alarm( 39 | { 40 | AlarmControlPanelState.DISARMED: "disarmed", 41 | AlarmControlPanelState.ARMED_AWAY: "arm", 42 | AlarmControlPanelState.ARMED_HOME: "home", 43 | AlarmControlPanelState.TRIGGERED: "sos", 44 | } 45 | ), 46 | ), 47 | ), 48 | } 49 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/buttons.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | 5 | Credits: official HA Tuya integration. 6 | Modified by: xZetsubou 7 | """ 8 | 9 | from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory 10 | 11 | BUTTONS: dict[str, tuple[LocalTuyaEntity, ...]] = { 12 | # Scene Switch 13 | # https://developer.tuya.com/en/docs/iot/f?id=K9gf7nx6jelo8 14 | "cjkg": ( 15 | LocalTuyaEntity( 16 | id=DPCode.SCENE_1, 17 | name="Scene 1", 18 | icon="mdi:palette", 19 | ), 20 | LocalTuyaEntity( 21 | id=DPCode.SCENE_2, 22 | name="Scene 2", 23 | icon="mdi:palette", 24 | ), 25 | LocalTuyaEntity( 26 | id=DPCode.SCENE_3, 27 | name="Scene 3", 28 | icon="mdi:palette", 29 | ), 30 | LocalTuyaEntity( 31 | id=DPCode.SCENE_4, 32 | name="Scene 4", 33 | icon="mdi:palette", 34 | ), 35 | LocalTuyaEntity( 36 | id=DPCode.SCENE_5, 37 | name="Scene 5", 38 | icon="mdi:palette", 39 | ), 40 | LocalTuyaEntity( 41 | id=DPCode.SCENE_6, 42 | name="Scene 6", 43 | icon="mdi:palette", 44 | ), 45 | LocalTuyaEntity( 46 | id=DPCode.SCENE_7, 47 | name="Scene 7", 48 | icon="mdi:palette", 49 | ), 50 | LocalTuyaEntity( 51 | id=DPCode.SCENE_8, 52 | name="Scene 8", 53 | icon="mdi:palette", 54 | ), 55 | LocalTuyaEntity( 56 | id=DPCode.SCENE_9, 57 | name="Scene 9", 58 | icon="mdi:palette", 59 | ), 60 | LocalTuyaEntity( 61 | id=DPCode.SCENE_10, 62 | name="Scene 10", 63 | icon="mdi:palette", 64 | ), 65 | LocalTuyaEntity( 66 | id=DPCode.SCENE_11, 67 | name="Scene 11", 68 | icon="mdi:palette", 69 | ), 70 | LocalTuyaEntity( 71 | id=DPCode.SCENE_12, 72 | name="Scene 12", 73 | icon="mdi:palette", 74 | ), 75 | LocalTuyaEntity( 76 | id=DPCode.SCENE_13, 77 | name="Scene 13", 78 | icon="mdi:palette", 79 | ), 80 | LocalTuyaEntity( 81 | id=DPCode.SCENE_14, 82 | name="Scene 14", 83 | icon="mdi:palette", 84 | ), 85 | LocalTuyaEntity( 86 | id=DPCode.SCENE_15, 87 | name="Scene 15", 88 | icon="mdi:palette", 89 | ), 90 | LocalTuyaEntity( 91 | id=DPCode.SCENE_16, 92 | name="Scene 16", 93 | icon="mdi:palette", 94 | ), 95 | LocalTuyaEntity( 96 | id=DPCode.SCENE_17, 97 | name="Scene 17", 98 | icon="mdi:palette", 99 | ), 100 | LocalTuyaEntity( 101 | id=DPCode.SCENE_18, 102 | name="Scene 18", 103 | icon="mdi:palette", 104 | ), 105 | LocalTuyaEntity( 106 | id=DPCode.SCENE_18, 107 | name="Scene 18", 108 | icon="mdi:palette", 109 | ), 110 | LocalTuyaEntity( 111 | id=DPCode.SCENE_19, 112 | name="Scene 19", 113 | icon="mdi:palette", 114 | ), 115 | LocalTuyaEntity( 116 | id=DPCode.SCENE_20, 117 | name="Scene 20", 118 | icon="mdi:palette", 119 | ), 120 | ), 121 | # Curtain 122 | # Note: Multiple curtains isn't documented 123 | # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df 124 | "cl": ( 125 | LocalTuyaEntity( 126 | id=DPCode.REMOTE_REGISTER, 127 | name="Pair Remote", 128 | icon="mdi:remote", 129 | entity_category=EntityCategory.CONFIG, 130 | ), 131 | ), 132 | # Smart Pet Feeder 133 | # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld 134 | "cwwsq": ( 135 | LocalTuyaEntity( 136 | id=DPCode.FACTORY_RESET, 137 | name="Factory Reset", 138 | icon="mdi:cog-counterclockwise", 139 | entity_category=EntityCategory.CONFIG, 140 | ), 141 | ), 142 | # Smart Pet Feeder 143 | # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld 144 | "cwwsq": ( 145 | LocalTuyaEntity( 146 | id=DPCode.FACTORY_RESET, 147 | name="Factory Reset", 148 | icon="mdi:cog-counterclockwise", 149 | entity_category=EntityCategory.CONFIG, 150 | ), 151 | ), 152 | # Cat litter box 153 | # https://developer.tuya.com/en/docs/iot/f?id=Kakg309qkmuit 154 | "msp": ( 155 | LocalTuyaEntity( 156 | id=DPCode.FACTORY_RESET, 157 | name="Factory Reset", 158 | icon="mdi:restore", 159 | entity_category=EntityCategory.CONFIG, 160 | ), 161 | LocalTuyaEntity( 162 | id=DPCode.REBOOT, 163 | name="Reboot", 164 | icon="mdi:restart", 165 | entity_category=EntityCategory.CONFIG, 166 | ), 167 | ), 168 | # Robot Vacuum 169 | # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo 170 | "sd": ( 171 | LocalTuyaEntity( 172 | id=DPCode.RESET_DUSTER_CLOTH, 173 | name="Reset Duster Cloth", 174 | icon="mdi:restart", 175 | entity_category=EntityCategory.CONFIG, 176 | ), 177 | LocalTuyaEntity( 178 | id=DPCode.RESET_EDGE_BRUSH, 179 | name="Reset Edge Brush", 180 | icon="mdi:restart", 181 | entity_category=EntityCategory.CONFIG, 182 | ), 183 | LocalTuyaEntity( 184 | id=DPCode.RESET_FILTER, 185 | name="Reset Filter", 186 | icon="mdi:air-filter", 187 | entity_category=EntityCategory.CONFIG, 188 | ), 189 | LocalTuyaEntity( 190 | id=DPCode.RESET_MAP, 191 | name="Reset Map", 192 | icon="mdi:map-marker-remove", 193 | entity_category=EntityCategory.CONFIG, 194 | ), 195 | LocalTuyaEntity( 196 | id=DPCode.RESET_ROLL_BRUSH, 197 | name="Reset Roll Brush", 198 | icon="mdi:restart", 199 | entity_category=EntityCategory.CONFIG, 200 | ), 201 | ), 202 | # Wake Up Light II 203 | # Not documented 204 | "hxd": ( 205 | LocalTuyaEntity( 206 | id=DPCode.SWITCH_USB6, 207 | name="Snooze", 208 | icon="mdi:sleep", 209 | ), 210 | ), 211 | "cz": ( 212 | LocalTuyaEntity( 213 | id=DPCode.CLEAR_ENERGY, 214 | name="Clear Energy", 215 | icon="mdi:lightning-bolt-circle", 216 | entity_category=EntityCategory.CONFIG, 217 | ), 218 | ), 219 | # EV Charcher 220 | # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm 221 | "qccdz": ( 222 | LocalTuyaEntity( 223 | id=DPCode.CLEAR_ENERGY, 224 | name="Clear Energy", 225 | icon="mdi:lightning-bolt-circle", 226 | entity_category=EntityCategory.CONFIG, 227 | ), 228 | ), 229 | } 230 | 231 | # Wireless Switch # also can come as knob switch. 232 | # https://developer.tuya.com/en/docs/iot/wxkg?id=Kbeo9t3ryuqm5 233 | BUTTONS["wxkg"] = BUTTONS["cjkg"] 234 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/covers.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | Credits: official HA Tuya integration. 5 | Modified by: xZetsubou 6 | """ 7 | 8 | from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory 9 | from homeassistant.components.cover import CoverDeviceClass 10 | 11 | # from const.py this is temporarily. 12 | CONF_COMMANDS_SET = "commands_set" 13 | CONF_POSITIONING_MODE = "positioning_mode" 14 | CONF_CURRENT_POSITION_DP = "current_position_dp" 15 | CONF_SET_POSITION_DP = "set_position_dp" 16 | CONF_POSITION_INVERTED = "position_inverted" 17 | CONF_SPAN_TIME = "span_time" 18 | 19 | 20 | def localtuya_cover(cmd_set, position_mode=None, inverted=False, timed=25): 21 | """Define localtuya cover configs""" 22 | data = { 23 | CONF_COMMANDS_SET: cmd_set, 24 | CONF_POSITIONING_MODE: position_mode, 25 | CONF_POSITION_INVERTED: inverted, 26 | CONF_SPAN_TIME: timed, 27 | } 28 | return data 29 | 30 | 31 | COVERS: dict[str, tuple[LocalTuyaEntity, ...]] = { 32 | # Curtain 33 | # Note: Multiple curtains isn't documented 34 | # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df 35 | "cl": ( 36 | LocalTuyaEntity( 37 | id=DPCode.CONTROL, 38 | name="Curtain", 39 | custom_configs=localtuya_cover("open_close_stop", "position"), 40 | current_state=DPCode.SITUATION_SET, 41 | current_position_dp=(DPCode.PERCENT_STATE, DPCode.PERCENT_CONTROL), 42 | set_position_dp=DPCode.PERCENT_CONTROL, 43 | ), 44 | LocalTuyaEntity( 45 | id=DPCode.CONTROL_2, 46 | name="Curtain 2", 47 | custom_configs=localtuya_cover("open_close_stop", "position"), 48 | current_position_dp=(DPCode.PERCENT_STATE_2, DPCode.PERCENT_CONTROL_2), 49 | set_position_dp=DPCode.PERCENT_CONTROL_2, 50 | device_class=CoverDeviceClass.CURTAIN, 51 | ), 52 | LocalTuyaEntity( 53 | id=DPCode.CONTROL_3, 54 | name="Curtain 3", 55 | custom_configs=localtuya_cover("open_close_stop", "position"), 56 | current_position_dp=(DPCode.PERCENT_STATE_3, DPCode.PERCENT_CONTROL_3), 57 | set_position_dp=DPCode.PERCENT_CONTROL_3, 58 | device_class=CoverDeviceClass.CURTAIN, 59 | ), 60 | LocalTuyaEntity( 61 | id=DPCode.CONTROL_4, 62 | name="Curtain 4", 63 | custom_configs=localtuya_cover("open_close_stop", "position"), 64 | current_position_dp=(DPCode.PERCENT_STATE_4, DPCode.PERCENT_CONTROL_4), 65 | set_position_dp=DPCode.PERCENT_CONTROL_4, 66 | device_class=CoverDeviceClass.CURTAIN, 67 | ), 68 | LocalTuyaEntity( 69 | id=DPCode.MACH_OPERATE, 70 | name="Curtain", 71 | custom_configs=localtuya_cover("fz_zz_stop", "position"), 72 | current_position_dp=DPCode.POSITION, 73 | set_position_dp=DPCode.POSITION, 74 | device_class=CoverDeviceClass.CURTAIN, 75 | ), 76 | # switch_1 is an undocumented code that behaves identically to control 77 | # It is used by the Kogan Smart Blinds Driver 78 | LocalTuyaEntity( 79 | id=DPCode.SWITCH_1, 80 | name="Blind", 81 | custom_configs=localtuya_cover("open_close_stop", "position"), 82 | current_position_dp=DPCode.PERCENT_CONTROL, 83 | set_position_dp=DPCode.PERCENT_CONTROL, 84 | device_class=CoverDeviceClass.BLIND, 85 | ), 86 | ), 87 | # Garage Door Opener 88 | # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee 89 | "ckmkzq": ( 90 | LocalTuyaEntity( 91 | id=DPCode.SWITCH_1, 92 | name="Door", 93 | custom_configs=localtuya_cover("open_close_stop", "none", True), 94 | current_position_dp=DPCode.DOORCONTACT_STATE, 95 | device_class=CoverDeviceClass.GARAGE, 96 | ), 97 | LocalTuyaEntity( 98 | id=DPCode.SWITCH_2, 99 | name="Door 2", 100 | custom_configs=localtuya_cover("open_close_stop", "none", True), 101 | current_position_dp=DPCode.DOORCONTACT_STATE_2, 102 | device_class=CoverDeviceClass.GARAGE, 103 | ), 104 | LocalTuyaEntity( 105 | id=DPCode.SWITCH_3, 106 | name="Door 3", 107 | custom_configs=localtuya_cover("open_close_stop", "none", True), 108 | current_position_dp=DPCode.DOORCONTACT_STATE_3, 109 | device_class=CoverDeviceClass.GARAGE, 110 | ), 111 | ), 112 | # Curtain Switch 113 | # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 114 | "clkg": ( 115 | LocalTuyaEntity( 116 | id=DPCode.CONTROL, 117 | name="Curtain", 118 | custom_configs=localtuya_cover("open_close_stop", "position"), 119 | current_position_dp=DPCode.PERCENT_CONTROL, 120 | set_position_dp=DPCode.PERCENT_CONTROL, 121 | device_class=CoverDeviceClass.CURTAIN, 122 | ), 123 | LocalTuyaEntity( 124 | id=DPCode.CONTROL_2, 125 | name="Curtain 2", 126 | custom_configs=localtuya_cover("open_close_stop", "position"), 127 | current_position_dp=DPCode.PERCENT_CONTROL_2, 128 | set_position_dp=DPCode.PERCENT_CONTROL_2, 129 | device_class=CoverDeviceClass.CURTAIN, 130 | ), 131 | ), 132 | # Curtain Robot 133 | # Note: Not documented 134 | "jdcljqr": ( 135 | LocalTuyaEntity( 136 | id=DPCode.CONTROL, 137 | name="Curtain", 138 | custom_configs=localtuya_cover("open_close_stop", "position"), 139 | current_position_dp=DPCode.PERCENT_STATE, 140 | set_position_dp=DPCode.PERCENT_CONTROL, 141 | device_class=CoverDeviceClass.CURTAIN, 142 | ), 143 | ), 144 | } 145 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/fans.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | Credits: official HA Tuya integration. 5 | Modified by: xZetsubou 6 | """ 7 | 8 | from .base import ( 9 | DPCode, 10 | LocalTuyaEntity, 11 | CONF_DEVICE_CLASS, 12 | EntityCategory, 13 | CLOUD_VALUE, 14 | ) 15 | from homeassistant.components.fan import DIRECTION_FORWARD, DIRECTION_REVERSE 16 | 17 | # from const.py this is temporarily 18 | CONF_FAN_SPEED_CONTROL = "fan_speed_control" 19 | CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" 20 | CONF_FAN_DIRECTION = "fan_direction" 21 | 22 | CONF_FAN_SPEED_MIN = "fan_speed_min" 23 | CONF_FAN_SPEED_MAX = "fan_speed_max" 24 | CONF_FAN_DIRECTION_FWD = "fan_direction_forward" 25 | CONF_FAN_DIRECTION_REV = "fan_direction_reverse" 26 | CONF_FAN_DPS_TYPE = "fan_dps_type" 27 | CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" 28 | 29 | FAN_SPEED_DP = ( 30 | DPCode.FAN_SPEED_PERCENT, 31 | DPCode.FAN_SPEED, 32 | DPCode.SPEED, 33 | DPCode.FAN_SPEED_ENUM, 34 | ) 35 | 36 | FANS_OSCILLATING = (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) 37 | 38 | 39 | def localtuya_fan(fwd, rev, min_speed, max_speed, order, dp_type): 40 | """Define localtuya fan configs""" 41 | data = { 42 | CONF_FAN_DIRECTION_FWD: fwd, 43 | CONF_FAN_DIRECTION_REV: rev, 44 | CONF_FAN_SPEED_MIN: CLOUD_VALUE(min_speed, CONF_FAN_SPEED_CONTROL, "min"), 45 | CONF_FAN_SPEED_MAX: CLOUD_VALUE(max_speed, CONF_FAN_SPEED_CONTROL, "max"), 46 | CONF_FAN_ORDERED_LIST: CLOUD_VALUE(order, CONF_FAN_SPEED_CONTROL, "range", str), 47 | CONF_FAN_DPS_TYPE: dp_type, 48 | } 49 | return data 50 | 51 | 52 | FANS: dict[str, tuple[LocalTuyaEntity, ...]] = { 53 | # Fan 54 | "fs": ( 55 | LocalTuyaEntity( 56 | id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), 57 | name="Fan", 58 | icon="mdi:fan", 59 | fan_speed_control=FAN_SPEED_DP, 60 | fan_direction=DPCode.FAN_DIRECTION, 61 | fan_oscillating_control=FANS_OSCILLATING, 62 | custom_configs=localtuya_fan( 63 | DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int" 64 | ), 65 | ), 66 | ), 67 | # Normal switch with fan controller. 68 | "tdq": ( 69 | LocalTuyaEntity( 70 | id=(DPCode.SWITCH_FAN, DPCode.FAN_SWITCH), 71 | name="Fan", 72 | icon="mdi:fan", 73 | fan_speed_control=FAN_SPEED_DP, 74 | fan_direction=DPCode.FAN_DIRECTION, 75 | fan_oscillating_control=FANS_OSCILLATING, 76 | custom_configs=localtuya_fan( 77 | DIRECTION_FORWARD, DIRECTION_REVERSE, 1, 100, "disabled", "int" 78 | ), 79 | ), 80 | ), 81 | } 82 | # Fan with Light 83 | FANS["fsd"] = FANS["fs"] 84 | # Fan wall switch 85 | FANS["fskg"] = FANS["fs"] 86 | # Air Purifier 87 | FANS["kj"] = FANS["fs"] 88 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/humidifiers.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | 5 | Credits: official HA Tuya integration. 6 | Modified by: xZetsubou 7 | """ 8 | 9 | from .base import ( 10 | DPCode, 11 | LocalTuyaEntity, 12 | CONF_DEVICE_CLASS, 13 | EntityCategory, 14 | CLOUD_VALUE, 15 | ) 16 | from homeassistant.components.humidifier import ( 17 | HumidifierDeviceClass, 18 | ATTR_MAX_HUMIDITY, 19 | ATTR_MIN_HUMIDITY, 20 | DEFAULT_MAX_HUMIDITY, 21 | DEFAULT_MIN_HUMIDITY, 22 | ) 23 | 24 | CONF_HUMIDIFIER_SET_HUMIDITY_DP = "humidifier_set_humidity_dp" 25 | CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP = "humidifier_current_humidity_dp" 26 | CONF_HUMIDIFIER_MODE_DP = "humidifier_mode_dp" 27 | CONF_HUMIDIFIER_AVAILABLE_MODES = "humidifier_available_modes" 28 | 29 | 30 | def localtuya_humidifier(modes): 31 | """Define localtuya fan configs""" 32 | 33 | data = { 34 | CONF_HUMIDIFIER_AVAILABLE_MODES: CLOUD_VALUE( 35 | modes, CONF_HUMIDIFIER_MODE_DP, "range", dict 36 | ), 37 | ATTR_MIN_HUMIDITY: CLOUD_VALUE( 38 | DEFAULT_MIN_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "min" 39 | ), 40 | ATTR_MAX_HUMIDITY: CLOUD_VALUE( 41 | DEFAULT_MAX_HUMIDITY, CONF_HUMIDIFIER_SET_HUMIDITY_DP, "max" 42 | ), 43 | } 44 | return data 45 | 46 | 47 | HUMIDIFIERS: dict[str, tuple[LocalTuyaEntity, ...]] = { 48 | # Dehumidifier 49 | # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha 50 | "cs": ( 51 | LocalTuyaEntity( 52 | id=DPCode.SWITCH, 53 | humidifier_current_humidity_dp=DPCode.HUMIDITY_INDOOR, 54 | humidifier_set_humidity_dp=DPCode.DEHUMIDITY_SET_VALUE, 55 | humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE), 56 | custom_configs=localtuya_humidifier( 57 | { 58 | "dehumidify": "Dehumidify", 59 | "drying": "Drying", 60 | "continuous": "Continuous", 61 | } 62 | ), 63 | device_class=HumidifierDeviceClass.DEHUMIDIFIER, 64 | ), 65 | ), 66 | # Humidifier 67 | # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b 68 | "jsq": ( 69 | LocalTuyaEntity( 70 | id=DPCode.SWITCH, 71 | humidifier_current_humidity_dp=DPCode.HUMIDITY_CURRENT, 72 | humidifier_set_humidity_dp=DPCode.HUMIDITY_SET, 73 | humidifier_mode_dp=(DPCode.MODE, DPCode.WORK_MODE), 74 | custom_configs=localtuya_humidifier( 75 | { 76 | "large": "Large", 77 | "middle": "Middle", 78 | "small": "Small", 79 | } 80 | ), 81 | device_class=HumidifierDeviceClass.HUMIDIFIER, 82 | ), 83 | ), 84 | } 85 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/locks.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | Credits: official HA Tuya integration. 5 | Modified by: xZetsubou 6 | """ 7 | 8 | from .base import ( 9 | DPCode, 10 | LocalTuyaEntity, 11 | ) 12 | 13 | 14 | def localtuya_lock(): 15 | """Define localtuya lock configs""" 16 | data = {} 17 | return data 18 | 19 | 20 | LOCKS: dict[str, tuple[LocalTuyaEntity, ...]] = { 21 | # Locks 22 | "ms": ( 23 | LocalTuyaEntity( 24 | id=(DPCode.REMOTE_UNLOCK_SWITCH, DPCode.SWITCH), 25 | jammed_dp=DPCode.HIJACK, 26 | lock_state_dp=(DPCode.CLOSED_OPENED, DPCode.OPEN_CLOSE), 27 | ), 28 | ), 29 | } 30 | 31 | LOCKS["jtmspro"] = LOCKS["ms"] 32 | LOCKS["jtmsbh"] = LOCKS["ms"] 33 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/remotes.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | 5 | Credits: official HA Tuya integration. 6 | Modified by: xZetsubou 7 | """ 8 | 9 | from .base import DPCode, LocalTuyaEntity 10 | 11 | 12 | CONF_RECEIVE_DP = "receive_dp" 13 | 14 | 15 | # def localtuya_remote(_): 16 | # """Define localtuya fan configs""" 17 | # data = {} 18 | # return data 19 | 20 | 21 | REMOTES: dict[str, tuple[LocalTuyaEntity, ...]] = { 22 | # IR Remote 23 | # not documented 24 | "wnykq": ( 25 | LocalTuyaEntity( 26 | id=(DPCode.IR_SEND, DPCode.CONTROL), 27 | receive_dp=(DPCode.IR_STUDY_CODE, DPCode.STUDY_CODE), 28 | key_study_dp=DPCode.KEY_STUDY, 29 | ), 30 | ), 31 | } 32 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/sirens.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | Credits: official HA Tuya integration. 5 | Modified by: xZetsubou 6 | """ 7 | 8 | from .base import DPCode, LocalTuyaEntity, CONF_DEVICE_CLASS, EntityCategory 9 | 10 | # All descriptions can be found here: 11 | # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 12 | SIRENS: dict[str, tuple[LocalTuyaEntity, ...]] = { 13 | # Multi-functional Sensor 14 | # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 15 | "dgnbj": ( 16 | LocalTuyaEntity( 17 | id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH), 18 | ), 19 | ), 20 | # Siren Alarm 21 | # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu 22 | "sgbj": ( 23 | LocalTuyaEntity( 24 | id=(DPCode.ALARM_SWITCH, DPCode.ALARMSWITCH), 25 | ), 26 | ), 27 | # Smart Camera 28 | # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 29 | "sp": ( 30 | LocalTuyaEntity( 31 | id=DPCode.SIREN_SWITCH, 32 | ), 33 | ), 34 | } 35 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/vacuums.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | Credits: official HA Tuya integration. 5 | Modified by: xZetsubou 6 | """ 7 | 8 | from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE 9 | 10 | CONF_POWERGO_DP = "powergo_dp" 11 | CONF_IDLE_STATUS_VALUE = "idle_status_value" 12 | CONF_RETURNING_STATUS_VALUE = "returning_status_value" 13 | CONF_DOCKED_STATUS_VALUE = "docked_status_value" 14 | CONF_BATTERY_DP = "battery_dp" 15 | CONF_MODE_DP = "mode_dp" 16 | CONF_MODES = "modes" 17 | CONF_FAN_SPEED_DP = "fan_speed_dp" 18 | CONF_FAN_SPEEDS = "fan_speeds" 19 | CONF_CLEAN_TIME_DP = "clean_time_dp" 20 | CONF_CLEAN_AREA_DP = "clean_area_dp" 21 | CONF_CLEAN_RECORD_DP = "clean_record_dp" 22 | CONF_LOCATE_DP = "locate_dp" 23 | CONF_FAULT_DP = "fault_dp" 24 | CONF_PAUSED_STATE = "paused_state" 25 | CONF_RETURN_MODE = "return_mode" 26 | CONF_STOP_STATUS = "stop_status" 27 | 28 | DEFAULT_IDLE_STATUS = "standby,sleep" 29 | DEFAULT_RETURNING_STATUS = "docking,to_charge,goto_charge" 30 | DEFAULT_DOCKED_STATUS = "charging,chargecompleted,charge_done" 31 | DEFAULT_MODES = "smart,wall_follow,spiral,single" 32 | DEFAULT_FAN_SPEEDS = "low,normal,high" 33 | DEFAULT_PAUSED_STATE = "paused" 34 | DEFAULT_RETURN_MODE = "chargego" 35 | DEFAULT_STOP_STATUS = "standby" 36 | 37 | 38 | def localtuya_vaccuums( 39 | modes: str = None, 40 | returning_status_value: str = None, 41 | return_mode: str = None, 42 | fan_speeds: str = None, 43 | paused_state: str = None, 44 | stop_status: str = None, 45 | idle_status_value: str = None, 46 | docked_status_value: str = None, 47 | ) -> dict: 48 | """Will return dict with the vacuum localtuya entity configs""" 49 | data = { 50 | CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", str), 51 | CONF_IDLE_STATUS_VALUE: idle_status_value or DEFAULT_IDLE_STATUS, 52 | CONF_STOP_STATUS: stop_status or DEFAULT_STOP_STATUS, 53 | CONF_PAUSED_STATE: paused_state or DEFAULT_PAUSED_STATE, 54 | CONF_FAN_SPEEDS: CLOUD_VALUE(fan_speeds, CONF_FAN_SPEED_DP, "range", str), 55 | CONF_RETURN_MODE: return_mode or DEFAULT_RETURN_MODE, 56 | CONF_RETURNING_STATUS_VALUE: returning_status_value or DEFAULT_RETURNING_STATUS, 57 | CONF_DOCKED_STATUS_VALUE: docked_status_value or CONF_DOCKED_STATUS_VALUE, 58 | } 59 | 60 | return data 61 | 62 | 63 | VACUUMS: dict[str, tuple[LocalTuyaEntity, ...]] = { 64 | # Robot Vacuum 65 | # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo 66 | "sd": ( 67 | LocalTuyaEntity( 68 | id=DPCode.STATUS, 69 | icon="mdi:robot-vacuum", 70 | powergo_dp=(DPCode.POWER_GO, DPCode.POWER, DPCode.SWITCH), 71 | battery_dp=( 72 | DPCode.BATTERY_PERCENTAGE, 73 | DPCode.ELECTRICITY_LEFT, 74 | DPCode.RESIDUAL_ELECTRICITY, 75 | ), 76 | mode_dp=DPCode.MODE, 77 | fan_speed_dp=DPCode.SUCTION, 78 | pause_dp=DPCode.PAUSE, 79 | locate_dp=DPCode.SEEK, 80 | clean_time_dp=( 81 | DPCode.CLEAN_TIME, 82 | DPCode.TOTAL_CLEAN_AREA, 83 | DPCode.TOTAL_CLEAN_TIME, 84 | ), 85 | clean_area_dp=DPCode.CLEAN_AREA, 86 | clean_record_dp=DPCode.CLEAN_RECORD, 87 | fault_dp=DPCode.FAULT, 88 | custom_configs=localtuya_vaccuums( 89 | modes=DEFAULT_MODES, 90 | returning_status_value=DEFAULT_RETURNING_STATUS, 91 | return_mode=DEFAULT_RETURN_MODE, 92 | fan_speeds=DEFAULT_FAN_SPEEDS, 93 | paused_state=DEFAULT_PAUSED_STATE, 94 | stop_status=DEFAULT_STOP_STATUS, 95 | idle_status_value=DEFAULT_IDLE_STATUS, 96 | docked_status_value=DEFAULT_DOCKED_STATUS, 97 | ), 98 | ), 99 | ), 100 | } 101 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/ha_entities/water_heaters.py: -------------------------------------------------------------------------------- 1 | """ 2 | This a file contains available tuya data 3 | https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq 4 | 5 | Credits: official HA Tuya integration. 6 | Modified by: xZetsubou 7 | """ 8 | 9 | from homeassistant.components.water_heater import ( 10 | DEFAULT_MAX_TEMP, 11 | DEFAULT_MIN_TEMP, 12 | ) 13 | from homeassistant.const import CONF_TEMPERATURE_UNIT 14 | 15 | from .base import DPCode, LocalTuyaEntity, CLOUD_VALUE 16 | from ...const import ( 17 | CONF_TARGET_TEMPERATURE_LOW_DP, 18 | CONF_TARGET_TEMPERATURE_HIGH_DP, 19 | CONF_PRECISION, 20 | CONF_TARGET_PRECISION, 21 | CONF_CURRENT_TEMPERATURE_DP, 22 | CONF_MAX_TEMP, 23 | CONF_MIN_TEMP, 24 | CONF_TARGET_TEMPERATURE_DP, 25 | CONF_MODES, 26 | CONF_MODE_DP, 27 | ) 28 | 29 | 30 | UNIT_C = "celsius" 31 | UNIT_F = "fahrenheit" 32 | 33 | 34 | def localtuya_water_heater( 35 | modes={}, 36 | unit=None, 37 | min_temperature=DEFAULT_MIN_TEMP, 38 | max_temperature=DEFAULT_MAX_TEMP, 39 | current_precsion=0.1, 40 | target_precision=1, 41 | ) -> dict: 42 | """Create localtuya climate configs""" 43 | data = {} 44 | for key, conf in { 45 | CONF_MODES: CLOUD_VALUE(modes, CONF_MODE_DP, "range", dict), 46 | CONF_MIN_TEMP: CLOUD_VALUE( 47 | min_temperature, CONF_TARGET_TEMPERATURE_DP, "min", scale=True 48 | ), 49 | CONF_MAX_TEMP: CLOUD_VALUE( 50 | max_temperature, CONF_TARGET_TEMPERATURE_DP, "max", scale=True 51 | ), 52 | CONF_TEMPERATURE_UNIT: unit, 53 | CONF_PRECISION: CLOUD_VALUE( 54 | str(current_precsion), CONF_CURRENT_TEMPERATURE_DP, "scale", str 55 | ), 56 | CONF_TARGET_PRECISION: CLOUD_VALUE( 57 | str(target_precision), CONF_TARGET_TEMPERATURE_DP, "scale", str 58 | ), 59 | }.items(): 60 | if conf is not None: 61 | data.update({key: conf}) 62 | 63 | return data 64 | 65 | 66 | WATER_HEATERS: dict[str, tuple[LocalTuyaEntity, ...]] = { 67 | # Water Heater 68 | # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx 69 | "rs": ( 70 | LocalTuyaEntity( 71 | id=DPCode.SWITCH, 72 | target_temperature_dp=(DPCode.TEMP_SET, DPCode.TEMP_SET_F), 73 | current_temperature_dp=(DPCode.TEMP_CURRENT, DPCode.TEMP_CURRENT_F), 74 | target_temperature_low_dp=(DPCode.TEMP_LOW, DPCode.LOWER_TEMP), 75 | target_temperature_high_dp=(DPCode.TEMP_UP, DPCode.UPPER_TEMP), 76 | mode_dp=DPCode.MODE, 77 | fan_speed_dp=(DPCode.FAN_SPEED_ENUM, DPCode.WINDSPEED), 78 | custom_configs=localtuya_water_heater( 79 | current_precsion=0.1, target_precision=0.1 80 | ), 81 | ), 82 | ), 83 | } 84 | -------------------------------------------------------------------------------- /custom_components/localtuya/core/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers functions for HASS-LocalTuya. 3 | """ 4 | 5 | import asyncio 6 | import logging 7 | import os.path 8 | from enum import Enum 9 | from fnmatch import fnmatch 10 | from typing import NamedTuple 11 | 12 | from homeassistant.util.yaml import load_yaml, dump 13 | from homeassistant.const import CONF_PLATFORM, CONF_ENTITIES 14 | 15 | 16 | import custom_components.localtuya.templates as templates_dir 17 | 18 | JSON_TYPE = list | dict | str 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | ############################### 24 | # Templates # 25 | ############################### 26 | class templates: 27 | 28 | def yaml_dump(config, fname: str | None = None) -> JSON_TYPE: 29 | """Save yaml config.""" 30 | try: 31 | with open(fname, "w", encoding="utf-8") as conf_file: 32 | return conf_file.write(dump(config)) 33 | except UnicodeDecodeError as exc: 34 | _LOGGER.error("Unable to save file %s: %s", fname, exc) 35 | 36 | def list_templates(): 37 | """Return the available templates files.""" 38 | dir = os.path.dirname(templates_dir.__file__) 39 | files = {} 40 | for e in sorted(os.scandir(dir), key=lambda e: e.name): 41 | file: str = e.name.lower() 42 | if e.is_file() and (fnmatch(file, "*yaml") or fnmatch(file, "*yml")): 43 | # fn = str(file).replace(".yaml", "").replace("_", " ") 44 | files[e.name] = e.name 45 | return files 46 | 47 | def import_config(filename): 48 | """Create a data that can be used as config in localtuya.""" 49 | template_dir = os.path.dirname(templates_dir.__file__) 50 | template_file = os.path.join(template_dir, filename) 51 | _config = load_yaml(template_file) 52 | entities = [] 53 | for cfg in _config: 54 | ent = {} 55 | for plat, values in cfg.items(): 56 | for key, value in values.items(): 57 | ent[str(key)] = ( 58 | str(value) 59 | if not isinstance(value, (bool, float, dict, list)) 60 | else value 61 | ) 62 | ent[CONF_PLATFORM] = plat 63 | entities.append(ent) 64 | if not entities: 65 | raise ValueError("No entities found the can be used for localtuya") 66 | return entities 67 | 68 | @classmethod 69 | def export_config(cls, config: dict, config_name: str): 70 | """Create a yaml config file for localtuya.""" 71 | export_config = [] 72 | for cfg in config[CONF_ENTITIES]: 73 | # Special case device_classes 74 | for k, v in cfg.items(): 75 | if not type(v) is str and isinstance(v, Enum): 76 | cfg[k] = v.value 77 | 78 | ents = {cfg[CONF_PLATFORM]: cfg} 79 | export_config.append(ents) 80 | fname = ( 81 | config_name + ".yaml" if not config_name.endswith(".yaml") else config_name 82 | ) 83 | fname = fname.replace(" ", "_") 84 | template_dir = os.path.dirname(templates_dir.__file__) 85 | template_file = os.path.join(template_dir, fname) 86 | 87 | cls.yaml_dump(export_config, template_file) 88 | 89 | 90 | ################################ 91 | ## config flows ## 92 | ################################ 93 | 94 | from ..const import CONF_LOCAL_KEY, CONF_NODE_ID 95 | 96 | GATEWAY = NamedTuple("Gateway", [("id", str), ("data", dict)]) 97 | 98 | 99 | def get_gateway_by_deviceid(device_id: str, cloud_data: dict) -> GATEWAY: 100 | """Return the gateway (id, data) of the sub-deviceID if existed in cloud_data.""" 101 | 102 | if sub_device := cloud_data.get(device_id): 103 | for dev_id, dev_data in cloud_data.items(): 104 | # Get gateway Assuming the LocalKey is the same gateway LocalKey! 105 | if ( 106 | dev_id != device_id 107 | and not dev_data.get(CONF_NODE_ID) 108 | and dev_data.get(CONF_LOCAL_KEY) == sub_device.get(CONF_LOCAL_KEY) 109 | ): 110 | return GATEWAY(dev_id, dev_data) 111 | 112 | 113 | ############################### 114 | # Auto configure device # 115 | ############################### 116 | from .ha_entities import gen_localtuya_entities 117 | -------------------------------------------------------------------------------- /custom_components/localtuya/diagnostics.py: -------------------------------------------------------------------------------- 1 | """Diagnostics support for LocalTuya.""" 2 | 3 | from __future__ import annotations 4 | 5 | import copy 6 | import logging 7 | from typing import Any 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICES 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.device_registry import DeviceEntry 13 | 14 | from . import HassLocalTuyaData 15 | from .const import CONF_LOCAL_KEY, CONF_USER_ID, DOMAIN, CONF_NO_CLOUD, DATA_DISCOVERY 16 | 17 | CLOUD_DEVICES = "cloud_devices" 18 | DEVICE_CONFIG = "device_config" 19 | DEVICE_CLOUD_INFO = "device_cloud_info" 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | DATA_OBFUSCATE = {"ip": 1, "uid": 3, CONF_LOCAL_KEY: 3, "lat": 0, "lon": 0} 24 | 25 | 26 | async def async_get_config_entry_diagnostics( 27 | hass: HomeAssistant, entry: ConfigEntry 28 | ) -> dict[str, Any]: 29 | """Return diagnostics for a config entry.""" 30 | data = {} 31 | data = dict(entry.data) 32 | hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] 33 | tuya_api = hass_localtuya.cloud_data 34 | if data.get(CONF_NO_CLOUD, True) is not True: 35 | await hass.async_create_task(tuya_api.async_get_devices_dps_query()) 36 | # censoring private information on integration diagnostic data 37 | for field in [CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_USER_ID]: 38 | data[field] = obfuscate(data[field]) 39 | data[CONF_DEVICES] = copy.deepcopy(entry.data[CONF_DEVICES]) 40 | for dev_id, dev in data[CONF_DEVICES].items(): 41 | local_key = dev[CONF_LOCAL_KEY] 42 | local_key_obfuscated = obfuscate(local_key) 43 | dev[CONF_LOCAL_KEY] = local_key_obfuscated 44 | data[CLOUD_DEVICES] = copy.deepcopy(tuya_api.device_list) 45 | for dev_id, dev in data[CLOUD_DEVICES].items(): 46 | for obf, obf_len in DATA_OBFUSCATE.items(): 47 | if ob := data[CLOUD_DEVICES][dev_id].get(obf): 48 | data[CLOUD_DEVICES][dev_id][obf] = obfuscate(ob, obf_len, obf_len) 49 | if discovery := hass.data[DOMAIN].get(DATA_DISCOVERY): 50 | data["Discovered_Devices"] = discovery.devices 51 | return data 52 | 53 | 54 | async def async_get_device_diagnostics( 55 | hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry 56 | ) -> dict[str, Any]: 57 | """Return diagnostics for a device entry.""" 58 | data = {} 59 | dev_id = list(device.identifiers)[0][1].split("_")[-1] 60 | data[DEVICE_CONFIG] = entry.data[CONF_DEVICES][dev_id].copy() 61 | # NOT censoring private information on device diagnostic data 62 | # local_key = data[DEVICE_CONFIG][CONF_LOCAL_KEY] 63 | # data[DEVICE_CONFIG][CONF_LOCAL_KEY] = f"{local_key[0:3]}...{local_key[-3:]}" 64 | 65 | hass_localtuya: HassLocalTuyaData = hass.data[DOMAIN][entry.entry_id] 66 | tuya_api = hass_localtuya.cloud_data 67 | if dev_id in tuya_api.device_list: 68 | await tuya_api.async_get_device_functions(dev_id) 69 | data[DEVICE_CLOUD_INFO] = copy.deepcopy(tuya_api.device_list[dev_id]) 70 | for obf, obf_len in DATA_OBFUSCATE.items(): 71 | if ob := data[DEVICE_CLOUD_INFO].get(obf): 72 | data[DEVICE_CLOUD_INFO][obf] = obfuscate(ob, obf_len, obf_len) 73 | # NOT censoring private information on device diagnostic data 74 | # local_key = data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] 75 | # local_key_obfuscated = "{local_key[0:3]}...{local_key[-3:]}" 76 | # data[DEVICE_CLOUD_INFO][CONF_LOCAL_KEY] = local_key_obfuscated 77 | 78 | # data["log"] = hass.data[DOMAIN][CONF_DEVICES][dev_id].logger.retrieve_log() 79 | if discovery := hass.data[DOMAIN].get(DATA_DISCOVERY): 80 | data["Discovered_Devices"] = discovery.devices.get(dev_id) 81 | return data 82 | 83 | 84 | def obfuscate(key, start_characters=3, end_characters=3) -> str: 85 | """Return obfuscated text by removing characters between [start_characters and end_characters]""" 86 | if start_characters <= 0 and end_characters <= 0: 87 | return "" 88 | 89 | return f"{key[0:start_characters]}...{key[-end_characters:]}" 90 | -------------------------------------------------------------------------------- /custom_components/localtuya/discovery.py: -------------------------------------------------------------------------------- 1 | """Discovery module for Tuya devices. 2 | 3 | based on tuya-convert.py from tuya-convert: 4 | https://github.com/ct-Open-Source/tuya-convert/blob/master/scripts/tuya-discovery.py 5 | 6 | Maintained by @xZetsubou 7 | """ 8 | 9 | import os 10 | import asyncio 11 | import json 12 | import logging 13 | from hashlib import md5 14 | from socket import inet_aton 15 | 16 | from cryptography.hazmat.backends import default_backend 17 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 18 | 19 | from .entity import pytuya 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | UDP_KEY = md5(b"yGAdlopoPVldABfn").digest() 24 | 25 | PREFIX_55AA_BIN = b"\x00\x00U\xaa" 26 | PREFIX_6699_BIN = b"\x00\x00\x66\x99" 27 | UDP_COMMAND = b"\x00\x00\x00\x00" 28 | 29 | DEFAULT_TIMEOUT = 6.0 30 | 31 | 32 | def decrypt(msg, key): 33 | def _unpad(data): 34 | return data[: -ord(data[len(data) - 1 :])] 35 | 36 | cipher = Cipher(algorithms.AES(key), modes.ECB(), default_backend()) 37 | decryptor = cipher.decryptor() 38 | return _unpad(decryptor.update(msg) + decryptor.finalize()).decode() 39 | 40 | 41 | def decrypt_udp(message): 42 | """Decrypt encrypted UDP broadcasts.""" 43 | if message[:4] == PREFIX_55AA_BIN: 44 | payload = message[20:-8] 45 | if message[8:12] == UDP_COMMAND: 46 | return payload 47 | return decrypt(payload, UDP_KEY) 48 | if message[:4] == PREFIX_6699_BIN: 49 | unpacked = pytuya.unpack_message(message, hmac_key=UDP_KEY, no_retcode=None) 50 | payload = unpacked.payload.decode() 51 | # app sometimes has extra bytes at the end 52 | while payload[-1] == chr(0): 53 | payload = payload[:-1] 54 | return payload 55 | return decrypt(message, UDP_KEY) 56 | 57 | 58 | class TuyaDiscovery(asyncio.DatagramProtocol): 59 | """Datagram handler listening for Tuya broadcast messages.""" 60 | 61 | def __init__(self, callback=None): 62 | """Initialize a new BaseDiscovery.""" 63 | self.devices = {} 64 | self._listeners = [] 65 | self._callback = callback 66 | 67 | async def start(self): 68 | """Start discovery by listening to broadcasts.""" 69 | loop = asyncio.get_running_loop() 70 | op_reuse_port = {"reuse_port": True} if os.name != "nt" else {} 71 | listener = loop.create_datagram_endpoint( 72 | lambda: self, local_addr=("0.0.0.0", 6666), **op_reuse_port 73 | ) 74 | encrypted_listener = loop.create_datagram_endpoint( 75 | lambda: self, local_addr=("0.0.0.0", 6667), **op_reuse_port 76 | ) 77 | # tuyaApp_encrypted_listener = loop.create_datagram_endpoint( 78 | # lambda: self, local_addr=("0.0.0.0", 7000), **op_reuse_port 79 | # ) 80 | self._listeners = await asyncio.gather(listener, encrypted_listener) 81 | _LOGGER.debug("Listening to broadcasts on UDP port 6666, 6667") 82 | 83 | def close(self): 84 | """Stop discovery.""" 85 | self._callback = None 86 | for transport, _ in self._listeners: 87 | transport.close() 88 | 89 | def datagram_received(self, data, addr): 90 | """Handle received broadcast message.""" 91 | try: 92 | try: 93 | data = decrypt_udp(data) 94 | except Exception: # pylint: disable=broad-except 95 | data = data.decode() 96 | decoded = json.loads(data) 97 | self.device_found(decoded) 98 | except: 99 | # _LOGGER.debug("Bordcast from app from ip: %s", addr[0]) 100 | _LOGGER.debug("Failed to decode broadcast from %r: %r", addr[0], data) 101 | 102 | def device_found(self, device): 103 | """Discover a new device.""" 104 | gwid, ip = device.get("gwId"), device.get("ip") 105 | # If device found but the ip changed. 106 | if gwid in self.devices and (self.devices[gwid].get("ip") != ip): 107 | self.devices.pop(gwid) 108 | 109 | if gwid not in self.devices: 110 | self.devices[gwid] = device 111 | # Sort devices by ip. 112 | sort_devices = sorted( 113 | self.devices.items(), key=lambda i: inet_aton(i[1].get("ip", "0")) 114 | ) 115 | self.devices = dict(sort_devices) 116 | 117 | _LOGGER.debug("Discovered device: %s", device) 118 | if self._callback: 119 | self._callback(device) 120 | 121 | 122 | async def discover(): 123 | """Discover and return devices on local network.""" 124 | discovery = TuyaDiscovery() 125 | try: 126 | await discovery.start() 127 | await asyncio.sleep(DEFAULT_TIMEOUT) 128 | finally: 129 | discovery.close() 130 | return discovery.devices 131 | -------------------------------------------------------------------------------- /custom_components/localtuya/humidifier.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based button devices.""" 2 | 3 | import logging 4 | from functools import partial 5 | from .config_flow import col_to_select 6 | from homeassistant.helpers.selector import ObjectSelector 7 | 8 | import voluptuous as vol 9 | from homeassistant.const import CONF_DEVICE_CLASS 10 | from homeassistant.components.humidifier import ( 11 | DOMAIN, 12 | HumidifierDeviceClass, 13 | DEVICE_CLASSES_SCHEMA, 14 | HumidifierEntity, 15 | HumidifierEntityDescription, 16 | HumidifierEntityFeature, 17 | ) 18 | from homeassistant.components.humidifier.const import ( 19 | ATTR_MAX_HUMIDITY, 20 | ATTR_MIN_HUMIDITY, 21 | DEFAULT_MAX_HUMIDITY, 22 | DEFAULT_MIN_HUMIDITY, 23 | ) 24 | 25 | from .const import ( 26 | CONF_HUMIDIFIER_SET_HUMIDITY_DP, 27 | CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP, 28 | CONF_HUMIDIFIER_MODE_DP, 29 | CONF_HUMIDIFIER_AVAILABLE_MODES, 30 | DictSelector, 31 | ) 32 | 33 | from .entity import LocalTuyaEntity, async_setup_entry 34 | 35 | 36 | _LOGGER = logging.getLogger(__name__) 37 | 38 | 39 | def flow_schema(dps): 40 | """Return schema used in config flow.""" 41 | return { 42 | vol.Optional(CONF_HUMIDIFIER_SET_HUMIDITY_DP): col_to_select(dps, is_dps=True), 43 | vol.Optional(CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP): col_to_select( 44 | dps, is_dps=True 45 | ), 46 | vol.Optional(CONF_HUMIDIFIER_MODE_DP): col_to_select(dps, is_dps=True), 47 | vol.Required(ATTR_MIN_HUMIDITY, default=DEFAULT_MIN_HUMIDITY): int, 48 | vol.Required(ATTR_MAX_HUMIDITY, default=DEFAULT_MAX_HUMIDITY): int, 49 | vol.Optional(CONF_HUMIDIFIER_AVAILABLE_MODES, default={}): ObjectSelector(), 50 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, 51 | } 52 | 53 | 54 | class LocalTuyaHumidifier(LocalTuyaEntity, HumidifierEntity): 55 | """Representation of a Localtuya Humidifier.""" 56 | 57 | _dp_mode = CONF_HUMIDIFIER_MODE_DP 58 | _available_modes = CONF_HUMIDIFIER_AVAILABLE_MODES 59 | _dp_current_humidity = CONF_HUMIDIFIER_CURRENT_HUMIDITY_DP 60 | _dp_set_humidity = CONF_HUMIDIFIER_SET_HUMIDITY_DP 61 | _mode_name_to_value = {} 62 | 63 | def __init__( 64 | self, 65 | device, 66 | config_entry, 67 | humidifierID, 68 | **kwargs, 69 | ): 70 | """Initialize the Tuya button.""" 71 | super().__init__(device, config_entry, humidifierID, _LOGGER, **kwargs) 72 | self._state = None 73 | self._current_mode = None 74 | 75 | if (modes := self._config.get(self._available_modes, {})) and ( 76 | self._config.get(self._dp_mode) 77 | ): 78 | self._attr_supported_features |= HumidifierEntityFeature.MODES 79 | modes = { 80 | k: v if k else v.replace("_", " ").capitalize() 81 | for k, v in modes.copy().items() 82 | } 83 | self._available_modes = DictSelector(modes) 84 | 85 | self._attr_min_humidity = self._config.get( 86 | ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY 87 | ) 88 | self._attr_max_humidity = self._config.get( 89 | ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY 90 | ) 91 | 92 | @property 93 | def is_on(self) -> bool: 94 | """Return the device is on or off.""" 95 | return self._state 96 | 97 | @property 98 | def mode(self) -> str | None: 99 | """Return the current mode.""" 100 | return self._current_mode 101 | 102 | @property 103 | def target_humidity(self) -> int | None: 104 | """Return the humidity we try to reach.""" 105 | target_dp = self._config.get(self._dp_set_humidity, None) 106 | return self.dp_value(target_dp) if target_dp else None 107 | 108 | @property 109 | def current_humidity(self) -> int | None: 110 | """Return the current humidity.""" 111 | curr_humidity = self._config.get(self._dp_current_humidity) 112 | 113 | return self.dp_value(self._dp_current_humidity) if curr_humidity else None 114 | 115 | async def async_turn_on(self, **kwargs): 116 | """Turn the device on.""" 117 | await self._device.set_dp(True, self._dp_id) 118 | 119 | async def async_turn_off(self, **kwargs): 120 | """Turn the device off.""" 121 | await self._device.set_dp(False, self._dp_id) 122 | 123 | async def async_set_humidity(self, humidity: int) -> None: 124 | """Set new target humidity.""" 125 | set_humidity_dp = self._config.get(self._dp_set_humidity, None) 126 | if set_humidity_dp is None: 127 | return None 128 | 129 | await self._device.set_dp(humidity, set_humidity_dp) 130 | 131 | @property 132 | def available_modes(self): 133 | """Return the list of presets that this device supports.""" 134 | return self._available_modes.names 135 | 136 | async def async_set_mode(self, mode): 137 | """Set new target preset mode.""" 138 | set_mode_dp = self._config.get(self._dp_mode, None) 139 | if set_mode_dp is None: 140 | return None 141 | 142 | await self._device.set_dp(self._available_modes.to_tuya(mode), set_mode_dp) 143 | 144 | def status_updated(self): 145 | """Device status was updated.""" 146 | super().status_updated() 147 | current_mode = self.dp_value(self._dp_mode) 148 | 149 | self._current_mode = self._available_modes.to_ha(current_mode, "unknown") 150 | 151 | 152 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaHumidifier, flow_schema) 153 | -------------------------------------------------------------------------------- /custom_components/localtuya/lock.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a Lock.""" 2 | 3 | import logging 4 | from functools import partial 5 | from typing import Any 6 | from .config_flow import col_to_select 7 | 8 | import voluptuous as vol 9 | from homeassistant.components.lock import DOMAIN, LockEntity 10 | from .entity import LocalTuyaEntity, async_setup_entry 11 | 12 | from .const import CONF_JAMMED_DP, CONF_LOCK_STATE_DP 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | def flow_schema(dps): 18 | """Return schema used in config flow.""" 19 | return { 20 | vol.Optional(CONF_LOCK_STATE_DP): col_to_select(dps, is_dps=True), 21 | vol.Optional(CONF_JAMMED_DP): col_to_select(dps, is_dps=True), 22 | } 23 | 24 | 25 | class LocalTuyaLock(LocalTuyaEntity, LockEntity): 26 | """Representation of a Tuya Lock.""" 27 | 28 | def __init__( 29 | self, 30 | device, 31 | config_entry, 32 | Lockid, 33 | **kwargs, 34 | ): 35 | """Initialize the Tuya Lock.""" 36 | super().__init__(device, config_entry, Lockid, _LOGGER, **kwargs) 37 | self._state = None 38 | 39 | async def async_lock(self, **kwargs: Any) -> None: 40 | """Lock the lock.""" 41 | await self._device.set_dp(True, self._dp_id) 42 | 43 | async def async_unlock(self, **kwargs: Any) -> None: 44 | """Unlock the lock.""" 45 | await self._device.set_dp(False, self._dp_id) 46 | 47 | def status_updated(self): 48 | """Device status was updated.""" 49 | state = self.dp_value(self._dp_id) 50 | if (lock_state := self.dp_value(CONF_LOCK_STATE_DP)) or lock_state is not None: 51 | state = lock_state 52 | 53 | self._attr_is_locked = state in (False, "closed", "close", None) 54 | 55 | if jammed := self.dp_value(CONF_JAMMED_DP, False): 56 | self._attr_is_jammed = jammed 57 | 58 | # No need to restore state for a Lock 59 | async def restore_state_when_connected(self): 60 | """Do nothing for a Lock.""" 61 | return 62 | 63 | 64 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaLock, flow_schema) 65 | -------------------------------------------------------------------------------- /custom_components/localtuya/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "localtuya", 3 | "name": "Local Tuya", 4 | "codeowners": [], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/xZetsubou/hass-localtuya/", 8 | "integration_type": "hub", 9 | "iot_class": "local_push", 10 | "issue_tracker": "https://github.com/xZetsubou/hass-localtuya/issues", 11 | "requirements": [], 12 | "version": "2025.5.1" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/localtuya/number.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a number.""" 2 | 3 | import logging 4 | from functools import partial 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.number import DOMAIN, NumberEntity, DEVICE_CLASSES_SCHEMA 8 | from homeassistant.const import ( 9 | CONF_DEVICE_CLASS, 10 | STATE_UNKNOWN, 11 | CONF_UNIT_OF_MEASUREMENT, 12 | ) 13 | 14 | from .entity import LocalTuyaEntity, async_setup_entry 15 | from .const import ( 16 | CONF_DEFAULT_VALUE, 17 | CONF_MAX_VALUE, 18 | CONF_MIN_VALUE, 19 | CONF_PASSIVE_ENTITY, 20 | CONF_RESTORE_ON_RECONNECT, 21 | CONF_SCALING, 22 | CONF_STEPSIZE, 23 | ) 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | DEFAULT_MIN = 0 28 | DEFAULT_MAX = 100000 29 | DEFAULT_STEP = 1.0 30 | 31 | 32 | def flow_schema(dps): 33 | """Return schema used in config flow.""" 34 | return { 35 | vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All( 36 | vol.Coerce(float), 37 | vol.Range(min=-1000000.0, max=1000000.0), 38 | ), 39 | vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All( 40 | vol.Coerce(float), 41 | vol.Range(min=-1000000.0, max=1000000.0), 42 | ), 43 | vol.Required(CONF_STEPSIZE, default=DEFAULT_STEP): vol.All( 44 | vol.Coerce(float), vol.Range(min=0.0, max=1000000.0) 45 | ), 46 | vol.Optional(CONF_RESTORE_ON_RECONNECT, default=False): bool, 47 | vol.Optional(CONF_PASSIVE_ENTITY, default=False): bool, 48 | vol.Optional(CONF_DEFAULT_VALUE): str, 49 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, 50 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(None, str), 51 | vol.Optional(CONF_SCALING): vol.All( 52 | vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) 53 | ), 54 | } 55 | 56 | 57 | class LocalTuyaNumber(LocalTuyaEntity, NumberEntity): 58 | """Representation of a Tuya Number.""" 59 | 60 | def __init__( 61 | self, 62 | device, 63 | config_entry, 64 | sensorid, 65 | **kwargs, 66 | ): 67 | """Initialize the Tuya sensor.""" 68 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 69 | self._state = STATE_UNKNOWN 70 | 71 | self._min_value = self.scale(self._config.get(CONF_MIN_VALUE, DEFAULT_MIN)) 72 | self._max_value = self.scale(self._config.get(CONF_MAX_VALUE, DEFAULT_MAX)) 73 | self._step_size = self.scale(self._config.get(CONF_STEPSIZE, DEFAULT_STEP)) 74 | 75 | # Override standard default value handling to cast to a float 76 | default_value = self._config.get(CONF_DEFAULT_VALUE) 77 | if default_value is not None: 78 | self._default_value = float(default_value) 79 | 80 | @property 81 | def native_value(self) -> float: 82 | """Return sensor state.""" 83 | self._state = self.scale(self._state) 84 | return self._state 85 | 86 | @property 87 | def native_min_value(self) -> float: 88 | """Return the minimum value.""" 89 | return self._min_value 90 | 91 | @property 92 | def native_max_value(self) -> float: 93 | """Return the maximum value.""" 94 | return self._max_value 95 | 96 | @property 97 | def native_step(self) -> float: 98 | """Return the maximum value.""" 99 | return self._step_size 100 | 101 | @property 102 | def native_unit_of_measurement(self): 103 | """Return the unit of measurement of this entity, if any.""" 104 | return self._config.get(CONF_UNIT_OF_MEASUREMENT) 105 | 106 | @property 107 | def device_class(self): 108 | """Return the class of this device.""" 109 | return self._config.get(CONF_DEVICE_CLASS) 110 | 111 | async def async_set_native_value(self, value: float) -> None: 112 | """Update the current value.""" 113 | if scale_factor := self._config.get(CONF_SCALING): 114 | value = value / float(scale_factor) 115 | 116 | await self._device.set_dp(int(value), self._dp_id) 117 | 118 | # Default value is the minimum value 119 | def entity_default_value(self): 120 | """Return the minimum value as the default for this entity type.""" 121 | return self._min_value 122 | 123 | 124 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaNumber, flow_schema) 125 | -------------------------------------------------------------------------------- /custom_components/localtuya/select.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as an enumeration.""" 2 | 3 | import logging 4 | from functools import partial 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.select import DOMAIN, SelectEntity 8 | from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN 9 | from homeassistant.helpers import selector 10 | 11 | from .entity import LocalTuyaEntity, async_setup_entry 12 | from .const import ( 13 | CONF_DEFAULT_VALUE, 14 | CONF_OPTIONS, 15 | CONF_PASSIVE_ENTITY, 16 | CONF_RESTORE_ON_RECONNECT, 17 | DictSelector, 18 | ) 19 | 20 | 21 | def flow_schema(dps): 22 | """Return schema used in config flow.""" 23 | return { 24 | vol.Required(CONF_OPTIONS, default={}): selector.ObjectSelector(), 25 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool, 26 | vol.Required(CONF_PASSIVE_ENTITY): bool, 27 | vol.Optional(CONF_DEFAULT_VALUE): str, 28 | } 29 | 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | 34 | class LocalTuyaSelect(LocalTuyaEntity, SelectEntity): 35 | """Representation of a Tuya Enumeration.""" 36 | 37 | def __init__( 38 | self, 39 | device, 40 | config_entry, 41 | sensorid, 42 | **kwargs, 43 | ): 44 | """Initialize the Tuya sensor.""" 45 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 46 | self._state = STATE_UNKNOWN 47 | self._state_friendly = "" 48 | 49 | # Set Display options 50 | options = {} 51 | config_options: dict = self._config.get(CONF_OPTIONS, {}) 52 | if not isinstance(config_options, dict): 53 | self.warning( 54 | f"{self.name} DPiD: {self._dp_id}: Options configured incorrectly!" 55 | + "It must be in the format of key-value pairs," 56 | + "where each line follows the structure [device_value: friendly name]" 57 | ) 58 | config_options = {} 59 | for k, v in config_options.items(): 60 | options[k] = v if v else k.replace("_", "").capitalize() 61 | 62 | self._options = DictSelector(options) 63 | 64 | @property 65 | def current_option(self) -> str: 66 | """Return the current value.""" 67 | return self._state_friendly 68 | 69 | @property 70 | def options(self) -> list: 71 | """Return the list of values.""" 72 | return self._options.names 73 | 74 | @property 75 | def device_class(self): 76 | """Return the class of this device.""" 77 | return self._config.get(CONF_DEVICE_CLASS) 78 | 79 | async def async_select_option(self, option: str) -> None: 80 | """Update the current value.""" 81 | option_value = self._options.to_tuya(option) 82 | self.debug("Sending Option: " + option + " -> " + option_value) 83 | await self._device.set_dp(option_value, self._dp_id) 84 | 85 | def status_updated(self): 86 | """Device status was updated.""" 87 | super().status_updated() 88 | 89 | if (state := self.dp_value(self._dp_id)) is not None: 90 | self._state_friendly = self._options.to_ha(state, state) 91 | 92 | # Default value is the first option 93 | def entity_default_value(self): 94 | """Return the first option as the default value for this entity type.""" 95 | return self._options.names[0] 96 | 97 | 98 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSelect, flow_schema) 99 | -------------------------------------------------------------------------------- /custom_components/localtuya/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a sensor.""" 2 | 3 | import logging 4 | import base64 5 | from functools import partial 6 | from .config_flow import col_to_select 7 | 8 | import voluptuous as vol 9 | from homeassistant.components.sensor import ( 10 | DEVICE_CLASSES_SCHEMA, 11 | DOMAIN, 12 | STATE_CLASSES_SCHEMA, 13 | SensorDeviceClass, 14 | SensorEntity, 15 | SensorStateClass, 16 | ) 17 | from homeassistant.const import ( 18 | CONF_DEVICE_CLASS, 19 | CONF_UNIT_OF_MEASUREMENT, 20 | Platform, 21 | STATE_UNKNOWN, 22 | UnitOfElectricCurrent, 23 | UnitOfElectricPotential, 24 | UnitOfPower, 25 | ) 26 | from homeassistant.helpers import entity_registry as er 27 | 28 | from .entity import LocalTuyaEntity, async_setup_entry 29 | from .const import CONF_SCALING, CONF_STATE_CLASS 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | DEFAULT_PRECISION = 2 34 | 35 | ATTR_POWER = "power" 36 | ATTR_VOLTAGE = "voltage" 37 | ATTR_CURRENT = "current" 38 | MAP_UOM = { 39 | ATTR_CURRENT: UnitOfElectricCurrent.AMPERE, 40 | ATTR_VOLTAGE: UnitOfElectricPotential.VOLT, 41 | ATTR_POWER: UnitOfPower.KILO_WATT, 42 | } 43 | 44 | 45 | def flow_schema(dps): 46 | """Return schema used in config flow.""" 47 | return { 48 | vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, 49 | vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, 50 | vol.Optional(CONF_STATE_CLASS): col_to_select( 51 | [sc.value for sc in SensorStateClass] 52 | ), 53 | vol.Optional(CONF_SCALING): vol.All( 54 | vol.Coerce(float), vol.Range(min=-1000000.0, max=1000000.0) 55 | ), 56 | } 57 | 58 | 59 | class LocalTuyaSensor(LocalTuyaEntity, SensorEntity): 60 | """Representation of a Tuya sensor.""" 61 | 62 | def __init__( 63 | self, 64 | device, 65 | config_entry, 66 | sensorid, 67 | **kwargs, 68 | ): 69 | """Initialize the Tuya sensor.""" 70 | super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs) 71 | self._state = None 72 | 73 | self._has_sub_entities = False 74 | self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) 75 | 76 | @property 77 | def native_value(self): 78 | """Return sensor state.""" 79 | return self._state 80 | 81 | @property 82 | def state_class(self) -> str | None: 83 | """Return state class.""" 84 | return getattr(self, "_attr_state_class", self._config.get(CONF_STATE_CLASS)) 85 | 86 | @property 87 | def native_unit_of_measurement(self): 88 | """Return the unit of measurement of this entity, if any.""" 89 | return getattr( 90 | self, 91 | "_attr_native_unit_of_measurement", 92 | self._config.get(CONF_UNIT_OF_MEASUREMENT), 93 | ) 94 | 95 | def status_updated(self): 96 | """Device status was updated.""" 97 | 98 | state = self.dp_value(self._dp_id) 99 | 100 | if self.is_base64(state): 101 | if not self._has_sub_entities: 102 | self.hass.add_job(self.__create_sub_sensors()) 103 | 104 | if None not in ( 105 | sub_sensor := getattr(self, "_attr_sub_sensor", None), 106 | sub_sensor_state := self.decode_base64(state).get(sub_sensor), 107 | ): 108 | self._state = sub_sensor_state 109 | else: 110 | self._state = state 111 | else: 112 | self._state = self.scale(state) 113 | 114 | def status_restored(self, stored_state) -> None: 115 | super().status_restored(stored_state) 116 | 117 | if (last_state := self._last_state) and self.is_base64(last_state): 118 | self._status.update({self._dp_id: last_state}) 119 | 120 | # No need to restore state for a sensor 121 | async def restore_state_when_connected(self): 122 | """Do nothing for a sensor.""" 123 | return 124 | 125 | def is_base64(self, data): 126 | """Return if the data is valid Tuya raw Base64 encoded data.""" 127 | return ( 128 | (data and isinstance(data, str)) 129 | and len(data) >= 12 130 | and len(data) % 2 == 0 131 | and data.endswith("=") 132 | ) 133 | 134 | def decode_base64(self, data): 135 | """Decode data base64 such as DPS phase_a.""" 136 | buf = base64.b64decode(data) 137 | voltage = (buf[1] | buf[0] << 8) / 10 138 | current = (buf[4] | buf[3] << 8) / 1000 139 | power = (buf[7] | buf[6] << 8) / 1000 140 | return {ATTR_VOLTAGE: voltage, ATTR_CURRENT: current, ATTR_POWER: power} 141 | 142 | async def __create_sub_sensors(self): 143 | """Create sub entities for voltage, current and power and hide this parent sensor.""" 144 | sub_entities = [] 145 | 146 | for sensor in (ATTR_CURRENT, ATTR_POWER, ATTR_VOLTAGE): 147 | sub_entity = LocalTuyaSensor( 148 | self._device, self._device_config.as_dict(), self._dp_id 149 | ) 150 | setattr(sub_entity, "_attr_sub_sensor", sensor) 151 | setattr(sub_entity, "_attr_unique_id", f"{self.unique_id}_{sensor}") 152 | setattr(sub_entity, "_attr_name", f"{self.name} {sensor.capitalize()}") 153 | setattr(sub_entity, "_attr_device_class", SensorDeviceClass(sensor)) 154 | setattr(sub_entity, "_attr_state_class", SensorStateClass.MEASUREMENT) 155 | setattr(sub_entity, "_attr_native_unit_of_measurement", MAP_UOM[sensor]) 156 | sub_entities.append(sub_entity) 157 | 158 | # Sub entities shouldn't have add entities attr. 159 | if sub_entities and self.componet_add_entities: 160 | self._has_sub_entities = True 161 | self.componet_add_entities(sub_entities) 162 | er.async_get(self.hass).async_update_entity( 163 | self.entity_id, hidden_by=er.RegistryEntryHider.INTEGRATION 164 | ) 165 | 166 | 167 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSensor, flow_schema) 168 | -------------------------------------------------------------------------------- /custom_components/localtuya/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | name: "Reload" 3 | description: Reload localtuya and reconnect to all devices. 4 | 5 | set_dp: 6 | name: "Set DP Value" 7 | description: Change the value of a datapoint (DP) 8 | fields: 9 | device_id: 10 | name: "Device ID" 11 | description: The device ID of the device where the datapoint value needs to be changed 12 | required: true 13 | example: 11100118278aab4de001 14 | selector: 15 | text: 16 | dp: 17 | name: "DP" 18 | description: Target DP, Datapoint index 19 | required: false 20 | example: 1 21 | selector: 22 | number: 23 | mode: box 24 | value: 25 | name: "Value" 26 | description: "A new value to set or a list of DP-value pairs. If a list is provided, the target DP will be ignored" 27 | required: true 28 | example: '{ "1": True, "2": True }' 29 | selector: 30 | object: 31 | 32 | remote_add_code: 33 | name: "Add Remote Code" 34 | description: Add the remote code to the device's remote storage. 35 | fields: 36 | target: 37 | name: "Choose remote device" 38 | description: "Select the remote to store the code on it" 39 | required: true 40 | selector: 41 | device: 42 | multiple: false 43 | entity: 44 | domain: "remote" 45 | filter: 46 | integration: "localtuya" 47 | device_name: 48 | name: "Device Name" 49 | description: The name of the device to store the code in 50 | required: true 51 | example: TV 52 | selector: 53 | text: 54 | command_name: 55 | name: "Command Name" 56 | description: The command name to use when calling it 57 | required: true 58 | example: volume_up 59 | selector: 60 | text: 61 | base64: 62 | name: "Base64 Code" 63 | description: The Base64 code (this will override the head/key values) 64 | required: false 65 | selector: 66 | text: 67 | head: 68 | name: "Head" 69 | description: "The header can be found in the Tuya IoT device debug logs, Key's required" 70 | required: false 71 | selector: 72 | text: 73 | key: 74 | name: "Key" 75 | description: "The key can be found in the Tuya IoT device debug logs, Head's required" 76 | required: false 77 | selector: 78 | text: 79 | -------------------------------------------------------------------------------- /custom_components/localtuya/siren.py: -------------------------------------------------------------------------------- 1 | """Platform to present any Tuya DP as a siren.""" 2 | 3 | import logging 4 | from functools import partial 5 | 6 | import voluptuous as vol 7 | from homeassistant.components.siren import DOMAIN, SirenEntity, SirenEntityFeature 8 | 9 | from .entity import LocalTuyaEntity, async_setup_entry 10 | from .const import CONF_STATE_ON 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | # CONF_STATE_MAP = ["True and False", "ON and OFF"] 15 | 16 | 17 | def flow_schema(dps): 18 | """Return schema used in config flow.""" 19 | return { 20 | vol.Required(CONF_STATE_ON, default="true"): str, 21 | # vol.Required(CONF_STATE_OFF, default="False"): str, 22 | } 23 | 24 | 25 | class LocalTuyaSiren(LocalTuyaEntity, SirenEntity): 26 | """Representation of a Tuya siren.""" 27 | 28 | _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF 29 | 30 | def __init__( 31 | self, 32 | device, 33 | config_entry, 34 | sirenid, 35 | **kwargs, 36 | ): 37 | """Initialize the Tuya siren.""" 38 | super().__init__(device, config_entry, sirenid, _LOGGER, **kwargs) 39 | self._is_on = False 40 | 41 | @property 42 | def is_on(self): 43 | """Return siren state.""" 44 | return self._is_on 45 | 46 | async def async_turn_on(self, **kwargs): 47 | """Turn Tuya siren on.""" 48 | await self._device.set_dp(True, self._dp_id) 49 | 50 | async def async_turn_off(self, **kwargs): 51 | """Turn Tuya siren off.""" 52 | await self._device.set_dp(False, self._dp_id) 53 | 54 | # No need to restore state for a siren 55 | async def restore_state_when_connected(self): 56 | """Do nothing for a siren.""" 57 | return 58 | 59 | def status_updated(self): 60 | """Device status was updated.""" 61 | super().status_updated() 62 | 63 | state = str(self.dp_value(self._dp_id)).lower() 64 | if state == self._config[CONF_STATE_ON].lower() or state == "true": 65 | self._is_on = True 66 | else: 67 | self._is_on = False 68 | 69 | 70 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSiren, flow_schema) 71 | -------------------------------------------------------------------------------- /custom_components/localtuya/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "This Account ID has already been configured.", 5 | "unsupported_device_type": "Unsupported device type!" 6 | }, 7 | "error": { 8 | "cannot_connect": "Cannot connect to device. Verify that address is correct.", 9 | "invalid_auth": "Failed to authenticate with device. Verify that device id and local key are correct.", 10 | "unknown": "An unknown error occurred. See log for details.", 11 | "switch_already_configured": "Switch with this ID has already been configured." 12 | }, 13 | "step": { 14 | "user": { 15 | "title": "Main Configuration", 16 | "description": "Input the credentials for Tuya Cloud API.", 17 | "data": { 18 | "region": "API server region", 19 | "client_id": "Client ID", 20 | "client_secret": "Secret", 21 | "user_id": "User ID" 22 | } 23 | }, 24 | "power_outlet": { 25 | "title": "Add subswitch", 26 | "description": "You are about to add subswitch number `{number}`. If you want to add another, tick `Add another switch` before continuing.", 27 | "data": { 28 | "id": "ID", 29 | "name": "Name", 30 | "friendly_name": "Friendly name", 31 | "current": "Current", 32 | "current_consumption": "Current Consumption", 33 | "voltage": "Voltage", 34 | "add_another_switch": "Add another switch" 35 | } 36 | } 37 | } 38 | }, 39 | "options": { 40 | "step": { 41 | "init": { 42 | "title": "LocalTuya Configuration", 43 | "description": "Please select the desired actionSSSS.", 44 | "data": { 45 | "add_device": "Add a new device", 46 | "edit_device": "Edit a device", 47 | "delete_device": "Delete a device", 48 | "setup_cloud": "Reconfigure Cloud API account" 49 | } 50 | }, 51 | "entity": { 52 | "title": "Entity Configuration", 53 | "description": "Editing entity with DPS `{id}` and platform `{platform}`.", 54 | "data": { 55 | "id": "ID", 56 | "friendly_name": "Friendly name", 57 | "current": "Current", 58 | "current_consumption": "Current Consumption", 59 | "voltage": "Voltage", 60 | "commands_set": "Open_Close_Stop Commands Set", 61 | "positioning_mode": "Positioning mode", 62 | "current_position_dp": "Current Position (for *position* mode only)", 63 | "set_position_dp": "Set Position (for *position* mode only)", 64 | "position_inverted": "Invert 0-100 position (for *position* mode only)", 65 | "span_time": "Full opening time, in secs. (for *timed* mode only)", 66 | "unit_of_measurement": "Unit of Measurement", 67 | "device_class": "Device Class", 68 | "scaling": "Scaling Factor", 69 | "state_on": "On Value", 70 | "state_off": "Off Value", 71 | "powergo_dp": "Power DP (Usually 25 or 2)", 72 | "idle_status_value": "Idle Status (comma-separated)", 73 | "returning_status_value": "Returning Status", 74 | "docked_status_value": "Docked Status (comma-separated)", 75 | "fault_dp": "Fault DP (Usually 11)", 76 | "battery_dp": "Battery status DP (Usually 14)", 77 | "mode_dp": "Mode DP (Usually 27)", 78 | "modes": "Modes list", 79 | "return_mode": "Return home mode", 80 | "fan_speed_dp": "Fan speeds DP (Usually 30)", 81 | "fan_speeds": "Fan speeds list (comma-separated)", 82 | "clean_time_dp": "Clean Time DP (Usually 33)", 83 | "clean_area_dp": "Clean Area DP (Usually 32)", 84 | "clean_record_dp": "Clean Record DP (Usually 34)", 85 | "locate_dp": "Locate DP (Usually 31)", 86 | "paused_state": "Pause state (pause, paused, etc)", 87 | "stop_status": "Stop status", 88 | "brightness": "Brightness (only for white color)", 89 | "brightness_lower": "Brightness Lower Value", 90 | "brightness_upper": "Brightness Upper Value", 91 | "color_temp": "Color Temperature", 92 | "color_temp_reverse": "Color Temperature Reverse", 93 | "color": "Color", 94 | "color_mode": "Color Mode", 95 | "color_temp_min_kelvin": "Minimum Color Temperature in K", 96 | "color_temp_max_kelvin": "Maximum Color Temperature in K", 97 | "music_mode": "Music mode available", 98 | "scene": "Scene", 99 | "scene_values": "Scene values, separate entries by a ;", 100 | "scene_values_friendly": "User friendly scene values, separate entries by a ;", 101 | "fan_speed_control": "Fan Speed Control dps", 102 | "fan_oscillating_control": "Fan Oscillating Control dps", 103 | "fan_speed_min": "minimum fan speed integer", 104 | "fan_speed_max": "maximum fan speed integer", 105 | "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", 106 | "fan_direction":"fan direction dps", 107 | "fan_direction_forward": "forward dps string", 108 | "fan_direction_reverse": "reverse dps string", 109 | "fan_dps_type": "DP value type", 110 | "current_temperature_dp": "Current Temperature", 111 | "target_temperature_dp": "Target Temperature", 112 | "temperature_step": "Temperature Step (optional)", 113 | "max_temperature_dp": "Max Temperature (optional)", 114 | "min_temperature_dp": "Min Temperature (optional)", 115 | "precision": "Precision (optional, for DPs values)", 116 | "target_precision": "Target Precision (optional, for DPs values)", 117 | "temperature_unit": "Temperature Unit (optional)", 118 | "hvac_mode_dp": "HVAC Mode DP (optional)", 119 | "hvac_mode_set": "HVAC Mode Set (optional)", 120 | "hvac_action_dp": "HVAC Current Action DP (optional)", 121 | "hvac_action_set": "HVAC Current Action Set (optional)", 122 | "preset_dp": "Presets DP (optional)", 123 | "preset_set": "Presets Set (optional)", 124 | "eco_dp": "Eco DP (optional)", 125 | "eco_value": "Eco value (optional)", 126 | "heuristic_action": "Enable heuristic action (optional)", 127 | "dps_default_value": "Default value when un-initialised (optional)", 128 | "restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection", 129 | "min_value": "Minimum Value", 130 | "max_value": "Maximum Value", 131 | "step_size": "Minimum increment between numbers" 132 | } 133 | }, 134 | "yaml_import": { 135 | "title": "Not Supported", 136 | "description": "Options cannot be edited when configured via YAML." 137 | } 138 | } 139 | }, 140 | "title": "LocalTuya" 141 | } 142 | -------------------------------------------------------------------------------- /custom_components/localtuya/switch.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based switch devices.""" 2 | 3 | import logging 4 | from functools import partial 5 | from .config_flow import col_to_select 6 | 7 | import voluptuous as vol 8 | from homeassistant.components.switch import ( 9 | DOMAIN, 10 | SwitchEntity, 11 | DEVICE_CLASSES_SCHEMA, 12 | SwitchDeviceClass, 13 | ) 14 | from homeassistant.const import CONF_DEVICE_CLASS 15 | 16 | from .entity import LocalTuyaEntity, async_setup_entry 17 | from .const import ( 18 | ATTR_CURRENT, 19 | ATTR_CURRENT_CONSUMPTION, 20 | ATTR_STATE, 21 | ATTR_VOLTAGE, 22 | CONF_CURRENT, 23 | CONF_CURRENT_CONSUMPTION, 24 | CONF_DEFAULT_VALUE, 25 | CONF_PASSIVE_ENTITY, 26 | CONF_RESTORE_ON_RECONNECT, 27 | CONF_VOLTAGE, 28 | ) 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | 33 | def flow_schema(dps): 34 | """Return schema used in config flow.""" 35 | return { 36 | vol.Optional(CONF_CURRENT): col_to_select(dps, is_dps=True), 37 | vol.Optional(CONF_CURRENT_CONSUMPTION): col_to_select(dps, is_dps=True), 38 | vol.Optional(CONF_VOLTAGE): col_to_select(dps, is_dps=True), 39 | vol.Required(CONF_RESTORE_ON_RECONNECT): bool, 40 | vol.Required(CONF_PASSIVE_ENTITY): bool, 41 | vol.Optional(CONF_DEFAULT_VALUE): str, 42 | vol.Optional(CONF_DEVICE_CLASS): col_to_select( 43 | [sc.value for sc in SwitchDeviceClass] 44 | ), 45 | } 46 | 47 | 48 | class LocalTuyaSwitch(LocalTuyaEntity, SwitchEntity): 49 | """Representation of a Tuya switch.""" 50 | 51 | _attr_device_class = SwitchDeviceClass.SWITCH 52 | 53 | def __init__( 54 | self, 55 | device, 56 | config_entry, 57 | switchid, 58 | **kwargs, 59 | ): 60 | """Initialize the Tuya switch.""" 61 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) 62 | self._state = None 63 | 64 | @property 65 | def is_on(self): 66 | """Check if Tuya switch is on.""" 67 | return self._state 68 | 69 | @property 70 | def extra_state_attributes(self): 71 | """Return device state attributes.""" 72 | attrs = {} 73 | if self.has_config(CONF_CURRENT): 74 | attrs[ATTR_CURRENT] = self.dp_value(self._config[CONF_CURRENT]) 75 | if self.has_config(CONF_CURRENT_CONSUMPTION): 76 | val_cc = self.dp_value(self._config[CONF_CURRENT_CONSUMPTION]) 77 | attrs[ATTR_CURRENT_CONSUMPTION] = None if val_cc is None else val_cc / 10 78 | if self.has_config(CONF_VOLTAGE): 79 | val_vol = self.dp_value(self._config[CONF_VOLTAGE]) 80 | attrs[ATTR_VOLTAGE] = None if val_vol is None else val_vol / 10 81 | 82 | # Store the state 83 | if self._state is not None: 84 | attrs[ATTR_STATE] = self._state 85 | elif self._last_state is not None: 86 | attrs[ATTR_STATE] = self._last_state 87 | return attrs 88 | 89 | async def async_turn_on(self, **kwargs): 90 | """Turn Tuya switch on.""" 91 | await self._device.set_dp(True, self._dp_id) 92 | 93 | async def async_turn_off(self, **kwargs): 94 | """Turn Tuya switch off.""" 95 | await self._device.set_dp(False, self._dp_id) 96 | 97 | # Default value is the "OFF" state 98 | def entity_default_value(self): 99 | """Return False as the default value for this entity type.""" 100 | return False 101 | 102 | 103 | async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaSwitch, flow_schema) 104 | -------------------------------------------------------------------------------- /custom_components/localtuya/templates/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/custom_components/localtuya/templates/__init__.py -------------------------------------------------------------------------------- /custom_components/localtuya/templates/sample_2g_switch.yaml: -------------------------------------------------------------------------------- 1 | # Simple 2 Switches config example 2 | - switch: 3 | id: "1" 4 | friendly_name: "2G Local Switch 1" 5 | entity_category: None 6 | restore_on_reconnect: false 7 | is_passive_entity: false 8 | platform: "switch" 9 | 10 | - switch: 11 | id: "2" 12 | friendly_name: "2G Local Switch 2" 13 | entity_category: None 14 | is_passive_entity: false 15 | platform: "switch" 16 | #################################################### 17 | #---# Templates Guide #---# 18 | #################################################### 19 | # Templates: 20 | # The template is basically ready to go configs can be imported instead of choosing configs DPs names etc... 21 | 22 | # IMPORTANT: 23 | # there is now valid check atm config so make sure you're importing correct configs. 24 | 25 | # the configs depends on the platform and what input does platform support read bottom. 26 | 27 | # THERE Is 2 ways to make template: 28 | # - 1st is write the yaml ur self: 29 | # --[ Keep in mind there is no valid check atm ] 30 | 31 | # - 2nd is to export ur device file from config flow. [ Recommended ]: 32 | # -- in HA Dashboard go to [ Devices -> localtuya -> Configure -> Edit Device * choose the device u want to export 33 | # --- Export the device config then submit] 34 | 35 | # Templates DIR: 36 | # the configs will be exported in [custom_components/localtuya/templates] 37 | 38 | # How to import: 39 | # -- When u add new device when the form [ Pick Entity type selection ] 40 | # --- Import template Form will show up showing available templates in templates folder. 41 | 42 | # -- templates in [custom_components/localtuya/templates] 43 | # -- Templates files will load up with HA so adding files will require restarting HA to show up. 44 | -------------------------------------------------------------------------------- /custom_components/localtuya/templates/sample_lights_bulb.yaml: -------------------------------------------------------------------------------- 1 | - light: 2 | brightness: '22' 3 | brightness_lower: 29 4 | brightness_upper: 1000 5 | color: '24' 6 | color_mode: '21' 7 | color_temp: '23' 8 | color_temp_max_kelvin: 6500 9 | color_temp_min_kelvin: 2700 10 | color_temp_reverse: false 11 | entity_category: None 12 | friendly_name: test_light_35 13 | id: '20' 14 | music_mode: true 15 | platform: light 16 | scene: '25' 17 | -------------------------------------------------------------------------------- /custom_components/localtuya/translations/ar.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "تم تكوين هذا الحساب بالفعل.", 5 | "device_updated": "تم تحديث تكوين الجهاز." 6 | }, 7 | "error": { 8 | "authentication_failed": "فشلت عملية المصادقة.\n{msg}", 9 | "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.", 10 | "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}", 11 | "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.", 12 | "unknown": "حدث خطأ غير معروف.\n{ex}.", 13 | "entity_already_configured": "تم تكوين هذه الكيان بالفعل.", 14 | "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.", 15 | "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).", 16 | "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)." 17 | }, 18 | "step": { 19 | "user": { 20 | "title": "تكوين حساب Cloud API", 21 | "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.", 22 | "data": { 23 | "region": "منطقة مركز البيانات", 24 | "client_id": "معرف العميل (Client ID)", 25 | "client_secret": "المعرف السري العميل (Client Secret)", 26 | "user_id": "معرف المستخدم (UID)", 27 | "username": "اسم المستخدم", 28 | "no_cloud": "هل تريد تعطيل Cloud API؟" 29 | } 30 | } 31 | } 32 | }, 33 | "options": { 34 | "abort": { 35 | "already_configured": "تم تكوين هذا الحساب بالفعل.", 36 | "device_success": "تم {action} الجهاز {dev_name} بنجاح.", 37 | "no_entities": "لا يمكن حذف كل الكيانات من الجهاز.\nإذا كنت ترغب في حذف الجهاز: انتقل إلى القائمة 'الأجهزة والخدمات'، ابحث عن جهازك في علامة التبويب 'الأجهزة'، انقر على 3 نقاط في الإطار 'معلومات الجهاز'، واضغط على زر 'حذف'." 38 | }, 39 | "error": { 40 | "authentication_failed": "فشلت عملية المصادقة.\n{msg}", 41 | "cannot_connect": "لا يمكن الاتصال بالجهاز. تحقق من صحة عنوان IP ثم حاول مرة أخرى.", 42 | "device_list_failed": "فشل استرجاع قائمة الأجهزة.\n{msg}", 43 | "invalid_auth": "فشلت عملية المصادقة مع الجهاز. تأكد من صحة معرّف الجهاز والمفتاح المحلي.", 44 | "unknown": "حدث خطأ غير معروف. \n{ex}.", 45 | "entity_already_configured": "تم تكوين هذه الكيان بالفعل.", 46 | "address_in_use": "منفذ TCP 6668 (المستخدم للاكتشاف) قيد الاستخدام بالفعل. تحقق من عدم استخدام أي تكامل آخر له.", 47 | "discovery_failed": "حدث خطأ عند اكتشاف الأجهزة. انظر إلى السجل للتفاصيل. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية).", 48 | "empty_dps": "نجح الاتصال بالجهاز ولكن لم يتم العثور على نقاط البيانات. يُرجى المحاولة مرة أخرى. إذا استمرت المشكلة، قم بإنشاء مشكلة جديدة (بما في ذلك السجلات التصحيحية)." 49 | }, 50 | "step": { 51 | "yaml_import": { 52 | "title": "غير معتمد", 53 | "description": "الأجهزة المكونة باستخدام `YAML` لا يمكن تكوينها في واجهة المستخدم. احذف جهازك من `YAML` وأعِد إنشاؤه في واجهة المستخدم أو قم بتعديل تكوين `YAML` الخاص بك." 54 | }, 55 | "init": { 56 | "title": "التكوين", 57 | "description": "حدد خيارًا للمتابعة.", 58 | "menu_options": { 59 | "add_device": "إضافة جهاز جديد", 60 | "edit_device": "إعادة تكوين الجهاز موجود", 61 | "configure_cloud": "إدارة حساب Cloud API" 62 | } 63 | }, 64 | "add_device": { 65 | "title": "اختيار الجهاز للتكوين", 66 | "description": "يتم اكتشاف الأجهزة المتوافقة مع Tuya على شبكتك المحلية تلقائيًا بمجرد إعدادها في تطبيق Tuya. إذا لم تر الجهاز الذي تتوقعه، اختر `إضافة الجهاز يدويًا` من القائمة المنسدلة.", 67 | "data": { 68 | "selected_device": "الأجهزة المكتشفة", 69 | "mass_configure": "ضبط جميع الأجهزة المتعرف عليها تلقائيًا" 70 | } 71 | }, 72 | "edit_device": { 73 | "title": "إعادة تكوين الجهاز الموجود", 74 | "description": "حدد الجهاز الذي ترغب في إعادة تكوينه.", 75 | "data": { 76 | "selected_device": "الأجهزة المكونة" 77 | } 78 | }, 79 | "configure_cloud": { 80 | "title": "إدارة حساب Cloud API", 81 | "description": "قم بتكوين بيانات الاعتماد المستخدمة للاتصال بـ Tuya Cloud API.", 82 | "data": { 83 | "region": "منطقة مركز البيانات", 84 | "client_id": "معرف العميل (Client ID)", 85 | "client_secret": "المعرف السري العميل (Client Secret)", 86 | "user_id": "معرف المستخدم (UID)", 87 | "username": "اسم للحساب", 88 | "no_cloud": "هل تريد تعطيل Cloud API؟" 89 | } 90 | }, 91 | "configure_device": { 92 | "title": "تكوين اتصال الجهاز", 93 | "description": "قم بتكوين أي تفاصيل جهاز {for_device} فارغة (إن وجدت) للسماح لـ LocalTuya بالاتصال بالجهاز.", 94 | "data": { 95 | "friendly_name": "اسم الجهاز", 96 | "host": "عنوان IP", 97 | "device_id": "معرف الجهاز", 98 | "local_key": "المفتاح المحلي (Local Key)", 99 | "node_id": "(اختياري) معرف الأجهزة الفرعية", 100 | "protocol_version": "إصدار البروتوكول", 101 | "enable_debug": "تمكين التصحيح (يجب تمكينه يدويًا في `configuration.yaml` أيضًا)", 102 | "scan_interval": "(اختياري) الفاصل الزمني للمسح بالثواني، إذا كان الجهاز لا يمسح تلقائيًا", 103 | "entities": "الكيانات التي تم تكوينها (قم بإلغاء التحديد للحذف)", 104 | "add_entities": "إضافة كيان (كيانات) جديدة", 105 | "manual_dps_strings": "(اختياري) دليل DPS، إذا لم يتم اكتشافه تلقائيًا (مفصولاً بفواصل)", 106 | "reset_dpids": "(اختياري) معرفات DPID لإرسالها في أمر RESET، إذا لم يستجب الجهاز لطلبات الحالة بعد التشغيل (مفصولة بفواصل)", 107 | "device_sleep_time": "(اختياري) وقت سبات الجهاز بالثواني: في حالة أن الجهاز يقوم بإرسال الحالة ثم يدخل في وضع السكون", 108 | "export_config": "احفظ تكوين الكيان كقالب" 109 | } 110 | }, 111 | "device_setup_method": { 112 | "title": "تكوين كيانات الجهاز", 113 | "description": "سيحاول LocalTuya اكتشاف بقية التكوين تلقائيًا. ", 114 | "menu_options": { 115 | "auto_configure_device": "اكتشف كيانات الجهاز تلقائيًا", 116 | "pick_entity_type": "قم بتكوين كيانات الجهاز يدويًا", 117 | "choose_template": "استخدم القالب المحفوظ" 118 | } 119 | }, 120 | "auto_configure_device": { 121 | "title": "التكوين التلقائي", 122 | "description": "حدث خطأ: {err_msg}. ", 123 | "menu_options": { 124 | "device_setup_method": "العودة إلى طريقة الإعداد" 125 | } 126 | }, 127 | "pick_entity_type": { 128 | "title": "اختيار نوع الكيان", 129 | "description": "اختر نوع الكيان الذي تريد إضافته.", 130 | "data": { 131 | "platform_to_add": "اختر الكيان", 132 | "no_additional_entities": "الانتهاء من تكوين الكيانات", 133 | "use_template": "استيراد ملف القالب" 134 | } 135 | }, 136 | "choose_template": { 137 | "title": "استيراد ملف القالب", 138 | "description": "توجد ملفات القالب في المجلد `templates` ([لمعلومات أكثر](https://github.com/xZetsubou/hass-localtuya/discussions/13)).", 139 | "data": { 140 | "templates": "اختيار القالب" 141 | } 142 | } 143 | } 144 | }, 145 | "title": "LocalTuya" 146 | } -------------------------------------------------------------------------------- /custom_components/localtuya/water_heater.py: -------------------------------------------------------------------------------- 1 | """Platform to locally control Tuya-based WaterHeater devices.""" 2 | 3 | import logging 4 | from functools import partial 5 | from .config_flow import col_to_select 6 | from homeassistant.helpers.selector import ObjectSelector 7 | 8 | import voluptuous as vol 9 | from homeassistant.components.water_heater import ( 10 | DEFAULT_MIN_TEMP, 11 | DEFAULT_MAX_TEMP, 12 | DOMAIN, 13 | WaterHeaterEntity, 14 | WaterHeaterEntityFeature, 15 | ) 16 | from homeassistant.components.water_heater.const import ( 17 | STATE_ECO, 18 | STATE_ELECTRIC, 19 | STATE_PERFORMANCE, 20 | STATE_HIGH_DEMAND, 21 | STATE_HEAT_PUMP, 22 | STATE_GAS, 23 | ) 24 | from homeassistant.const import ( 25 | ATTR_TEMPERATURE, 26 | CONF_TEMPERATURE_UNIT, 27 | PRECISION_HALVES, 28 | PRECISION_TENTHS, 29 | PRECISION_WHOLE, 30 | UnitOfTemperature, 31 | ) 32 | from .entity import LocalTuyaEntity, async_setup_entry 33 | from .const import ( 34 | CONF_TARGET_TEMPERATURE_DP, 35 | CONF_CURRENT_TEMPERATURE_DP, 36 | CONF_MIN_TEMP, 37 | CONF_MAX_TEMP, 38 | CONF_PRECISION, 39 | CONF_TARGET_PRECISION, 40 | CONF_MODE_DP, 41 | CONF_MODES, 42 | CONF_TARGET_TEMPERATURE_LOW_DP, 43 | CONF_TARGET_TEMPERATURE_HIGH_DP, 44 | DictSelector, 45 | ) 46 | 47 | _LOGGER = logging.getLogger(__name__) 48 | 49 | 50 | TEMPERATURE_CELSIUS = "celsius" 51 | TEMPERATURE_FAHRENHEIT = "fahrenheit" 52 | 53 | DEFAULT_TEMPERATURE_UNIT = TEMPERATURE_CELSIUS 54 | DEFAULT_PRECISION = PRECISION_TENTHS 55 | DEFAULT_TEMPERATURE_STEP = PRECISION_HALVES 56 | PERCISION_SET = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS] 57 | 58 | OFF_MODE = "Off" 59 | 60 | 61 | def flow_schema(dps): 62 | """Return schema used in config flow.""" 63 | return { 64 | vol.Optional(CONF_TARGET_TEMPERATURE_DP): col_to_select(dps, is_dps=True), 65 | vol.Optional(CONF_TARGET_TEMPERATURE_LOW_DP): col_to_select(dps, is_dps=True), 66 | vol.Optional(CONF_TARGET_TEMPERATURE_HIGH_DP): col_to_select(dps, is_dps=True), 67 | vol.Optional(CONF_CURRENT_TEMPERATURE_DP): col_to_select(dps, is_dps=True), 68 | vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), 69 | vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float), 70 | vol.Optional(CONF_PRECISION, default=str(DEFAULT_PRECISION)): col_to_select( 71 | PERCISION_SET 72 | ), 73 | vol.Optional( 74 | CONF_TARGET_PRECISION, default=str(DEFAULT_PRECISION) 75 | ): col_to_select(PERCISION_SET), 76 | vol.Optional(CONF_MODE_DP): col_to_select(dps, is_dps=True), 77 | vol.Optional(CONF_MODES, default={}): ObjectSelector(), 78 | vol.Optional(CONF_TEMPERATURE_UNIT): col_to_select( 79 | [TEMPERATURE_CELSIUS, TEMPERATURE_FAHRENHEIT] 80 | ), 81 | } 82 | 83 | 84 | def config_unit(unit): 85 | if unit == TEMPERATURE_FAHRENHEIT: 86 | return UnitOfTemperature.FAHRENHEIT 87 | else: 88 | return UnitOfTemperature.CELSIUS 89 | 90 | 91 | class LocalTuyaWaterHeater(LocalTuyaEntity, WaterHeaterEntity): 92 | """Tuya WaterHeater device.""" 93 | 94 | _enable_turn_on_off_backwards_compatibility = False 95 | _attr_current_operation = False 96 | 97 | def __init__( 98 | self, 99 | device, 100 | config_entry, 101 | switchid, 102 | **kwargs, 103 | ): 104 | """Initialize a new LocalTuyaWaterHeater.""" 105 | super().__init__(device, config_entry, switchid, _LOGGER, **kwargs) 106 | self._state = None 107 | self._target_temperature = None 108 | self._current_temperature = None 109 | self._dp_mode = self._config.get(CONF_MODE_DP, None) 110 | 111 | self._available_modes = DictSelector(self._config.get(CONF_MODES, {})) 112 | 113 | self._precision = float(self._config.get(CONF_PRECISION, DEFAULT_PRECISION)) 114 | self._precision_target = float( 115 | self._config.get(CONF_TARGET_PRECISION, DEFAULT_PRECISION) 116 | ) 117 | 118 | @property 119 | def supported_features(self): 120 | """Flag supported features.""" 121 | supported_features = WaterHeaterEntityFeature(0) 122 | if self.has_config(CONF_TARGET_TEMPERATURE_DP): 123 | supported_features |= WaterHeaterEntityFeature.TARGET_TEMPERATURE 124 | if self.has_config(CONF_MODE_DP): 125 | supported_features |= WaterHeaterEntityFeature.OPERATION_MODE 126 | 127 | supported_features |= WaterHeaterEntityFeature.ON_OFF 128 | 129 | return supported_features 130 | 131 | @property 132 | def precision(self): 133 | """Return the precision of the system.""" 134 | return self._precision 135 | 136 | @property 137 | def temperature_unit(self): 138 | """Return the unit of measurement used by the platform.""" 139 | return config_unit(self._config.get(CONF_TEMPERATURE_UNIT)) 140 | 141 | @property 142 | def min_temp(self): 143 | """Return the minimum temperature.""" 144 | return self._config.get(CONF_MIN_TEMP, DEFAULT_MIN_TEMP) 145 | 146 | @property 147 | def max_temp(self): 148 | """Return the maximum temperature.""" 149 | return self._config.get(CONF_MAX_TEMP, DEFAULT_MAX_TEMP) 150 | 151 | @property 152 | def operation_list(self) -> list[str] | None: 153 | """Return the list of available operation modes.""" 154 | return self._available_modes.names + [OFF_MODE] 155 | 156 | @property 157 | def current_temperature(self): 158 | """Return the current temperature.""" 159 | return self._current_temperature 160 | 161 | @property 162 | def target_temperature(self): 163 | """Return the temperature we try to reach.""" 164 | return self._target_temperature 165 | 166 | @property 167 | def target_temperature_high(self) -> float | None: 168 | """Return the highbound target temperature we try to reach.""" 169 | return self._attr_target_temperature_high 170 | 171 | @property 172 | def target_temperature_low(self) -> float | None: 173 | """Return the lowbound target temperature we try to reach.""" 174 | return self._attr_target_temperature_low 175 | 176 | async def async_set_temperature(self, **kwargs): 177 | """Set new target temperature.""" 178 | if ATTR_TEMPERATURE in kwargs and self.has_config(CONF_TARGET_TEMPERATURE_DP): 179 | temperature = kwargs[ATTR_TEMPERATURE] 180 | 181 | temperature = round(temperature / self._precision_target) 182 | await self._device.set_dp( 183 | temperature, self._config[CONF_TARGET_TEMPERATURE_DP] 184 | ) 185 | 186 | async def async_set_operation_mode(self, operation_mode: str) -> None: 187 | """Set new target operation mode.""" 188 | status = {} 189 | if operation_mode == OFF_MODE: 190 | return await self.async_turn_off() 191 | elif not self._state: 192 | status[self._dp_id] = True 193 | 194 | status[self._dp_mode] = self._available_modes.to_tuya(operation_mode) 195 | await self._device.set_dps(status) 196 | 197 | async def async_turn_on(self) -> None: 198 | """Turn the entity on.""" 199 | await self._device.set_dp(True, self._dp_id) 200 | 201 | async def async_turn_off(self) -> None: 202 | """Turn the entity off.""" 203 | await self._device.set_dp(False, self._dp_id) 204 | 205 | def status_updated(self): 206 | """Device status was updated.""" 207 | self._state = self.dp_value(self._dp_id) 208 | 209 | # Update target temperature 210 | if self.has_config(CONF_TARGET_TEMPERATURE_DP): 211 | self._target_temperature = ( 212 | self.dp_value(CONF_TARGET_TEMPERATURE_DP) * self._precision_target 213 | ) 214 | 215 | # Update current temperature 216 | if self.has_config(CONF_CURRENT_TEMPERATURE_DP): 217 | self._current_temperature = ( 218 | self.dp_value(CONF_CURRENT_TEMPERATURE_DP) * self._precision 219 | ) 220 | 221 | # Update modes states 222 | if not self._state: 223 | self._attr_current_operation = OFF_MODE 224 | elif self._dp_mode is not None and (mode := self.dp_value(CONF_MODE_DP)): 225 | self._attr_current_operation = self._available_modes.to_ha(mode) 226 | 227 | if ( 228 | target_high := self.dp_value(CONF_TARGET_TEMPERATURE_HIGH_DP) 229 | ) or target_high is not None: 230 | self._attr_target_temperature_high = target_high 231 | 232 | if ( 233 | target_low := self.dp_value(CONF_TARGET_TEMPERATURE_LOW_DP) 234 | ) or target_low is not None: 235 | self._attr_target_temperature_low = target_low 236 | 237 | 238 | async_setup_entry = partial( 239 | async_setup_entry, DOMAIN, LocalTuyaWaterHeater, flow_schema 240 | ) 241 | -------------------------------------------------------------------------------- /documentation/docs/auto_configure.md: -------------------------------------------------------------------------------- 1 | # Auto configure devices 2 | Localtuya can disocver you device entities if cloud is enable because the feature at the moment rely on `DP code` and [Devices Category](https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq#title-6-List%20of%20category%20code){target="_blank"}. 3 | 4 | By known the `category` we use that to get all the possible entities from stored data.
Data stored in `/localtuya/core/ha_entities` (1) 5 | {.annotate} 6 | 7 | 1. Files are named with entities type

![](images/dev/ha_entities_dir.png) 8 | 9 | ??? info "DPCodes data" 10 | All known `Codes` are stored in `base.py` in `DPCode Class`.
11 | If class doesn't contains your `DPCode` Add it, `DPCode class sorted in alphabetically`. 12 | 13 | 14 | !!! tip annotate "How to get the `Codes and DP`" 15 | You can download your device data in your `Home Assistant` by `Downloading Diagnostics`. 16 | 17 | 1. Download `device` diagnostics `localtuya` from device page. (1) `file -> device_cloud_info` 18 | 2. Or download `entry` diagnostics `note: It contains all devices data` (2) `file -> cloud_devices` 19 | 20 | Inside downloaded `txt file`, in `cloud_data object` look for your `device_id -> dps_data` (3) 21 | 22 | 1. ![](images/dev/device_diagnostics.png) 23 | 2. ![](images/dev/entry_diagnostics.png) 24 | 3. TIP: Search for device name instead of `device_id` 25 | 26 | _Now that we know the device `category` and `Codes` we can start add the entities._ 27 | 28 | In `/localtuya/core/ha_entities` open the file named with `entity type` you want to add.
29 | All files contains `constant dict` (1) includes all known `categories` and possible entities.
30 | {.annotate} 31 | 32 | 1. e.g `COVERS or SWITCHES` 33 | 34 | Look for the `category` it already exist modify it and add the missing `entities`. 35 | 36 | Using `LocalTuyaEntity class` we pass entity parameters `id` and `DPs config name as keys and DPCode as values` Config names has to be supported by `localtuya` (1) 37 | {.annotate} 38 | 39 | 1. All entities platforms has `id` config name. but some has more DPs configs names
For example: `cover platforms` has config names for `current_position_dp` and `set_position_dp`
40 | 41 | 64 | 65 | # Examples 66 | 67 | ??? example "Add `code: switch_4` into `SWITCHES` in `kg` category" 68 | ```python 69 | "kg": ( 70 | LocalTuyaEntity( 71 | id=DPCode.SWITCH_4, # REQUIRED: id config name = look DP with code `switch_4` 72 | name="Switch 4", # Name the entity: `Switch 4` 73 | icon="mdi:icon_name", # icon for the entity 74 | entity_category=EntityCategory.CONFIG, # Show entity in this category 75 | ), 76 | ), 77 | ``` 78 | 79 | ??? example "Add `switch` into `SWITCHES` in `cl` category: with condition" 80 | ```python 81 | "cl": ( 82 | LocalTuyaEntity( 83 | id=DPCode.CONTROL_BACK, 84 | name="Reverse", 85 | icon="mdi:swap-horizontal", 86 | entity_category=EntityCategory.CONFIG, 87 | condition_contains_any=["true", "false"], 88 | ), 89 | ), 90 | ``` 91 | 92 | 93 | 94 | ??? example "Add `cover` into `COVERS` in `cl` category" 95 | ```python 96 | "cl": ( 97 | LocalTuyaEntity( 98 | id=DPCode.CONTROL, 99 | name="Curtain", 100 | custom_configs=localtuya_cover("open_close_stop", "position"), # localtuya config 101 | current_state=DPCode.SITUATION_SET, 102 | current_position_dp=(DPCode.PERCENT_CONTROL, DPCode.PERCENT_STATE),#(1)! 103 | set_position_dp=DPCode.PERCENT_CONTROL, 104 | ), 105 | ), 106 | ``` 107 | 108 | 1. `current_position_dp` will search for DP of two possible codes and will take the first `DP` found. 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /documentation/docs/cloud_api.md: -------------------------------------------------------------------------------- 1 | # Cloud API Setup 2 | 3 | The Tuya integration integrates all Powered by Tuya devices you have added to the Tuya Smart and Tuya Smart Life apps. 4 | 5 | !!! note 6 | LocalTuya uses the cloud to obtain your device's data, making the configuration of devices much simpler. 7 | 8 | ## Configuration of the Tuya IoT Platform 9 | 10 | ### Prerequisites 11 | 12 | - Your devices need first to be added in the [Tuya Smart or Smart Life app](https://developer.tuya.com/docs/iot/tuya-smart-app-smart-life-app-advantages?id=K989rqa49rluq#title-1-Download){target="_blank"}. 13 | - You will also need to create an account in the [Tuya IoT Platform](https://iot.tuya.com/){target="_blank"}. 14 | This is a separate account from the one you made for the app. You cannot log in with your app's credentials. 15 | 16 | ### Create a project 17 | 18 | 1. Log in to the [Tuya IoT Platform](https://iot.tuya.com/){target="_blank"}. 19 | 2. In the left navigation bar, click `Cloud` > `Development`. 20 | 3. On the page that appears, click `Create Cloud Project`. 21 | 4. In the `Create Cloud Project` dialog box, configure `Project Name`, `Description`, `Industry`, and `Data Center`. For the `Development Method` field, select `Smart Home` from the dropdown list. For the `Data Center` field, select the zone you are located in. Refer to the country/data center mapping list [here](https://github.com/tuya/tuya-home-assistant/blob/main/docs/regions_dataCenters.md){target="_blank"} to choose the right data center for the country you are in. 22 | 23 | ![](https://www.home-assistant.io/images/integrations/tuya/image_001.png) 24 | 25 | 5. Click `Create` to continue with the project configuration. 26 | 6. In Configuration Wizard, make sure you add `Industry Basic Service`, `Smart Home Basic Service` and `Device Status Notification` APIs. The list of API should look like this: 27 | ![](https://www.home-assistant.io/images/integrations/tuya/image_002new.png) 28 | 7. Click `Authorize`. 29 | 30 | ### Link devices by app account 31 | 32 | 1. Navigate to the `Devices` tab. 33 | 2. Click `Link App Account` > `Add App Account`. 34 | ![](./images/cloud_link_account.png) 35 | 3. Scan the QR code that appears using the `Tuya Smart` app or `Smart Life` app using the 'Me' section of the app. 36 | 37 | ![](https://www.home-assistant.io/images/integrations/tuya/image_004.png) 38 | 39 | 4. Click `Confirm` in the app. 40 | 5. To confirm that everything worked, navigate to the `All Devices` tab. Here you should be able to find the devices from the app. 41 | 6. If zero devices are imported, try changing the DataCenter and check the account used is the "Home Owner". 42 | You can change DataCenter by clicking the Cloud icon on the left menu, then clicking the Edit link in the Operation column for your newly created project. You can change DataCenter in the popup window. 43 | 44 | ![](https://www.home-assistant.io/images/integrations/tuya/image_005.png) 45 | 46 | ### Get authorization data 47 | 48 | Click the created project to enter the `Project Overview` page and get the `Authorization Key`. You will need these for setting up the integration. in the next step. 49 | 50 | ![](images/tuya_iot_overview.png) 51 | 52 | `Data center region`: 53 | Choose the country you picked when signing up. 54 | 55 | `Client ID`: 56 | Go to your cloud project on [Tuya IoT Platform](https://iot.tuya.com/){target="_blank"}. in the **Overview** tab. 57 | 58 | `Client Secret`: 59 | Go to your cloud project on [Tuya IoT Platform](https://iot.tuya.com/){target="_blank"}. in the **Overview** tab. 60 | 61 | ### Get USER ID 62 | Navigate to the `Devices` tab -> click on `Link Tuya App Account` Copy `UID <- is User ID`. 63 | 64 | ![](https://user-images.githubusercontent.com/46300268/246021288-25d56177-2cc1-45dd-adb0-458b6c5a25f3.png) 65 | 66 | ## Error codes and troubleshooting 67 | 68 | 69 | 70 | ??? failure "1004: sign invalid" 71 | Incorrect Access ID or Access Secret. Please refer to the **Configuration** part above. 72 | 73 | ??? failure "1106: permission deny" 74 | - App account not linked with cloud project: On the [Tuya IoT Platform](https://iot.tuya.com/cloud/), you have linked devices by using Tuya Smart or Smart Life app in your cloud project. For more information, see [Link devices by app account](https://developer.tuya.com/docs/iot/Platform_Configuration_smarthome?id=Kamcgamwoevrx#title-3-Link%20devices%20by%20app%20account){target="_blank"}. 75 | 76 | - Incorrect username or password: Enter the correct account and password of the Tuya Smart or Smart Life app in the **Account** and **Password** fields (social login, which the Tuya Smart app allows, may not work, and thus should be avoided for use with the Home Assistant integration). Note that the app account depends on which app (Tuya Smart or Smart Life) you used to link devices on the [Tuya IoT Platform](https://iot.tuya.com/cloud/). 77 | 78 | - Incorrect country. You must select the region of your account of the Tuya Smart app or Smart Life app. 79 | 80 | ??? failure "1100: param is empty" 81 | Empty parameter of username or app. Please fill the parameters refer to the **Configuration** part above. 82 | 83 | ??? failure "2406: skill id invalid" 84 | - Make sure you use the **Tuya Smart** or **SmartLife** app account to log in. Also, choose the right data center endpoint related to your country region. For more details, please check [Country Regions and Data Center](https://github.com/tuya/tuya-home-assistant/blob/main/docs/regions_dataCenters.md). 85 | 86 | - Your cloud project on the [Tuya IoT Development Platform](https://iot.tuya.com) should be created after May 25, 2021. Otherwise, you need to create a new project. 87 | 88 | - This error can often be resolved by unlinking the app from the project (`Devices` tab > `Link Tuya App Account` > `Unlink`) and [relinking it again](#link-devices-by-app-account). 89 | 90 | ??? failure "28841105: No permissions. This project is not authorized to call this API" 91 | Some APIs are not authorized, please [Subscribe](https://developer.tuya.com/docs/iot/applying-for-api-group-permissions?id=Ka6vf012u6q76#title-2-Subscribe%20to%20APIs){target="_blank"} then [Authorize](https://developer.tuya.com/docs/iot/applying-for-api-group-permissions?id=Ka6vf012u6q76#title-3-Grant%20a%20project%20access%20to%20API%20calls){target="_blank"}. The following APIs must be subscribed for this tutorial: 92 | 93 | - Device Status Notification 94 | 95 | - Industry Basic Service 96 | 97 | - Smart Home Basic Service 98 | 99 | - Authorization 100 | 101 | - IoT Core 102 | 103 | - Smart Home Scene Linkage 104 | 105 | - IoT Data Analytics 106 | 107 | ??? failure "28841002: No permissions. Your subscription to cloud development plan has expired" 108 | Your subscription to Tuya cloud development **IoT Core Service** resources has expired, please [extend it](https://iot.tuya.com/cloud/products/detail?abilityId=1442730014117204014){target="_blank"} in `Cloud` > `Cloud Services` > `IoT Core` > `My Subscriptions` tab > `Subscribed Resources` > `IoT Core` > `Extend Trial Period`. 109 | 110 | ## Document source 111 | [Home Assistant Tuya](https://www.home-assistant.io/integrations/tuya/){target="_blank"} -------------------------------------------------------------------------------- /documentation/docs/faq/index.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | #### Low Power Devices 4 | !!! abstract "" 5 | A device that has [Low Power Mode](https://developer.tuya.com/en/docs/iot-device-dev/Low_consumption_Wi_Fi?id=Kay3gha1um42e){target="_blank"}, applied on such as __`Wi-Fi door locks and sensors`__.
6 | The device will report its status every x minutes. Most of the time, the device will go into sleep mode, and most likely it will disconnect from the network. 7 | Some Device has an option to control the reports period. 8 | In order to add this device, you need to specify the device sleep time in the [device configartion](../usage/configure_add_device.md).
9 | !!! tip "" 10 | If you add the device while it's sleeping and it's `disconnected` from the network, it won't connect
11 | If you changed any value on the device while it is asleep, the new states will be applied when it wakes up.
12 | Try to avoid WiFi Sensors devices and go for BLE or ZigBee. 13 | 14 |
15 | 16 | #### IR Remotes 17 | !!! abstract "" 18 | Usually, the IR remote devices doesn't have DPS status, so if you encounter an error `no datapoints could be found` If the device information is incorrect, this won't work.
19 | you can ignore the handshake (which fails if no DPS is found) by adding __`0`__ in the manual DPS field. 20 | 21 |
22 | 23 | #### ZigBee Gateway 24 | !!! abstract "" 25 | The ZigBee gateway isn't supposed to be added unless it has more features other than just being a hub.
26 | Otherwise, if you added them, you will encounter an error stating `no datapoints could be found`. 27 | 28 |
29 | 30 | ### Devices Discovery 31 | !!! abstract "" 32 | By default, `LocalTuya` includes a discovery feature that scans for Tuya devices within the local network and lists them in the config flow. 33 | However, this function requires Home Assistant to have the same subnets as Tuya devices 34 | 35 |
36 | 37 | ### Cloud Pull 38 | !!! abstract "" 39 | The cloud pull feature is just something I added to inform users that there might be some DPS that can be used, but weren't reported by the device. 40 | Most of the cloud-pulled DPS aren't really useful; they might be encrypted or have empty values. 41 | However, it won't change the fact that the device contains these DPS, so using them is up to the user. 42 | 43 | 46 | 47 | -------------------------------------------------------------------------------- /documentation/docs/ha_events.md: -------------------------------------------------------------------------------- 1 | # Events 2 | !!! note "" 3 | Your device must be added to localtuya to use Events 4 | 5 | Localtuya fires an [events](https://www.home-assistant.io/docs/configuration/events/){target="_blank"} on `homeassisstant` 6 | that can be used on automation or monitoring your device behaviour from [Developer tools -> events](https://my.home-assistant.io/redirect/developer_events/){target="_blank"} (1)
7 | {.annotate} 8 | 9 | 1. to monitor your device subscribe to any event below and trigger action on the device using tuya app 10 | 11 | 12 | !!! annotate tip "" 13 | With this you can automate devices such as `scene remote` (1) to trigger an action on `homeassistant` 14 | 15 | 1. e.g. `single click`, `double click` or `hold`. 16 | 17 | | Event | Data 18 | | --------------------------------- | ------------------------------------ 19 | | `localtuya_status_update` | `#!json {"data": {"device_id", "old_status", "new_status"} }` 20 | | `localtuya_device_dp_triggered` | `#!json {"data": {"device_id", "dp", "value"} }` 21 | 22 | 23 | Examples 24 | === "localtuya_states_update" 25 | 26 | ```yaml title="" 27 | # This will only triggers if status changed. 28 | trigger: 29 | - platform: event 30 | event_type: localtuya_status_update 31 | condition: [] 32 | action: 33 | - service: persistent_notification.create 34 | data: 35 | message: "{{ trigger.event.data }}" 36 | 37 | ``` 38 | 39 | === "localtuya_device_dp_triggered" 40 | 41 | ```yaml title="" 42 | # This will always triggers if DP used. 43 | trigger: 44 | - platform: event 45 | event_type: localtuya_device_dp_triggered 46 | condition: [] 47 | action: 48 | - service: persistent_notification.create 49 | data: 50 | message: "{{ trigger.event.data }}" 51 | 52 | ``` 53 | ??? example "example of an automation to trigger a scene when the first button on a remote is single-clicked" 54 | ```yaml title="" 55 | 56 | trigger: 57 | - platform: event 58 | event_type: localtuya_device_dp_triggered 59 | event_data: 60 | device_id: bfa2f86e3068440a449dhd 61 | dp: "1" # quotes are important for dp 62 | value: single_click 63 | condition: [] 64 | action: 65 | - service: persistent_notification.create 66 | data: 67 | message: "{{ trigger.event.data }}" 68 | 69 | ``` 70 | 71 | !!! annotate warning "Database flooding" 72 | If the recorder is enabled, devices like temperature sensors may update frequently (e.g., every second). 73 | This can cause excessive events and significantly increase database size. 74 | It is recommended to exclude _localtuya_ events from the recorder to prevent database overload. 75 | !!! annotate tip "" 76 | ```yaml title="" 77 | recorder: 78 | exclude: 79 | event_types: 80 | - localtuya_status_update 81 | - localtuya_device_dp_triggered 82 | ``` -------------------------------------------------------------------------------- /documentation/docs/ha_services.md: -------------------------------------------------------------------------------- 1 | # Services 2 | 3 | 4 | | Service | Data | Description 5 | | ----------- | ---------------------------------------------------------|------------------------------------- 6 | | `localtuya.reload` | | Reload All `localtuya` entries 7 | | `localtuya.set_dp` | `#!json {"data": {"device_id", "dp", "value"}}` | Set new value for one `DP` or multi 8 | | `localtuya.remote_add_code` | `#!json {"data": {"target", "device_name", "command_name", "base64", "head", "key" }}` | Manually add code into remote device. 9 | 10 | 11 | === "Set DP Service" 12 | 13 | ```yaml title="Change the value of DP 1" 14 | service: localtuya.set_dp 15 | data: 16 | device_id: 11100118278aab4de001 17 | dp: 1 18 | value: true 19 | ``` 20 |
21 | ```yaml title="Change the values for multi DPs" 22 | service: localtuya.set_dp 23 | data: 24 | device_id: 11100118278aab4de001 #(1)! 25 | value: 26 | "1": true # (2)! 27 | "2": true # (3)! 28 | "3": false # (4)! 29 | ``` 30 | 31 | 1. Device with this ID must be added into `localtuya` 32 | 2. Set `DP 1` Value to `true` 33 | 3. Set `DP 2` Value to `true` 34 | 4. Set `DP 3` Value to `false` 35 | 36 | === "Reload Service" 37 | Reload all `LocalTuya` Entries 38 | ```yaml 39 | service: localtuya.reload 40 | ``` 41 | 42 | === "Add Remote Code" 43 | Add a TV button using `head/key` or `base64` 44 | ```yaml 45 | action: localtuya.remote_add_code 46 | data: 47 | target: c187a2102cb1e38161377eb4d4afb6f7 48 | device_name: TV 49 | command_name: volume_up 50 | head: "11111111111" # Head: Can be obtain from Tuya IoT device debug logs. 51 | key: "223123" # Key: Can be obtain from Tuya IoT device debug logs. 52 | ``` 53 | -------------------------------------------------------------------------------- /documentation/docs/images/cloud_link_account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/cloud_link_account.png -------------------------------------------------------------------------------- /documentation/docs/images/configure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/configure.png -------------------------------------------------------------------------------- /documentation/docs/images/delete_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/delete_device.png -------------------------------------------------------------------------------- /documentation/docs/images/dev/device_diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/dev/device_diagnostics.png -------------------------------------------------------------------------------- /documentation/docs/images/dev/entry_diagnostics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/dev/entry_diagnostics.png -------------------------------------------------------------------------------- /documentation/docs/images/dev/ha_entities_dir.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/dev/ha_entities_dir.png -------------------------------------------------------------------------------- /documentation/docs/images/dp_list_explain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/dp_list_explain.png -------------------------------------------------------------------------------- /documentation/docs/images/dps_list_ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/dps_list_ex.png -------------------------------------------------------------------------------- /documentation/docs/images/init.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/init.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_add_devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_add_devices.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_configure_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_configure_device.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_configure_entity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_configure_entity.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_configure_more.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_configure_more.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_configure_switch_ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_configure_switch_ex.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_reconfigure_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_reconfigure_device.png -------------------------------------------------------------------------------- /documentation/docs/images/opt_reconfigure_device_entity_check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/opt_reconfigure_device_entity_check.png -------------------------------------------------------------------------------- /documentation/docs/images/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/options.png -------------------------------------------------------------------------------- /documentation/docs/images/report_bug_enable_debug_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/report_bug_enable_debug_ui.png -------------------------------------------------------------------------------- /documentation/docs/images/report_bug_enable_device_debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/report_bug_enable_device_debug.png -------------------------------------------------------------------------------- /documentation/docs/images/templates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/templates.png -------------------------------------------------------------------------------- /documentation/docs/images/tuya_iot_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/documentation/docs/images/tuya_iot_overview.png -------------------------------------------------------------------------------- /documentation/docs/index.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | LocalTuya is an [HomeAssistant](https://www.home-assistant.io/){target="_blank"} integration that enables you to control your Tuya-based smart devices directly within your local network. 4 | 5 | 6 | !!! info "LocalTuya is a Hub " 7 | `LocalTuya` serves as a hub. After setup, whether using `cloud` or `no cloud`, you can manage your devices through the entry configuration UI in `hub configuration`. 8 | 9 | 10 | 11 | !!! info "Cloud API" 12 | LocalTuya uses the cloud only to obtain device data and pre-fill the required fields for you. 13 | 14 | It offers many features to simplify device setup. 15 | 16 | `LocalTuya` can be used independently of the cloud. 17 | 18 | [:material-file-document: Usage](usage/installation.md){.md-button} 19 | [:simple-homeassistantcommunitystore: Add repository to HACS](https://my.home-assistant.io/redirect/hacs_repository/?category=integration&repository=hass-localtuya&owner=xZetsubou){ target=_blank .md-button } 20 | 21 | 22 | ## Features 23 | 24 | - [Cloud API](cloud_api.md) `Optional - Only used to assist in the devices setup process` 25 | - Supported Sub-devices: `Devices that function through gateways` 26 | - Auto-configure devices - *`Requires a cloud API setup`* 27 | - Automatic insertion - *`Requires a cloud API setup`* 28 | - Devices discovery - *`Discovers Tuya devices on your network`* 29 | 30 | ## Supported Platforms 31 | - Binary Sensor 32 | - Button 33 | - Climate 34 | - Cover 35 | - Fan 36 | - Humidifier 37 | - Light 38 | - Number 39 | - Selector 40 | - Sensor 41 | - Siren 42 | - Switch 43 | - Vacuum 44 | -------------------------------------------------------------------------------- /documentation/docs/report_issue.md: -------------------------------------------------------------------------------- 1 | # Report an issue 2 | 3 | Whenever you write a bug report, it's incredibly helpful to include debug logs directly. Otherwise, we'll need to request them separately, prolonging the process. Please enable debug logs as shown and include them in your issue: 4 | 5 | 6 | ### Enable from the UI 7 | Via UI (1) 8 | `Reload integration after enabling the debug from the UI` 9 | {.annotate} 10 | 11 | 1. ![](images/report_bug_enable_debug_ui.png) 12 | 13 | 14 | 15 | ### Enable from Configuration file 16 | Configuration.yaml file `Recommended`
17 | Add the below line into your `configuration.yaml` that located in HA config directory.
18 | 19 | !!! note 20 | Editing the `configuration` file will require an HA restart to apply the changes. 21 | 22 | ```yaml 23 | logger: 24 | default: warning 25 | logs: 26 | custom_components.localtuya: debug 27 | custom_components.localtuya.pytuya: debug 28 | ``` 29 | 30 | ### Enable device debug. 31 | Then, edit the device that is showing problems and check the `Enable debugging for this device` (1) 32 | {.annotate} 33 | 34 | 1. ![](images/report_bug_enable_device_debug.png) 35 | -------------------------------------------------------------------------------- /documentation/docs/style/extra.css: -------------------------------------------------------------------------------- 1 | .md-typeset .admonition , 2 | .md-typeset details { 3 | border-width: 0; 4 | border-radius: 0; 5 | border-left-width: 3.5px; 6 | } -------------------------------------------------------------------------------- /documentation/docs/usage/configure_edit_device.md: -------------------------------------------------------------------------------- 1 | # Reconfigure Devices 2 | Click on `Configure` (1) a menu will show up (2) Choose `Reconfigure existing device` 3 | { .annotate } 4 | 5 | 1. ![](../images/configure.png) 6 | 2. ![](../images/options.png) 7 | 8 | 9 | The `Reconfigure existing device` (1) operates similarly to the _`Add Device`_ steps, but it allows you to modify existing entities or add new ones 10 | {.annotate} 11 | 12 | 1. ![](../images/opt_reconfigure_device.png) 13 | 14 | #### Delete entities 15 | You can delete the entity by uncheck the entity you want to remove on `reconfigure device step` 16 | 17 | ![](../images/opt_reconfigure_device_entity_check.png) -------------------------------------------------------------------------------- /documentation/docs/usage/devices_delete.md: -------------------------------------------------------------------------------- 1 | # Delete Devices 2 | 3 | 1. Go to your device page in Home Assistant. 4 | 2. On the device info panel, click on the :material-dots-vertical: and select on __delete__ 5 | 6 | ???+ example "Example image" 7 | ![](../images/delete_device.png) -------------------------------------------------------------------------------- /documentation/docs/usage/installation.md: -------------------------------------------------------------------------------- 1 | # Install Integration 2 | 3 | If you haven't added the repository to `HACS`. 4 | 5 | [:simple-homeassistantcommunitystore: Add repository to HACS](https://my.home-assistant.io/redirect/hacs_repository/?category=integration&repository=hass-localtuya&owner=xZetsubou){ target=_blank .md-button } 6 | 7 | 12 | 13 |
14 | 15 | #### Add HUB 16 | 1. Adding hub options: 17 | 18 | a. Go to the [integration page](https://my.home-assistant.io/redirect/integrations/){target=_blank} in HA and click on `ADD INTEGRATION` in the bottom right corner. 19 | 20 | b. Or use [MY: Add Integration](https://my.home-assistant.io/redirect/config_flow_start/?domain=localtuya){target=_blank} 21 |

22 | 23 | 2. Adding a new hub will introduce you to this configuration page (1)
24 | { .annotate } 25 | 26 | 1. ![](../images/init.png) 27 | 28 | a. If you prefer not to set up the cloud API, check `Disable Cloud API?` 29 | 30 | b. If you've set up a `cloud` account, you should have all the necessary information 31 |
[Get authentication data](../cloud_api.md/#get-authorization-data). 32 | 33 | -------------------------------------------------------------------------------- /documentation/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Localtuya 2 | repo_url: https://github.com/xZetsubou/hass-localtuya/ 3 | edit_uri: blob/master/documentation/docs/ 4 | copyright: Copyright © Bander xZetsubou 5 | 6 | theme: 7 | name: 'material' 8 | features: 9 | - content.action.edit 10 | - content.code.annotate 11 | - content.code.copy 12 | - navigation.instant 13 | - navigation.sections 14 | - navigation.top 15 | palette: 16 | scheme: slate 17 | primary: teal 18 | accent: teal 19 | 20 | nav: 21 | - index.md 22 | - ha_events.md 23 | - ha_services.md 24 | - Guides: 25 | - Usage: 26 | - usage/installation.md 27 | - usage/configure_add_device.md 28 | - usage/configure_edit_device.md 29 | - usage/devices_delete.md 30 | - cloud_api.md 31 | - Development: 32 | - auto_configure.md 33 | - Others: 34 | - faq/index.md 35 | - report_issue.md 36 | 37 | 38 | extra_css: 39 | - style/extra.css 40 | 41 | markdown_extensions: 42 | - admonition 43 | - attr_list 44 | - md_in_html 45 | - pymdownx.details 46 | - pymdownx.inlinehilite 47 | - pymdownx.snippets 48 | - pymdownx.superfences 49 | - tables 50 | - pymdownx.highlight: 51 | anchor_linenums: true 52 | line_spans: __span 53 | pygments_lang_class: true 54 | - pymdownx.emoji: 55 | emoji_index: !!python/name:material.extensions.emoji.twemoji 56 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 57 | - pymdownx.tabbed: 58 | alternate_style: true 59 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Local Tuya", 3 | "homeassistant": "2025.1.0", 4 | "render_readme": true, 5 | "persistent_directory": "templates" 6 | } 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py312"] 3 | include = 'custom_components/localtuya/.*\.py' 4 | 5 | [tool.codespell] 6 | skip = '*.pyc,*.pyi,*~,*.json' 7 | ignore-words-list = "hass,contiuation" 8 | count = true 9 | quiet-level = 3 10 | 11 | [tool.pytest.ini_options] 12 | asyncio_default_fixture_loop_scope = "function" 13 | testpaths = ["tests"] 14 | asyncio_mode = "auto" 15 | 16 | [tool.coverage.run] 17 | source = ["tests"] 18 | 19 | # pylint config stolen from Home Assistant 20 | [tool.pylint.MAIN] 21 | # Use a conservative default here; 2 should speed up most setups and not hurt 22 | # any too bad. Override on command line as appropriate. 23 | # Disabled for now: https://github.com/PyCQA/pylint/issues/3584 24 | #jobs = 2 25 | load-plugins = [ 26 | "pylint_strict_informational", 27 | ] 28 | persistent = false 29 | extension-pkg-whitelist = [ 30 | "ciso8601", 31 | "cv2", 32 | ] 33 | 34 | [tool.pylint.BASIC] 35 | good-names = [ 36 | "_", 37 | "ev", 38 | "ex", 39 | "fp", 40 | "i", 41 | "id", 42 | "j", 43 | "k", 44 | "Run", 45 | "T", 46 | "hs", 47 | ] 48 | 49 | [tool.pylint."MESSAGES CONTROL"] 50 | # Reasons disabled: 51 | # format - handled by black 52 | # locally-disabled - it spams too much 53 | # duplicate-code - unavoidable 54 | # cyclic-import - doesn't test if both import on load 55 | # abstract-class-little-used - prevents from setting right foundation 56 | # unused-argument - generic callbacks and setup methods create a lot of warnings 57 | # too-many-* - are not enforced for the sake of readability 58 | # too-few-* - same as too-many-* 59 | # abstract-method - with intro of async there are always methods missing 60 | # inconsistent-return-statements - doesn't handle raise 61 | # too-many-ancestors - it's too strict. 62 | # wrong-import-order - isort guards this 63 | disable = [ 64 | "format", 65 | "abstract-class-little-used", 66 | "abstract-method", 67 | "cyclic-import", 68 | "duplicate-code", 69 | "inconsistent-return-statements", 70 | "locally-disabled", 71 | "not-context-manager", 72 | "too-few-public-methods", 73 | "too-many-ancestors", 74 | "too-many-arguments", 75 | "too-many-branches", 76 | "too-many-instance-attributes", 77 | "too-many-lines", 78 | "too-many-locals", 79 | "too-many-public-methods", 80 | "too-many-return-statements", 81 | "too-many-statements", 82 | "too-many-boolean-expressions", 83 | "unused-argument", 84 | "wrong-import-order", 85 | ] 86 | enable = [ 87 | "use-symbolic-message-instead", 88 | ] 89 | 90 | [tool.pylint.REPORTS] 91 | score = false 92 | 93 | [tool.pylint.FORMAT] 94 | expected-line-ending-format = "LF" 95 | 96 | [tool.pylint.MISCELLANEOUS] 97 | notes = "XXX" 98 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | black==24.8.0 2 | codespell==2.4.1 3 | flake8==3.9.2 4 | homeassistant==2025.1.0 5 | mypy==0.901 6 | pydocstyle==6.1.1 7 | pylint-strict-informational==0.1 8 | pylint==2.8.2 9 | pytest-asyncio 10 | pytest-cov -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.tox 3 | max-line-length = 120 4 | ignore = E203, W503 5 | 6 | [mypy] 7 | python_version = 3.9 8 | ignore_errors = true 9 | follow_imports = silent 10 | ignore_missing_imports = true 11 | warn_incomplete_stub = true 12 | warn_redundant_casts = true 13 | warn_unused_configs = true 14 | 15 | [mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,homeassistant.components.select.*,homeassistant.components.number.*] 16 | strict = true 17 | ignore_errors = false 18 | warn_unreachable = true 19 | # TODO: turn these off, address issues 20 | allow_any_generics = true 21 | implicit_reexport = true 22 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Init localtuya tests""" 2 | 3 | import asyncio 4 | import homeassistant.util.ulid as ulid_util 5 | import os, sys 6 | import pytest 7 | import threading 8 | import time 9 | 10 | from typing import Any 11 | from unittest.mock import AsyncMock, Mock 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.core import HomeAssistant 14 | from custom_components.localtuya import TuyaCloudApi 15 | from custom_components.localtuya import coordinator 16 | from custom_components.localtuya import entity 17 | from custom_components.localtuya.const import DOMAIN 18 | 19 | HOST = "192.168.1.100" 20 | DEVICE_NAME = "device" 21 | 22 | DEVICE_CONFIG = { 23 | "host": HOST, 24 | "device_id": "767823809c9c1f458745", 25 | "protocol_version": "3.3", 26 | "local_key": "wV[NcWGUSFF`dSgO", 27 | "friendly_name": "Local 3G", 28 | } 29 | 30 | 31 | async def init(config: dict[str, dict[str, Any]], entity_domain, entity_class): 32 | add_entities = AsyncMock() 33 | 34 | asyncio.create_task = lambda _: None 35 | asyncio.get_running_loop = lambda: type( 36 | "", (), {"_thread_id": threading.get_ident()} 37 | ) 38 | hass = HomeAssistant("") 39 | entry = ConfigEntry(**create_entry(config)) 40 | tuya_api = TuyaCloudApi("EU", "test_client_id", "test_secret", "test_user_id") 41 | 42 | hass.data.setdefault("localtuya", {entry.entry_id: {}}) 43 | 44 | dump_device = coordinator.TuyaDevice(hass, entry, config[DEVICE_NAME]) 45 | dump_device.status_updated = lambda x: [ 46 | [e._status.update(x), e.connection_made(), e.status_updated()] 47 | for e in get_entites(dump_device) 48 | ] 49 | 50 | localtuya_hass_data = coordinator.HassLocalTuyaData(tuya_api, {HOST: dump_device}) 51 | hass.data[DOMAIN][entry.entry_id] = localtuya_hass_data 52 | 53 | await entity.async_setup_entry( 54 | entity_domain, 55 | entity_class, 56 | lambda _: {}, 57 | hass=hass, 58 | config_entry=entry, 59 | async_add_entities=add_entities, 60 | ) 61 | 62 | add_entities.assert_called_once() 63 | return dump_device 64 | 65 | 66 | def create_entry(config: dict[str, dict[str, Any]]): 67 | return { 68 | "data": {"devices": config}, 69 | "disabled_by": None, 70 | "discovery_keys": None, 71 | "domain": "test", 72 | "entry_id": ulid_util.ulid_now(), 73 | "minor_version": 1, 74 | "options": {}, 75 | "pref_disable_new_entities": None, 76 | "pref_disable_polling": None, 77 | "title": "Mock LocalTuya", 78 | "unique_id": None, 79 | "version": 1, 80 | "source": "user", 81 | } 82 | 83 | 84 | def get_entites(device: coordinator.TuyaDevice): 85 | return getattr(device, "_entities") 86 | -------------------------------------------------------------------------------- /tests/test_alarm_control_panel.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.alarm_control_panel import ( 5 | LocalTuyaAlarmControlPanel, 6 | DEFAULT_SUPPORTED_MODES, 7 | DOMAIN as PLATFORM_DOMAIN, 8 | TuyaMode, 9 | AlarmControlPanelState, 10 | ) 11 | 12 | CONFIG = { 13 | DEVICE_NAME: { 14 | "host": HOST, 15 | "add_entities": False, 16 | "device_id": "767823809c9c1f458745", 17 | "dps_strings": [ 18 | "1 ( code: switch_1 , value: True )", 19 | "2 ( code: switch_2 , value: False )", 20 | "3 ( code: switch_3 , value: True )", 21 | "9 ( code: countdown_1 , value: 0 )", 22 | "10 ( code: countdown_2 , value: 0 )", 23 | "11 ( code: countdown_3 , value: 0 )", 24 | ], 25 | "enable_debug": False, 26 | "entities": [ 27 | { 28 | "entity_category": "None", 29 | "friendly_name": "Button 1", 30 | "id": "1", 31 | "platform": "alarm_control_panel", 32 | "alarm_supported_states": DEFAULT_SUPPORTED_MODES, 33 | } 34 | ], 35 | "export_config": False, 36 | "friendly_name": "Local 3G", 37 | "local_key": "wV[NcWGUSFF`dSgO", 38 | "model": "S603", 39 | "node_id": None, 40 | "product_key": "key5nck4tavy43jp", 41 | "protocol_version": "3.3", 42 | } 43 | } 44 | 45 | DPS_STATUS = {"1": None} 46 | 47 | 48 | async def test_alarm_control_panel(): 49 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaAlarmControlPanel) 50 | entities: list[LocalTuyaAlarmControlPanel] = get_entites(device) 51 | 52 | assert len(entities) > 0 53 | entity_1, *_ = entities 54 | 55 | assert type(entity_1) is LocalTuyaAlarmControlPanel 56 | 57 | assert entity_1.alarm_state == None 58 | device.status_updated({"1": TuyaMode.ARM}) 59 | assert entity_1.alarm_state == AlarmControlPanelState.ARMED_AWAY 60 | device.status_updated({"1": TuyaMode.DISARMED}) 61 | assert entity_1.alarm_state == AlarmControlPanelState.DISARMED 62 | device.status_updated({"1": TuyaMode.HOME}) 63 | assert entity_1.alarm_state == AlarmControlPanelState.ARMED_HOME 64 | device.status_updated({"1": TuyaMode.SOS}) 65 | assert entity_1.alarm_state == AlarmControlPanelState.TRIGGERED 66 | -------------------------------------------------------------------------------- /tests/test_auto_configure.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | from custom_components.localtuya.core.ha_entities import ( 3 | gen_localtuya_entities, 4 | DATA_PLATFORMS, 5 | ) 6 | from custom_components.localtuya.const import PLATFORMS 7 | 8 | 9 | COVER_DEVICE_DATA = { 10 | "device_config": { 11 | "friendly_name": "Cover", 12 | "dps_strings": [ 13 | "1 ( code: control , value: open )", 14 | "2 ( code: percent_control , value: 100 )", 15 | "3 ( code: percent_state , value: 100 )", 16 | "5 ( code: control_back_mode , value: forward )", 17 | "7 ( code: work_state , value: opening )", 18 | "11 ( code: situation_set , value: fully_open )", 19 | "12 ( code: fault , value: 0 )", 20 | "101 ( code: remote_register , value: False, cloud pull )", 21 | "102 ( code: reset_limit , value: False, cloud pull )", 22 | "103 ( code: up_confirm , value: True )", 23 | "104 ( code: middle_confirm , value: False )", 24 | "105 ( code: down_confirm , value: True )", 25 | "106 ( code: motor_mode , value: contiuation )", 26 | ], 27 | }, 28 | "device_cloud_info": { 29 | "active_time": 1660859328, 30 | "biz_type": 18, 31 | "category": "cl", 32 | "create_time": 1660859328, 33 | "icon": "smart/icon/ay1535532217868NsRD0/d303688e83885c9920b0b2dcf3872aa3.png", 34 | "id": "bfa2f86e3068440a449dhd", 35 | "ip": "2...1", 36 | "lat": "", 37 | "local_key": "999...420", 38 | "lon": "", 39 | "model": "", 40 | "name": "Blind", 41 | "online": True, 42 | "owner_id": "13377642", 43 | "product_id": "jzmy5ut0vishwscm", 44 | "product_name": "zemismart curtain motor", 45 | "status": [ 46 | {"code": "control", "value": "open"}, 47 | {"code": "percent_control", "value": 0}, 48 | {"code": "percent_state", "value": 0}, 49 | {"code": "control_back_mode", "value": "forward"}, 50 | {"code": "work_state", "value": "opening"}, 51 | {"code": "situation_set", "value": "fully_open"}, 52 | {"code": "fault", "value": 0}, 53 | ], 54 | "sub": False, 55 | "time_zone": "+03:00", 56 | "uid": "eu1...Hyb", 57 | "update_time": 1737849634, 58 | "uuid": "d3a81500860ab39c", 59 | "dps_data": { 60 | "1": { 61 | "code": "control", 62 | "custom_name": "", 63 | "dp_id": 1, 64 | "time": 1737581443559, 65 | "type": "Enum", 66 | "value": "open", 67 | "values": '{"type": "enum", "range": ["open", "stop", "close", "continue"]}', 68 | "id": 1, 69 | "accessMode": "rw", 70 | }, 71 | "2": { 72 | "code": "percent_control", 73 | "custom_name": "", 74 | "dp_id": 2, 75 | "time": 1738097484455, 76 | "type": "Integer", 77 | "value": 0, 78 | "values": '{"type": "value", "max": 100, "min": 0, "scale": 0, "step": 1, "unit": "%"}', 79 | "id": 2, 80 | "accessMode": "rw", 81 | }, 82 | "3": { 83 | "code": "percent_state", 84 | "custom_name": "", 85 | "dp_id": 3, 86 | "time": 1738097508589, 87 | "type": "value", 88 | "value": 0, 89 | "id": 3, 90 | "accessMode": "ro", 91 | "values": '{"type": "value", "max": 100, "min": 0, "scale": 0, "step": 1, "unit": "%"}', 92 | }, 93 | "5": { 94 | "code": "control_back_mode", 95 | "custom_name": "", 96 | "dp_id": 5, 97 | "time": 1734388862581, 98 | "type": "Enum", 99 | "value": "forward", 100 | "values": '{"type": "enum", "range": ["forward", "back"]}', 101 | "id": 5, 102 | "accessMode": "rw", 103 | }, 104 | "7": { 105 | "code": "work_state", 106 | "custom_name": "", 107 | "dp_id": 7, 108 | "time": 1735420780853, 109 | "type": "enum", 110 | "value": "opening", 111 | "id": 7, 112 | "accessMode": "ro", 113 | "values": '{"type": "enum", "range": ["opening", "closing"]}', 114 | }, 115 | "11": { 116 | "code": "situation_set", 117 | "custom_name": "", 118 | "dp_id": 11, 119 | "time": 1734388860575, 120 | "type": "enum", 121 | "value": "fully_open", 122 | "id": 11, 123 | "accessMode": "ro", 124 | "values": '{"type": "enum", "range": ["fully_open", "fully_close"]}', 125 | }, 126 | "12": { 127 | "code": "fault", 128 | "custom_name": "", 129 | "dp_id": 12, 130 | "time": 1734388860586, 131 | "type": "bitmap", 132 | "value": 0, 133 | "id": 12, 134 | "accessMode": "ro", 135 | "values": '{"type": "bitmap", "label": ["motor_fault"], "maxlen": 1}', 136 | }, 137 | "101": { 138 | "code": "remote_register", 139 | "custom_name": "", 140 | "dp_id": 101, 141 | "time": 1660859328099, 142 | "type": "bool", 143 | "value": False, 144 | "id": 101, 145 | "accessMode": "rw", 146 | "values": '{"type": "bool"}', 147 | }, 148 | "102": { 149 | "code": "reset_limit", 150 | "custom_name": "", 151 | "dp_id": 102, 152 | "time": 1660859328099, 153 | "type": "bool", 154 | "value": False, 155 | "id": 102, 156 | "accessMode": "rw", 157 | "values": '{"type": "bool"}', 158 | }, 159 | "103": { 160 | "code": "up_confirm", 161 | "custom_name": "", 162 | "dp_id": 103, 163 | "time": 1734388860596, 164 | "type": "bool", 165 | "value": True, 166 | "id": 103, 167 | "accessMode": "rw", 168 | "values": '{"type": "bool"}', 169 | }, 170 | "104": { 171 | "code": "middle_confirm", 172 | "custom_name": "", 173 | "dp_id": 104, 174 | "time": 1734388860605, 175 | "type": "bool", 176 | "value": False, 177 | "id": 104, 178 | "accessMode": "rw", 179 | "values": '{"type": "bool"}', 180 | }, 181 | "105": { 182 | "code": "down_confirm", 183 | "custom_name": "", 184 | "dp_id": 105, 185 | "time": 1734388860616, 186 | "type": "bool", 187 | "value": True, 188 | "id": 105, 189 | "accessMode": "rw", 190 | "values": '{"type": "bool"}', 191 | }, 192 | "106": { 193 | "code": "motor_mode", 194 | "custom_name": "", 195 | "dp_id": 106, 196 | "time": 1736952575226, 197 | "type": "enum", 198 | "value": "contiuation", 199 | "id": 106, 200 | "accessMode": "rw", 201 | "values": '{"type": "enum", "range": ["contiuation", "point"]}', 202 | }, 203 | }, 204 | }, 205 | } 206 | 207 | 208 | async def test_auto_configure(): 209 | 210 | for k in PLATFORMS.values(): 211 | assert k in DATA_PLATFORMS 212 | 213 | category = COVER_DEVICE_DATA["device_cloud_info"]["category"] 214 | entities = gen_localtuya_entities(COVER_DEVICE_DATA["device_config"], category) 215 | assert len(entities) > 4 216 | -------------------------------------------------------------------------------- /tests/test_binary_sensor.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.binary_sensor import ( 5 | LocalTuyaBinarySensor, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | STATE_ON = "activated" 10 | CONFIG = { 11 | DEVICE_NAME: { 12 | **DEVICE_CONFIG, 13 | "entities": [ 14 | { 15 | "entity_category": "None", 16 | "friendly_name": f"{PLATFORM_DOMAIN} 1", 17 | "icon": "", 18 | "id": "1", 19 | "state_on": STATE_ON, 20 | "platform": PLATFORM_DOMAIN, 21 | "restore_on_reconnect": False, 22 | } 23 | ], 24 | } 25 | } 26 | 27 | DPS_STATUS = {"1": "activated", "2": False} 28 | 29 | 30 | async def test_button(): 31 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaBinarySensor) 32 | entities: list[LocalTuyaBinarySensor] = get_entites(device) 33 | 34 | assert len(entities) > 0 35 | entity_1, *_ = entities 36 | assert type(entity_1) is LocalTuyaBinarySensor 37 | 38 | assert entity_1.state == "off" 39 | 40 | device.status_updated(DPS_STATUS) 41 | 42 | assert entity_1.state == "on" 43 | assert entity_1.dp_value("1") == STATE_ON 44 | -------------------------------------------------------------------------------- /tests/test_button.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.button import ( 5 | LocalTuyaButton, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | CONFIG = { 10 | DEVICE_NAME: { 11 | **DEVICE_CONFIG, 12 | "entities": [ 13 | { 14 | "entity_category": "None", 15 | "friendly_name": "Button 1", 16 | "icon": "", 17 | "id": "1", 18 | "is_passive_entity": False, 19 | "platform": "button", 20 | "restore_on_reconnect": False, 21 | } 22 | ], 23 | } 24 | } 25 | 26 | DPS_STATUS = {"1": True, "2": False} 27 | 28 | 29 | async def test_button(): 30 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaButton) 31 | entities: list[LocalTuyaButton] = get_entites(device) 32 | 33 | assert len(entities) > 0 34 | entity_1, *_ = entities 35 | assert type(entity_1) is LocalTuyaButton 36 | 37 | device.status_updated(DPS_STATUS) 38 | 39 | assert entity_1.state == None 40 | -------------------------------------------------------------------------------- /tests/test_climate.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.climate import ( 5 | LocalTuyaClimate, 6 | HVACAction, 7 | HVACMode, 8 | DOMAIN as PLATFORM_DOMAIN, 9 | ) 10 | 11 | FAN_SPEED_LIST = ["auto", "low", "middle", "high"] 12 | CONFIG = { 13 | DEVICE_NAME: { 14 | **DEVICE_CONFIG, 15 | "entities": [ 16 | { 17 | "friendly_name": "Terrace Floor", 18 | "entity_category": "None", 19 | "target_temperature_dp": "16", 20 | "current_temperature_dp": "24", 21 | "temperature_step": "1", 22 | "min_temperature": 7.0, 23 | "max_temperature": 35.0, 24 | "precision": "1", 25 | "target_precision": "1", 26 | "hvac_mode_dp": "2", 27 | "hvac_mode_set": { 28 | "auto": "program", 29 | "heat": "manual", 30 | "cool": "COLD", 31 | }, 32 | "hvac_action_dp": "100", 33 | "hvac_action_set": { 34 | "heating": True, 35 | "idle": False, 36 | }, 37 | "preset_dp": "5", 38 | "preset_set": {"holiday": "Holiday Friendly Name"}, 39 | "fan_speed_dp": "6", 40 | "fan_speed_list": ",".join(FAN_SPEED_LIST), 41 | "swing_mode_dp": "11", 42 | "swing_modes": { 43 | "both": "up-and-down", 44 | "up": "up-only", 45 | "down": "down-only", 46 | }, 47 | "swing_horizontal_dp": "12", 48 | "swing_horizontal_modes": { 49 | "both": "left-and-right", 50 | "left": "left-only", 51 | "right": "right-only", 52 | }, 53 | "temperature_unit": "fahrenheit/celsius", 54 | "id": "1", 55 | "eco_dp": "101", 56 | "eco_value": "eco_on", 57 | "platform": "climate", 58 | "icon": "", 59 | "heuristic_action": False, 60 | } 61 | ], 62 | } 63 | } 64 | 65 | DPS_STATUS = { 66 | "1": True, 67 | "2": "COLD", 68 | "5": "holiday", 69 | "6": "middle", 70 | "11": "both", 71 | "12": "right", 72 | "16": 68, # F 73 | "24": 24, # c 74 | "100": False, 75 | "101": "ECO_NOT", 76 | } 77 | 78 | 79 | async def test_climate(): 80 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaClimate) 81 | entities: list[LocalTuyaClimate] = get_entites(device) 82 | 83 | assert len(entities) > 0 84 | entity_1, *_ = entities 85 | assert type(entity_1) is LocalTuyaClimate 86 | 87 | assert entity_1._is_on == None 88 | 89 | status = {**DPS_STATUS, **{"1": False}} 90 | device.status_updated(status) 91 | assert type(entity_1._state_on) is bool 92 | 93 | assert entity_1._is_on == False 94 | assert entity_1.hvac_action == HVACAction.OFF 95 | assert entity_1.hvac_mode == HVACMode.OFF 96 | 97 | device.status_updated(DPS_STATUS) 98 | assert entity_1._is_on == True 99 | assert entity_1.hvac_action == HVACAction.IDLE 100 | assert entity_1.hvac_mode == HVACMode.COOL 101 | assert entity_1.fan_modes == FAN_SPEED_LIST 102 | assert entity_1.preset_mode == "Holiday Friendly Name" 103 | assert entity_1.current_temperature == 24 104 | assert entity_1.target_temperature == 20 # f to c 105 | assert entity_1.fan_mode == "middle" 106 | 107 | # Eco Preset 108 | device.status_updated({**DPS_STATUS, **{"101": "eco_on"}}) 109 | assert entity_1.preset_mode == "eco" 110 | 111 | # heuristic_action 112 | entity_1._hvac_action_dp = None 113 | status = {**DPS_STATUS, **{"2": "manual", "24": 18.9}} 114 | device.status_updated(status) 115 | assert entity_1.hvac_action == HVACAction.HEATING 116 | status = {**DPS_STATUS, **{"2": "manual", "24": 21.1}} 117 | device.status_updated(status) 118 | assert entity_1.hvac_action == HVACAction.IDLE 119 | 120 | # String DP ID 121 | status = {**DPS_STATUS, **{"1": "OFF"}} 122 | device.status_updated(status) 123 | assert not entity_1._is_on 124 | assert type(entity_1._state_on) is str 125 | 126 | # Integer DP ID 127 | status = {**DPS_STATUS, **{"1": 1}} 128 | device.status_updated(status) 129 | assert entity_1._is_on 130 | assert type(entity_1._state_on) is int 131 | 132 | # Swing modes 133 | assert entity_1.swing_mode == "up-and-down" 134 | assert entity_1.swing_horizontal_mode == "right-only" 135 | device.status_updated({**DPS_STATUS, **{"11": "up", "12": "both"}}) 136 | assert entity_1.swing_mode == "up-only" 137 | assert entity_1.swing_horizontal_mode == "left-and-right" 138 | -------------------------------------------------------------------------------- /tests/test_cover.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.cover import ( 5 | LocalTuyaCover, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | STATE_OPENING, 8 | STATE_CLOSING, 9 | STATE_STOPPED, 10 | STATE_SET_CMD, 11 | STATE_SET_OPENING, 12 | STATE_SET_CLOSING, 13 | ) 14 | 15 | CONFIG = { 16 | DEVICE_NAME: { 17 | **DEVICE_CONFIG, 18 | "entities": [ 19 | { 20 | "commands_set": "open_close_stop", 21 | "current_position_dp": "3", 22 | "entity_category": "None", 23 | "friendly_name": "Curtain", 24 | "icon": "", 25 | "id": "1", 26 | "platform": "cover", 27 | "position_inverted": False, 28 | "positioning_mode": "position", 29 | "set_position_dp": "2", 30 | "span_time": 25.0, 31 | }, 32 | ], 33 | } 34 | } 35 | 36 | DPS_STATUS = {"1": "stop", "2": 80, "3": 80} 37 | 38 | 39 | async def test_cover(): 40 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaCover) 41 | entities: list[LocalTuyaCover] = get_entites(device) 42 | 43 | assert len(entities) > 0 44 | entity_1, *_ = entities 45 | entity_1.schedule_update_ha_state = lambda: None 46 | assert type(entity_1) is LocalTuyaCover 47 | 48 | status = DPS_STATUS.copy() 49 | device.status_updated(DPS_STATUS) 50 | 51 | assert entity_1.is_closed == False 52 | assert entity_1.current_cover_position == status["2"] 53 | 54 | await entity_1.async_set_cover_position(position=0) 55 | assert entity_1._current_state == STATE_SET_CLOSING 56 | await entity_1.async_set_cover_position(position=100) 57 | assert entity_1._current_state == STATE_SET_OPENING 58 | 59 | device.status_updated({**status, **{"2": 100, "3": 100}}) 60 | assert entity_1.is_closed == False 61 | await entity_1.async_set_cover_position(position=100) 62 | assert entity_1._current_state == STATE_STOPPED 63 | 64 | # Position inverted. 65 | entity_1._position_inverted = True 66 | device.status_updated({}) 67 | assert entity_1.is_closed == True 68 | 69 | # await entity_1.async_set_cover_position(position=100) 70 | # assert entity_1._current_state == STATE_SET_CLOSING 71 | -------------------------------------------------------------------------------- /tests/test_discovery.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.discovery import TuyaDiscovery 5 | 6 | 7 | DEVICE3_3 = b"\x00\x00U\xaa\x00\x00\x00\x00\x00\x00\x00\x13\x00\x00\x00\x9c\x00\x00\x00\x00\xd0\x97fgo3i\xeb\x10\xb5\xe9\xf12\xfd\x80*\xfcL\xa4\x07\xf4\x8b\x98A\xad\xe0d\xbd\xa76\x9d\xa2\xb6^b\xea\xdc7\x1d\xb4!'\x98\xca\x04+\xff\xc3\xd9I_\xff\x17L)\x02\xe4^;:\xad$\x8f^\xc2\xfb\x84Y\xb1\x15_\xc7]K\xf6i\x9f\x92\xcb\xa4\xc0\xbaR\x01H\x04^v\x05\xfa\x04\x98\xdf\xeaZ\xab\xf1\xb12s\x08\xc3\x92q\x11\xc6!H\x94*+\xc1\x96\xcb\xf5b\x168\xef\x10\x01d\xbb\x81\x94n\xa8]n\xda\x8e\xea@\xe9;\x1e?\xc1J%p\xe1\x82yf3\xd2\xf1\x00\x00\xaaU" 8 | DEVICE3_4 = b"\x00\x00U\xaa\x00\x00\x00\x00\x00\x00\x00#\x00\x00\x00\xbc\x00\x00\x00\x00\xd0\x97fgo3i\xeb\x10\xb5\xe9\xf12\xfd\x80*L+\xe5,T(z\x15\xb0(+\xe3\x88\xa4T\x0eYb\xdd\x10\xb3\xf1f%\x8f\xff\xbc\x11q>-}-\xaf_kr\xdb\x94?\xb0\x82D\xc3\xe4\xee\x98\xf0\xc2\xfb\x84Y\xb1\x15_\xc7]K\xf6i\x9f\x92\xcb\xa4\xc0\xbaR\x01H\x04^v\x05\xfa\x04\x98\xdf\xeaZ\xab'\xa7\xaf\xd2\xbdmF\xefr\xb6\xdfi\xa1V\x00-<\x9d\xd05t\xec\x0e\x0b\r\x8f\x86\x0f7KA\x98\x06\x16\xcdD\x03ov\x01\xabq\xf0\xaa\x83\x91\xa5\\\xe0\x91:y7B2+\x90\x96IH\xda\xd4\xb6\x0c\xc0\t{l\xdc\xaaZ\x14\x82#\xb3$f\x86\xa1.\xbb\x961[\x00\x00\xaaU" 9 | DEVICE3_5 = b"\x00\x00f\x99\x00\x00\x00\x00\x00\x00\x00\x00\x00\x13\x00\x00\x00\xf0\x93\x07 \xd5\x92~\xcf\xf2\x91\x85S\xdc\x9f\x8dY=dS\x18\xca>\xae\xc2\xcf\xe7iV\xf2{\xc4!\xe7\xb8\x00i\x11K\x11j\x0e5\xed\x8c\xe7mO\x91c\xceAGS\x7f@\xd6\x12\n\xce\x92\xb4\x9a\xe5\xef\xf4\x8e\xdf\xd3\xe1\xda\rt\xf1\xee\x1b\x86z\xb28\x9a\x11\xebx\x9c\xe4\x9b\x19t6L\x13JZ7\xe7\xa6\x88\xb9\xa9\xcc\xf91\xdc\x8f\x1d%>\x13\x10M'\xeeG\x9e\xf7\xe5\xd6\xdeK1W\xe0\xa9\xf5\x8c\\\xa1\xd6<\x1e\x1ec\xfb\xc9 CV\x9d\xa3C@I\x1c\x15\xb4=6\xa0\xce\n+\xef\x1c\xc1\x96\xf1_\xc0Y2\xe2\xcd\xc4j\xa7H\xcf\xe1B\xe1\xed^\x98\xfe;\xf1P:!%\x82*\xc9\xf6\xbd\x17\x8e\xd9\xb5\xdf\xba\x19\x8d\x03\x9b\xfa\x00\x99s\xd6t\x0eD=&\xd8\xd1\xd7\x827\xec\xac\xf2\x19\x8a\xfe\x94\x1f\xd5\xe6\xe1a\xc1\xfb 1\xa8\xf4]vd\x18\xed\x86<\x11\x13\xde\x14\xf2\xba\x00\x00\x99f" 10 | 11 | 12 | async def test_dsicovery(): 13 | mock_callback = AsyncMock() 14 | discovery = TuyaDiscovery(mock_callback) 15 | 16 | discovery.datagram_received(DEVICE3_3, None) 17 | discovery.datagram_received(DEVICE3_4, None) 18 | discovery.datagram_received(DEVICE3_5, None) 19 | 20 | mock_callback.assert_called() 21 | assert len(discovery.devices) == 3 22 | -------------------------------------------------------------------------------- /tests/test_fan.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | import math 5 | from custom_components.localtuya.fan import LocalTuyaFan, DOMAIN as PLATFORM_DOMAIN 6 | from homeassistant.util.percentage import ( 7 | int_states_in_range, 8 | ordered_list_item_to_percentage, 9 | percentage_to_ordered_list_item, 10 | percentage_to_ranged_value, 11 | ranged_value_to_percentage, 12 | ) 13 | 14 | CONFIG = { 15 | DEVICE_NAME: { 16 | **DEVICE_CONFIG, 17 | "entities": [ 18 | { 19 | "friendly_name": "Fan", 20 | "entity_category": "config", 21 | "fan_speed_control": "3", 22 | "fan_direction": "4", 23 | "fan_direction_forward": "forward", 24 | "fan_direction_reverse": "reverse", 25 | "fan_speed_min": 1, 26 | "fan_speed_max": 6, 27 | "fan_speed_ordered_list": "disabled", 28 | "id": "1", 29 | "platform": "fan", 30 | "icon": "", 31 | "fan_oscillating_control": "6", 32 | }, 33 | { 34 | "friendly_name": "Fan", 35 | "entity_category": "config", 36 | "fan_speed_control": "2", 37 | "fan_direction": "4", 38 | "fan_direction_forward": "forward", 39 | "fan_direction_reverse": "reverse", 40 | "fan_speed_min": 1, 41 | "fan_speed_max": 6, 42 | "fan_speed_ordered_list": "low,mid,high,max", 43 | "id": "21", 44 | "platform": "fan", 45 | "icon": "", 46 | }, 47 | ], 48 | } 49 | } 50 | 51 | DPS_STATUS = {"1": True, "2": "mid", "3": 4, "4": "reverse", "6": True} 52 | 53 | 54 | async def test_fan(): 55 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaFan) 56 | entities: list[LocalTuyaFan] = get_entites(device) 57 | 58 | assert len(entities) > 0 59 | entity_1, entity_2, *_ = entities 60 | assert type(entity_1) is LocalTuyaFan 61 | 62 | status = DPS_STATUS.copy() 63 | assert not entity_1.is_on 64 | device.status_updated(status) 65 | 66 | assert entity_1.is_on 67 | assert ( 68 | entity_1.current_direction 69 | == status[CONFIG[DEVICE_NAME]["entities"][0]["fan_direction"]] 70 | ) 71 | assert entity_1.oscillating == True 72 | 73 | speed_range = entity_1._speed_range 74 | speed_percentage = ranged_value_to_percentage( 75 | speed_range, status[CONFIG[DEVICE_NAME]["entities"][0]["fan_speed_control"]] 76 | ) 77 | assert entity_1.percentage == speed_percentage 78 | 79 | assert percentage_to_ranged_value(speed_range, 100) == 6 80 | assert math.ceil(percentage_to_ranged_value(speed_range, 1)) == 1 81 | 82 | # Order speed. 83 | speed_range = entity_2._ordered_list 84 | speed_percentage = ordered_list_item_to_percentage(speed_range, "mid") 85 | assert entity_2.percentage == speed_percentage 86 | assert percentage_to_ordered_list_item(speed_range, 0) == speed_range[0] 87 | assert percentage_to_ordered_list_item(speed_range, 100) == speed_range[-1] 88 | -------------------------------------------------------------------------------- /tests/test_humidifier.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.humidifier import ( 5 | LocalTuyaHumidifier, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | CONFIG = { 10 | DEVICE_NAME: { 11 | **DEVICE_CONFIG, 12 | "entities": [ 13 | { 14 | "device_class": "dehumidifier", 15 | "entity_category": "None", 16 | "friendly_name": "", 17 | "humidifier_available_modes": { 18 | "continuous": "Continuous", 19 | "dehumidify": "Dehumidify", 20 | "drying": "Drying", 21 | }, 22 | "humidifier_current_humidity_dp": "16", 23 | "humidifier_mode_dp": "4", 24 | "humidifier_set_humidity_dp": "2", 25 | "icon": "", 26 | "id": "1", 27 | "max_humidity": 70, 28 | "min_humidity": 35, 29 | "platform": "humidifier", 30 | }, 31 | ], 32 | } 33 | } 34 | ENTITIES = CONFIG[DEVICE_NAME]["entities"] 35 | DPS_STATUS = {"1": True, "2": 34, "4": "drying", "16": 34} 36 | 37 | 38 | async def test_humidifier(): 39 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaHumidifier) 40 | entities: list[LocalTuyaHumidifier] = get_entites(device) 41 | 42 | assert len(entities) > 0 43 | entity_1, *_ = entities 44 | assert type(entity_1) is LocalTuyaHumidifier 45 | 46 | assert entity_1.state == None 47 | 48 | status = DPS_STATUS.copy() 49 | device.status_updated(status) 50 | 51 | assert entity_1.state == "on" 52 | assert entity_1.current_humidity == status.get( 53 | ENTITIES[0]["humidifier_current_humidity_dp"] 54 | ) 55 | assert ( 56 | entity_1.mode 57 | == ENTITIES[0]["humidifier_available_modes"][ 58 | status[ENTITIES[0]["humidifier_mode_dp"]] 59 | ] 60 | ) 61 | assert ( 62 | entity_1.target_humidity 63 | == status[ENTITIES[0]["humidifier_current_humidity_dp"]] 64 | ) 65 | -------------------------------------------------------------------------------- /tests/test_light.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.light import ( 5 | LocalTuyaLight, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ColorMode, 8 | ) 9 | 10 | CONFIG = { 11 | DEVICE_NAME: { 12 | **DEVICE_CONFIG, 13 | "entities": [ 14 | { 15 | "id": "20", 16 | "color_mode": "21", 17 | "brightness": "22", 18 | "color_temp": "23", 19 | "color": "24", 20 | "scene": "25", 21 | "brightness_lower": 10, 22 | "brightness_upper": 1000, 23 | "color_temp_min_kelvin": 2700, 24 | "color_temp_max_kelvin": 6500, 25 | "color_temp_reverse": False, 26 | "music_mode": True, 27 | "friendly_name": None, 28 | "icon": "", 29 | "entity_category": "None", 30 | "platform": "light", 31 | } 32 | ], 33 | } 34 | } 35 | 36 | DPS_STATUS = { 37 | "20": True, 38 | "21": "white", 39 | "22": 600, 40 | "23": 1000, 41 | "24": "000403e8000c", 42 | "25": "010e0d000084000003e800000000", 43 | } 44 | ENC_COLOR = "0319090087db1c" 45 | BLE_COLOR = "0319090087db1c" 46 | 47 | 48 | async def test_light(): 49 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaLight) 50 | entities: list[LocalTuyaLight] = get_entites(device) 51 | 52 | assert len(entities) > 0 53 | entity_1, *_ = entities 54 | assert type(entity_1) is LocalTuyaLight 55 | 56 | status = DPS_STATUS.copy() 57 | device.status_updated(status) 58 | 59 | assert entity_1.state == "on" 60 | assert entity_1.brightness is not None 61 | assert entity_1.is_white_mode 62 | assert entity_1.color_temp_kelvin is not None 63 | 64 | device.status_updated({"21": "colour"}) 65 | assert entity_1.hs_color is not None 66 | 67 | device.status_updated({"24": ENC_COLOR}) 68 | sat, brightness = entity_1.hs_color 69 | assert sat < 360 and brightness <= 100 70 | 71 | device.status_updated({"21": "music"}) 72 | assert entity_1.is_music_mode 73 | 74 | device.status_updated({"21": "scene"}) 75 | assert entity_1.effect is not None 76 | assert entity_1.is_scene_mode 77 | 78 | # Bluetooth 79 | # device.status_updated({"21": "colour", "24": "AHhkZA==", "25": ""}) 80 | -------------------------------------------------------------------------------- /tests/test_lock.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.lock import LocalTuyaLock, DOMAIN as PLATFORM_DOMAIN 5 | 6 | STATE_ON = "activated" 7 | CONFIG = { 8 | DEVICE_NAME: { 9 | **DEVICE_CONFIG, 10 | "entities": [ 11 | { 12 | "entity_category": "None", 13 | "friendly_name": f"{PLATFORM_DOMAIN} 1", 14 | "icon": "", 15 | "id": "1", 16 | "lock_state_dp": "2", 17 | "jammed_dp": "3", 18 | "platform": PLATFORM_DOMAIN, 19 | "restore_on_reconnect": False, 20 | } 21 | ], 22 | } 23 | } 24 | 25 | DPS_STATUS = {"1": None, "2": "unlocked"} 26 | 27 | 28 | async def test_lock(): 29 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaLock) 30 | entities: list[LocalTuyaLock] = get_entites(device) 31 | 32 | assert len(entities) > 0 33 | entity_1, *_ = entities 34 | assert type(entity_1) is LocalTuyaLock 35 | 36 | device.status_updated(DPS_STATUS) 37 | assert entity_1.state == "unlocked" 38 | 39 | device.status_updated({**DPS_STATUS, **{"1": True}}) 40 | assert not entity_1.is_locked 41 | 42 | assert not entity_1.is_jammed 43 | device.status_updated({**DPS_STATUS, **{"3": True}}) 44 | assert entity_1.is_jammed 45 | -------------------------------------------------------------------------------- /tests/test_number.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.number import ( 5 | LocalTuyaNumber, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | CONFIG = { 10 | DEVICE_NAME: { 11 | **DEVICE_CONFIG, 12 | "entities": [ 13 | { 14 | "entity_category": "None", 15 | "friendly_name": f"{PLATFORM_DOMAIN} 1", 16 | "icon": "", 17 | "id": "1", 18 | "scaling": 0.01, 19 | "platform": PLATFORM_DOMAIN, 20 | "restore_on_reconnect": False, 21 | } 22 | ], 23 | } 24 | } 25 | 26 | DPS_STATUS = {"1": 500} 27 | 28 | 29 | async def test_lock(): 30 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaNumber) 31 | entities: list[LocalTuyaNumber] = get_entites(device) 32 | 33 | assert len(entities) > 0 34 | entity_1, *_ = entities 35 | assert type(entity_1) is LocalTuyaNumber 36 | 37 | device.status_updated(DPS_STATUS) 38 | assert entity_1.native_value == 5 39 | -------------------------------------------------------------------------------- /tests/test_remote.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.remote import ( 5 | LocalTuyaRemote, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | STATE_ON = "activated" 10 | CONFIG = { 11 | DEVICE_NAME: { 12 | **DEVICE_CONFIG, 13 | "entities": [ 14 | { 15 | "entity_category": "None", 16 | "friendly_name": f"{PLATFORM_DOMAIN} 1", 17 | "icon": "", 18 | "id": "1", 19 | "platform": PLATFORM_DOMAIN, 20 | "restore_on_reconnect": False, 21 | } 22 | ], 23 | } 24 | } 25 | 26 | DPS_STATUS = { 27 | "201": "", 28 | "202": "", 29 | } 30 | 31 | 32 | async def test_lock(): 33 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaRemote) 34 | entities: list[LocalTuyaRemote] = get_entites(device) 35 | 36 | assert len(entities) > 0 37 | entity_1, *_ = entities 38 | assert type(entity_1) is LocalTuyaRemote 39 | 40 | # device.status_updated(DPS_STATUS) 41 | -------------------------------------------------------------------------------- /tests/test_select.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.select import ( 5 | LocalTuyaSelect, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | CONFIG = { 10 | DEVICE_NAME: { 11 | **DEVICE_CONFIG, 12 | "entities": [ 13 | { 14 | "entity_category": "config", 15 | "friendly_name": "Motor Direction", 16 | "icon": "mdi:swap-vertical", 17 | "id": "5", 18 | "is_passive_entity": False, 19 | "platform": PLATFORM_DOMAIN, 20 | "restore_on_reconnect": False, 21 | "select_options": {"back": "Back", "forward": "Forward"}, 22 | } 23 | ], 24 | } 25 | } 26 | 27 | DPS_STATUS = {"5": "back"} 28 | 29 | 30 | async def test_lock(): 31 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaSelect) 32 | entities: list[LocalTuyaSelect] = get_entites(device) 33 | 34 | assert len(entities) > 0 35 | entity_1, *_ = entities 36 | assert type(entity_1) is LocalTuyaSelect 37 | 38 | device.status_updated(DPS_STATUS) 39 | assert ( 40 | entity_1.state in CONFIG[DEVICE_NAME]["entities"][0]["select_options"].values() 41 | ) 42 | -------------------------------------------------------------------------------- /tests/test_siren.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.siren import ( 5 | LocalTuyaSiren, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | ) 8 | 9 | STATE_ON = "activated" 10 | CONFIG = { 11 | DEVICE_NAME: { 12 | **DEVICE_CONFIG, 13 | "entities": [ 14 | { 15 | "friendly_name": "Siren", 16 | "id": "5", 17 | "state_on": STATE_ON, 18 | "is_passive_entity": False, 19 | "platform": PLATFORM_DOMAIN, 20 | "restore_on_reconnect": False, 21 | } 22 | ], 23 | } 24 | } 25 | 26 | DPS_STATUS = {"5": STATE_ON} 27 | 28 | 29 | async def test_siren(): 30 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaSiren) 31 | entities: list[LocalTuyaSiren] = get_entites(device) 32 | 33 | assert len(entities) > 0 34 | entity_1, *_ = entities 35 | assert type(entity_1) is LocalTuyaSiren 36 | 37 | assert not entity_1.is_on 38 | device.status_updated(DPS_STATUS) 39 | assert entity_1.is_on 40 | -------------------------------------------------------------------------------- /tests/test_switch.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from homeassistant.const import EntityCategory 5 | from custom_components.localtuya.switch import LocalTuyaSwitch, DOMAIN as SWITCH_DOMAIN 6 | 7 | CONFIG = { 8 | DEVICE_NAME: { 9 | **DEVICE_CONFIG, 10 | "entities": [ 11 | { 12 | "entity_category": "None", 13 | "friendly_name": "Switch 1", 14 | "icon": "", 15 | "id": "1", 16 | "is_passive_entity": False, 17 | "platform": "switch", 18 | "restore_on_reconnect": False, 19 | }, 20 | { 21 | "entity_category": "config", 22 | "friendly_name": "Switch 2", 23 | "icon": "", 24 | "id": "2", 25 | "is_passive_entity": False, 26 | "platform": "switch", 27 | "restore_on_reconnect": False, 28 | }, 29 | ], 30 | } 31 | } 32 | 33 | DPS_STATUS = {"1": True, "2": False} 34 | 35 | 36 | async def test_switch(): 37 | device = await init(CONFIG, SWITCH_DOMAIN, LocalTuyaSwitch) 38 | entities: list[LocalTuyaSwitch] = get_entites(device) 39 | 40 | assert len(entities) > 0 41 | entity_sw1, entity_sw2, *_ = entities 42 | assert type(entity_sw1) is LocalTuyaSwitch 43 | 44 | assert entity_sw1.state == None 45 | device.status_updated(DPS_STATUS) 46 | 47 | assert entity_sw1.state == "on" 48 | assert entity_sw2.state == "off" 49 | assert entity_sw2.entity_category == EntityCategory.CONFIG 50 | -------------------------------------------------------------------------------- /tests/test_vacuum.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.vacuum import ( 5 | LocalTuyaVacuum, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | VacuumActivity, 8 | CONF_MODE_DP, 9 | CONF_PAUSE_DP, 10 | ) 11 | 12 | CONFIG = { 13 | DEVICE_NAME: { 14 | **DEVICE_CONFIG, 15 | "entities": [ 16 | { 17 | "entity_category": "None", 18 | "powergo_dp": "2", 19 | "idle_status_value": "standby,sleep", 20 | "docked_status_value": "charging,chargecompleted,charge_done", 21 | "returning_status_value": "docking,to_charge,goto_charge", 22 | "paused_state": "paused", 23 | "stop_status": "standby", 24 | "pause_dp": "101", 25 | "battery_dp": "6", 26 | "mode_dp": "3", 27 | "modes": "smart,zone,pose,part,chargego,wallfollow,selectroom", 28 | "return_mode": "chargego", 29 | "fan_speed_dp": "14", 30 | "fan_speeds": "strong,normal,quiet", 31 | "clean_time_dp": "17", 32 | "clean_area_dp": "16", 33 | "clean_record_dp": "19", 34 | "locate_dp": "13", 35 | "fault_dp": "18", 36 | "friendly_name": "", 37 | "id": "5", 38 | "platform": PLATFORM_DOMAIN, 39 | "icon": "mdi:robot-vacuum", 40 | } 41 | ], 42 | } 43 | } 44 | 45 | DPS_STATUS = { 46 | "2": True, # powergo_dp 47 | "3": "smart", # mode_dp 48 | "5": "cleaning", # id - state 49 | "6": 45, # battery_dp 50 | "13": False, # locate_dp 51 | "14": "quiet", # fan_speed_dp 52 | "16": 0, # clean_area_dp 53 | "17": 0, # clean_time_dp 54 | "18": 0, # fault_dp 55 | "19": "", # clean_record_dp 56 | "101": False, # pause_dp 57 | } 58 | 59 | 60 | async def test_vacuum(): 61 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaVacuum) 62 | entities: list[LocalTuyaVacuum] = get_entites(device) 63 | 64 | assert len(entities) > 0 65 | entity_1, *_ = entities 66 | assert type(entity_1) is LocalTuyaVacuum 67 | 68 | entity_1_cfg = CONFIG[DEVICE_NAME]["entities"][0] 69 | 70 | status = DPS_STATUS.copy() 71 | device.status_updated(status) 72 | assert entity_1.state == VacuumActivity.CLEANING 73 | assert entity_1.battery_level == 45 74 | assert entity_1.fan_speed == "quiet" 75 | assert status[entity_1_cfg[CONF_MODE_DP]] in entity_1._modes_list 76 | 77 | device.status_updated({entity_1_cfg[CONF_PAUSE_DP]: True}) 78 | assert entity_1.state == VacuumActivity.PAUSED 79 | 80 | device.status_updated({entity_1_cfg["id"]: "standby"}) 81 | assert entity_1.state == VacuumActivity.IDLE 82 | -------------------------------------------------------------------------------- /tests/test_water_heater.py: -------------------------------------------------------------------------------- 1 | """Test for localtuya.""" 2 | 3 | from . import * 4 | from custom_components.localtuya.water_heater import ( 5 | LocalTuyaWaterHeater, 6 | DOMAIN as PLATFORM_DOMAIN, 7 | CONF_TARGET_TEMPERATURE_DP, 8 | CONF_CURRENT_TEMPERATURE_DP, 9 | CONF_TARGET_TEMPERATURE_LOW_DP, 10 | CONF_TARGET_TEMPERATURE_HIGH_DP, 11 | CONF_MODES, 12 | CONF_MODE_DP, 13 | ) 14 | 15 | CONFIG = { 16 | DEVICE_NAME: { 17 | **DEVICE_CONFIG, 18 | "entities": [ 19 | { 20 | "friendly_name": "Water Heater", 21 | "id": "1", 22 | "target_temperature_dp": "2", 23 | "target_temperature_low_dp": "2", 24 | "target_temperature_high_dp": "3", 25 | "current_temperature_dp": "4", 26 | "mode_dp": "5", 27 | "modes": { 28 | "eheat": "Heating", 29 | "bcool": "Cooling", 30 | "auto": "Auto", 31 | }, 32 | "precision": "1", 33 | "target_precision": "1", 34 | "is_passive_entity": False, 35 | "platform": PLATFORM_DOMAIN, 36 | "restore_on_reconnect": False, 37 | } 38 | ], 39 | } 40 | } 41 | 42 | DPS_STATUS = { 43 | "1": True, 44 | "2": 20, 45 | "3": 25, 46 | "4": 22, 47 | "5": "eheat", 48 | } 49 | 50 | 51 | async def test_water_heater(): 52 | device = await init(CONFIG, PLATFORM_DOMAIN, LocalTuyaWaterHeater) 53 | entities: list[LocalTuyaWaterHeater] = get_entites(device) 54 | 55 | assert len(entities) > 0 56 | entity_1, *_ = entities 57 | assert type(entity_1) is LocalTuyaWaterHeater 58 | 59 | device.status_updated(DPS_STATUS) 60 | entity_1_cfg = CONFIG[DEVICE_NAME]["entities"][0] 61 | 62 | assert entity_1.state == entity_1_cfg[CONF_MODES].get( 63 | DPS_STATUS.get(entity_1_cfg[CONF_MODE_DP]), False 64 | ) 65 | assert entity_1.target_temperature == DPS_STATUS.get( 66 | entity_1_cfg[CONF_TARGET_TEMPERATURE_DP], False 67 | ) 68 | assert entity_1.target_temperature_low == DPS_STATUS.get( 69 | entity_1_cfg[CONF_TARGET_TEMPERATURE_LOW_DP], False 70 | ) 71 | assert entity_1.target_temperature_high == DPS_STATUS.get( 72 | entity_1_cfg[CONF_TARGET_TEMPERATURE_HIGH_DP], False 73 | ) 74 | assert entity_1.current_temperature == DPS_STATUS.get( 75 | entity_1_cfg[CONF_CURRENT_TEMPERATURE_DP], False 76 | ) 77 | -------------------------------------------------------------------------------- /tuyadebug.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xZetsubou/hass-localtuya/0bc0fdd5723b10d8819e9e959b583ab530daf698/tuyadebug.tgz --------------------------------------------------------------------------------