diff --git a/.cookiecutter.json b/.cookiecutter.json new file mode 100644 index 0000000..1be3a9a --- /dev/null +++ b/.cookiecutter.json @@ -0,0 +1,10 @@ +{ + "_template": "gh:oncleben31/cookiecutter-homeassistant-custom-component", + "class_name_prefix": "TplinkDeco", + "domain_name": "tplink_deco", + "friendly_name": "TP-Link Deco", + "github_user": "amosyuen", + "project_name": "tplink-deco", + "test_suite": "no", + "version": "0.0.0" +} diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..07b5139 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,8 @@ +default_config: + +logger: + default: info + logs: + custom_components.tplink_deco: debug +# If you need to debug uncommment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) +# debugpy: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..9a04853 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ludeeus/container:integration-debian", + "name": "TP-Link Deco integration development", + "context": "..", + "appPort": ["9123:8123"], + "postCreateCommand": "container install", + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "terminal.integrated.shell.linux": "/bin/bash", + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a09db44 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,16 @@ +--- +name: Feature request +about: Suggest an idea for this project +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..218615e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,40 @@ +--- +name: Issue +about: Create a report to help us improve +--- + + + +## Version of the custom_component + + + +## Configuration + +```yaml +Add your logs here. +``` + +## Describe the bug + +A clear and concise description of what the bug is. + +## Debug log + + + +```text + +Add your logs here. + +``` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..15c7513 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + - package-ecosystem: pip + directory: "/.github/workflows" + schedule: + interval: daily + - package-ecosystem: pip + directory: "/" + schedule: + interval: daily diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..f7f83aa --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,66 @@ +--- +# Labels names are important as they are used by Release Drafter to decide +# regarding where to record them in changelog or if to skip them. +# +# The repository labels will be automatically configured using this file and +# the GitHub Action https://github.com/marketplace/actions/github-labeler. +- name: breaking + description: Breaking Changes + color: bfd4f2 +- name: bug + description: Something isn't working + color: d73a4a +- name: build + description: Build System and Dependencies + color: bfdadc +- name: ci + description: Continuous Integration + color: 4a97d6 +- name: dependencies + description: Pull requests that update a dependency file + color: 0366d6 +- name: documentation + description: Improvements or additions to documentation + color: 0075ca +- name: duplicate + description: This issue or pull request already exists + color: cfd3d7 +- name: enhancement + description: New feature or request + color: a2eeef +- name: github_actions + description: Pull requests that update Github_actions code + color: "000000" +- name: good first issue + description: Good for newcomers + color: 7057ff +- name: help wanted + description: Extra attention is needed + color: 008672 +- name: invalid + description: This doesn't seem right + color: e4e669 +- name: performance + description: Performance + color: "016175" +- name: python + description: Pull requests that update Python code + color: 2b67c6 +- name: question + description: Further information is requested + color: d876e3 +- name: refactoring + description: Refactoring + color: ef67c4 +- name: removal + description: Removals and Deprecations + color: 9ae7ea +- name: style + description: Style + color: c120e5 +- name: testing + description: Testing + color: b1fc6f +- name: wontfix + description: This will not be worked on + color: ffffff diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..7a04410 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,29 @@ +categories: + - title: ":boom: Breaking Changes" + label: "breaking" + - title: ":rocket: Features" + label: "enhancement" + - title: ":fire: Removals and Deprecations" + label: "removal" + - title: ":beetle: Fixes" + label: "bug" + - title: ":racehorse: Performance" + label: "performance" + - title: ":rotating_light: Testing" + label: "testing" + - title: ":construction_worker: Continuous Integration" + label: "ci" + - title: ":books: Documentation" + label: "documentation" + - title: ":hammer: Refactoring" + label: "refactoring" + - title: ":lipstick: Style" + label: "style" + - title: ":package: Dependencies" + labels: + - "dependencies" + - "build" +template: | + ## Changes + + $CHANGES diff --git a/.github/workflows/constraints.txt b/.github/workflows/constraints.txt new file mode 100644 index 0000000..5b1ca4b --- /dev/null +++ b/.github/workflows/constraints.txt @@ -0,0 +1,5 @@ +pip==21.0 +pre-commit==2.9.3 +black==20.8b1 +flake8==3.8.4 +reorder-python-imports==2.3.6 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..54ee057 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,20 @@ +name: Manage labels + +on: + push: + branches: + - main + - master + +jobs: + labeler: + name: Labeler + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v2.3.4 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@v3.1.1 + with: + skip-delete: true diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..26e1e08 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,15 @@ +name: Draft a release note +on: + push: + branches: + - main + - master +jobs: + draft_release: + name: Release Drafter + runs-on: ubuntu-latest + steps: + - name: Run release-drafter + uses: release-drafter/release-drafter@v5.13.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..4ad47b9 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,63 @@ +name: Linting + +on: + push: + branches: + - main + - master + - dev + pull_request: + schedule: + - cron: "0 0 * * *" + +env: + DEFAULT_PYTHON: 3.9 + +jobs: + pre-commit: + runs-on: ubuntu-latest + name: Pre-commit + steps: + - name: Check out the repository + uses: actions/checkout@v2 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + + - name: Upgrade pip + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip --version + + - name: Install Python modules + run: | + pip install --constraint=.github/workflows/constraints.txt pre-commit black flake8 reorder-python-imports + + - name: Run pre-commit on all files + run: | + pre-commit run --all-files --show-diff-on-failure --color=always + + hacs: + runs-on: "ubuntu-latest" + name: HACS + steps: + - name: Check out the repository + uses: actions/checkout@v2 + + - name: HACS validation + uses: hacs/action@main + with: + category: "integration" + ignore: brands + + hassfest: + runs-on: ubuntu-latest + name: Hassfest + steps: + - name: Check out the repository + uses: "actions/checkout@v2" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4a4d84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +pythonenv* +.python-version +.coverage +venv +.venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..511f9f0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.3.0 + hooks: + - id: check-added-large-files + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: local + hooks: + - id: black + name: black + entry: black + language: system + types: [python] + require_serial: true + - id: flake8 + name: flake8 + entry: flake8 + language: system + types: [python] + require_serial: true + - id: reorder-python-imports + name: Reorder python imports + entry: reorder-python-imports + language: system + types: [python] + args: [--application-directories=custom_components] + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.2.1 + hooks: + - id: prettier diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..cc5337a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + // Example of attaching to local debug server + "name": "Python: Attach Local", + "type": "python", + "request": "attach", + "port": 5678, + "host": "localhost", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + } + ] + }, + { + // Example of attaching to my production server + "name": "Python: Attach Remote", + "type": "python", + "request": "attach", + "port": 5678, + "host": "homeassistant.local", + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/usr/src/homeassistant" + } + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a18dc56 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.pythonPath": "venv/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..47f1210 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 9123", + "type": "shell", + "command": "container start", + "problemMatcher": [] + }, + { + "label": "Run Home Assistant configuration against /config", + "type": "shell", + "command": "container check", + "problemMatcher": [] + }, + { + "label": "Upgrade Home Assistant to latest dev", + "type": "shell", + "command": "container install", + "problemMatcher": [] + }, + { + "label": "Install a specific version of Home Assistant", + "type": "shell", + "command": "container set-version", + "problemMatcher": [] + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e658865 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contribution guidelines + +Contributing to this project should be as easy and transparent as possible, whether it's: + +- Reporting a bug +- Discussing the current state of the code +- Submitting a fix +- Proposing new features + +## Github is used for everything + +Github is used to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. + +1. Fork the repo and create your branch from `master`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using black). +4. Test you contribution. +5. Issue that pull request! + +## Any contributions you make will be under the MIT Software License + +In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. + +## Report bugs using Github's [issues](../../issues) + +GitHub issues are used to track public bugs. +Report a bug by [opening a new issue](../../issues/new/choose); it's that easy! + +## Write bug reports with detail, background, and sample code + +**Great Bug Reports** tend to have: + +- A quick summary and/or background +- Steps to reproduce + - Be specific! + - Give sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +People _love_ thorough bug reports. I'm not even kidding. + +## Use a Consistent Coding Style + +Use [black](https://github.com/ambv/black) and [prettier](https://prettier.io/) +to make sure the code follows the style. + +Or use the `pre-commit` settings implemented in this repository +(see deicated section below). + +## Test your code modification + +This custom component is based on [integration_blueprint template](https://github.com/custom-components/integration_blueprint). + +It comes with development environment in a container, easy to launch +if you use Visual Studio Code. With this container you will have a stand alone +Home Assistant instance running and already configured with the included +[`.devcontainer/configuration.yaml`](./.devcontainer/configuration.yaml) +file. + +You can use the `pre-commit` settings implemented in this repository to have +linting tool checking your contributions (see deicated section below). + +## Pre-commit + +You can use the [pre-commit](https://pre-commit.com/) settings included in the +repostory to have code style and linting checks. + +With `pre-commit` tool already installed, +activate the settings of the repository: + +```console +$ pre-commit install +``` + +Now the pre-commit tests will be done every time you commit. + +You can run the tests on all repository file with the command: + +```console +$ pre-commit run --all-files +``` + +## License + +By contributing, you agree that your contributions will be licensed under its MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2e41cd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 amosyuen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9d2d1f --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# TP-Link Deco + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + +[![pre-commit][pre-commit-shield]][pre-commit] +[![Black][black-shield]][black] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Community Forum][forum-shield]][forum] + +## Functionality + +This integration is a local polling integration that logs into the admin web UI for TP-Link Deco routers. Currently the only feature implemented is device trackers for active devices. + +### Device Trackers + +Besides the device being present (connected to the router), the following attributes are also exposed: + +Attribute | Example Values (comma separated) +===|=== +mac|1A-B2-C3-4D-56-EF +ip_address|192.168.0.100 +connection_type|band5, band2_4 +interface|main, guest +down_kb_per_s|100 +up_kb_per_s|100 + +{% if not installed %} + +## Installation + +### HACS + +1. Install [HACS](https://hacs.xyz/) +1. Go to HACS integrations section +1. Click "EXPLORE & DOWNLOAD REPOSITORIES" +1. Search for "TP-Link Deco" and add it +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "TP-Link Deco". + +### Manual + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +2. If you do not have a `custom_components` directory (folder) there, you need to create it. +3. In the `custom_components` directory (folder) create a new folder called `tplink_deco`. +4. Download _all_ the files from the `custom_components/tplink_deco/` directory (folder) in this repository. +5. Place the files you downloaded in the new directory (folder) you created. +6. Restart Home Assistant +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "TP-Link Deco" + +{% endif %} + +## Configuration (Important! Please Read) + +Config is done in the HA integrations UI. + +The login credentials must be the deco **owner** credentials and the username should be left as **admin**. Manager credentials will not work. Also when this integration is logged in, all other sessions for the owner will be logged out. Recommend that you create a separate manager account with full permissions to manage the router manually and use the owner credentials only for this integration. + +## Tested Devices + +- Deco X60 + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +## Credits + +This project was generated from [@oncleben31](https://github.com/oncleben31)'s [Home Assistant Custom Component Cookiecutter](https://github.com/oncleben31/cookiecutter-homeassistant-custom-component) template. + +Code template was mainly taken from [@Ludeeus](https://github.com/ludeeus)'s [integration_blueprint][integration_blueprint] template + +--- + +[integration_blueprint]: https://github.com/custom-components/integration_blueprint +[black]: https://github.com/psf/black +[black-shield]: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge +[buymecoffee]: https://www.paypal.com/paypalme/my/profile +[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge +[commits-shield]: https://img.shields.io/github/commit-activity/y/amosyuen/ha-tplink-deco.svg?style=for-the-badge +[commits]: https://github.com/amosyuen/ha-tplink-deco/commits/main +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[exampleimg]: example.png +[forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge +[forum]: https://community.home-assistant.io/ +[license-shield]: https://img.shields.io/github/license/amosyuen/ha-tplink-deco.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40amosyuen-blue.svg?style=for-the-badge +[pre-commit]: https://github.com/pre-commit/pre-commit +[pre-commit-shield]: https://img.shields.io/badge/pre--commit-enabled-brightgreen?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/amosyuen/ha-tplink-deco.svg?style=for-the-badge +[releases]: https://github.com/amosyuen/ha-tplink-deco/releases +[user_profile]: https://github.com/amosyuen diff --git a/custom_components/tplink_deco/__init__.py b/custom_components/tplink_deco/__init__.py new file mode 100644 index 0000000..d6af5a3 --- /dev/null +++ b/custom_components/tplink_deco/__init__.py @@ -0,0 +1,130 @@ +""" +Custom integration to integrate TP-Link Deco with Home Assistant. + +For more details about this integration, please refer to +https://github.com/amosyuen/tplink-deco +""" +import asyncio +import logging +from typing import Any + +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME +from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_USERNAME +from homeassistant.core import Config +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import TplinkDecoApi +from .const import DOMAIN +from .const import PLATFORMS +from .const import STARTUP_MESSAGE +from .coordinator import TPLinkDecoClient +from .coordinator import TplinkDecoDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +def create_api(hass: HomeAssistant, data: dict[str:Any]): + host = data.get(CONF_HOST) + username = data.get(CONF_USERNAME) + password = data.get(CONF_PASSWORD) + session = async_get_clientsession(hass) + + return TplinkDecoApi(host, username, password, session) + + +async def async_create_coordinator( + hass: HomeAssistant, entry: ConfigEntry, api: TplinkDecoApi +): + consider_home_seconds = entry.data.get(CONF_CONSIDER_HOME) + scan_interval_seconds = entry.data.get(CONF_SCAN_INTERVAL) + + # Load tracked entities from registry + existing_entries = entity_registry.async_entries_for_config_entry( + entity_registry.async_get(hass), + entry.entry_id, + ) + data = {} + for entry in existing_entries: + if entry.domain == DEVICE_TRACKER_DOMAIN: + client = TPLinkDecoClient(entry.unique_id) + client.name = entry.original_name + data[entry.unique_id] = client + coordinator = TplinkDecoDataUpdateCoordinator( + hass, api, scan_interval_seconds, consider_home_seconds, data + ) + await coordinator.async_config_entry_first_refresh() + + return coordinator + + +async def async_setup(hass: HomeAssistant, config: Config): + """Set up this integration using YAML is not supported.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up this integration using UI.""" + _LOGGER.debug("async_setup_entry") + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + _LOGGER.info(STARTUP_MESSAGE) + + api = create_api(hass, entry.data) + hass.data[DOMAIN][entry.entry_id] = await async_create_coordinator(hass, entry, api) + + for platform in PLATFORMS: + hass.async_add_job( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + if coordinator is not None: + await coordinator.async_close() + + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) + + return unloaded + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update options.""" + if entry.data == entry.options: + _LOGGER.debug("update_listener: No changes in options for %s", entry.entry_id) + return + + _LOGGER.debug("update_listener: Updating options and reloading %s", entry.entry_id) + hass.config_entries.async_update_entry( + entry=entry, + data=entry.options.copy(), + ) + await async_reload_entry(hass, entry) diff --git a/custom_components/tplink_deco/api.py b/custom_components/tplink_deco/api.py new file mode 100644 index 0000000..42e0fd4 --- /dev/null +++ b/custom_components/tplink_deco/api.py @@ -0,0 +1,345 @@ +import asyncio +import base64 +import hashlib +import json +import logging +import math +import re +import secrets +from typing import Any +from urllib.parse import quote_plus + +import aiohttp +import async_timeout +from aiohttp.hdrs import CONTENT_TYPE +from aiohttp.hdrs import COOKIE +from aiohttp.hdrs import SET_COOKIE +from Crypto.Cipher import PKCS1_v1_5 +from Crypto.PublicKey import RSA +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import algorithms +from cryptography.hazmat.primitives.ciphers import Cipher +from cryptography.hazmat.primitives.ciphers import modes + +from .exceptions import AuthException + +TIMEOUT = 10 + +AES_KEY_BYTES = 16 +MIN_AES_KEY = 10 ** (AES_KEY_BYTES - 1) +MAX_AES_KEY = 10 ** AES_KEY_BYTES - 1 + +PKCS1_v1_5_HEADER_BYTES = 11 + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +def byte_len(n: int) -> int: + return (int(math.log2(n)) + 8) >> 3 + + +def rsa_encrypt(n: int, e: int, plaintext: bytes) -> bytes: + """ + RSA encrypts plaintext. TP-Link breaks the plaintext down into blocks and concatenates the output. + :param n: The RSA public key's n value + :param e: The RSA public key's e value + :param plaintext: The data to encrypt + :return: RSA encrypted ciphertext + """ + public_key = RSA.construct((n, e)).publickey() + encryptor = PKCS1_v1_5.new(public_key) + block_size = byte_len(n) + bytes_per_block = block_size - PKCS1_v1_5_HEADER_BYTES + + encrypted_text = "" + text_bytes = len(plaintext) + index = 0 + while index < text_bytes: + content_num_bytes = min(bytes_per_block, text_bytes - index) + content = plaintext[index : index + content_num_bytes] + encrypted_text += encryptor.encrypt(content).hex() + index += content_num_bytes + + return encrypted_text + + +def aes_encrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes: + """ + AES-CBC encrypt with PKCS #7 padding. This matches the AES options on TP-Link routers. + :param key: The AES key + :param iv: The AES IV + :param plaintext: Data to encrypt + :return: Ciphertext + """ + padder = padding.PKCS7(algorithms.AES.block_size).padder() + plaintext_bytes: bytes = padder.update(plaintext) + padder.finalize() + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + encryptor = cipher.encryptor() + ciphertext = encryptor.update(plaintext_bytes) + encryptor.finalize() + return ciphertext + + +def aes_decrypt(key: bytes, iv: bytes, plaintext: bytes) -> bytes: + """ + AES-CBC decrypt with PKCS #7 padding. + :param key: The AES key + :param iv: The AES IV + :param plaintext: Data to encrypt + :return: Ciphertext + """ + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + decryptor = cipher.decryptor() + ciphertext = decryptor.update(plaintext) + decryptor.finalize() + return ciphertext + + +class TplinkDecoApi: + def __init__( + self, host: str, username: str, password: str, session: aiohttp.ClientSession + ) -> None: + self._host = host + self._username = username + self._password = password + self._session = session + + self._aes_key = None + self._aes_key_bytes = None + self._aes_iv = None + self._aes_iv_bytes = None + + self._password_rsa_n = None + self._password_rsa_e = None + self._sign_rsa_n = None + self._sign_rsa_e = None + + self._seq = None + self._stok = None + self._cookie = None + + async def async_list_clients(self) -> dict: + if self._aes_key is None: + self.generate_aes_key_and_iv() + + if self._password_rsa_n is None: + await self.async_fetch_keys() + + if self._seq is None or self._stok is None: + await self.async_fetch_auth() + await self.async_login() + + client_payload = {"operation": "read", "params": {"device_mac": "default"}} + response_json = await self._async_post( + "List Clients", + f"http://{self._host}/cgi-bin/luci/;stok={self._stok}/admin/client", + params={"form": "client_list"}, + data=self._encode_payload(client_payload), + ) + + data = self._decrypt_data("List Clients", response_json["data"]) + error_code = data.get("error_code") + if error_code != 0: + raise Exception(f"List clients error {error_code}") + + client_list = data["result"]["client_list"] + # client_list is only the connected clients + _LOGGER.debug(f"client_list={client_list}") + + clients = {} + for client in client_list: + clients[client["mac"]] = client + client["name"] = base64.b64decode(client["name"]).decode() + + return clients + + def generate_aes_key_and_iv(self): + # TPLink requires key and IV to be a 16 digit number (no leading 0s) + self._aes_key = secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY + self._aes_iv = secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY + self._aes_key_bytes = str(self._aes_key).encode("utf-8") + self._aes_iv_bytes = str(self._aes_iv).encode("utf-8") + _LOGGER.debug(f"aes_key={self._aes_key}") + _LOGGER.debug(f"aes_iv={self._aes_iv}") + + async def async_fetch_keys(self): + response_json = await self._async_post( + "Fetch keys", + f"http://{self._host}/cgi-bin/luci/;stok=/login", + params={"form": "keys"}, + data=json.dumps({"operation": "read"}), + ) + + keys = response_json["result"]["password"] + self._password_rsa_n = int(keys[0], 16) + self._password_rsa_e = int(keys[1], 16) + _LOGGER.debug(f"password_rsa_n={self._password_rsa_n}") + _LOGGER.debug(f"password_rsa_e={self._password_rsa_e}") + + async def async_fetch_auth(self): + response_json = await self._async_post( + "Fetch auth", + f"http://{self._host}/cgi-bin/luci/;stok=/login", + params={"form": "auth"}, + data=json.dumps({"operation": "read"}), + ) + + auth_result = response_json["result"] + auth_key = auth_result["key"] + self._sign_rsa_n = int(auth_key[0], 16) + _LOGGER.debug(f"sign_rsa_n={self._sign_rsa_n}") + self._sign_rsa_e = int(auth_key[1], 16) + _LOGGER.debug(f"sign_rsa_e={self._sign_rsa_e}") + + self._seq = auth_result["seq"] + _LOGGER.debug(f"seq={self._seq}") + + async def async_login(self): + password_encrypted = rsa_encrypt( + self._password_rsa_n, self._password_rsa_e, self._password.encode() + ) + + login_payload = { + "params": {"password": password_encrypted}, + "operation": "login", + } + response_json = await self._async_post( + "Login", + f"http://{self._host}/cgi-bin/luci/;stok=/login", + params={"form": "login"}, + data=self._encode_payload(login_payload), + ) + + data = self._decrypt_data("Login", response_json["data"]) + error_code = data["error_code"] + result = data["result"] + if error_code == -5002: + attempts = result["attemptsAllowed"] + raise AuthException( + f"Invalid login credentials. {attempts} attempts remaining." + ) + if error_code != 0: + raise Exception(f"Login error {data['error_code']}") + + self._stok = result["stok"] + _LOGGER.debug(f"stok={self._stok}") + + if self._cookie is None: + raise Exception("Login response did not have a Set-Cookie header") + + async def _async_post( + self, context: str, url: str, params: dict[str:Any], data: Any + ) -> dict: + headers = {CONTENT_TYPE: "application/json"} + if self._cookie is not None: + headers[COOKIE] = self._cookie + try: + async with async_timeout.timeout(TIMEOUT): + response = await self._session.post( + url, + params=params, + data=data, + headers=headers, + ) + response.raise_for_status() + + cookie = response.headers.get(SET_COOKIE) + if cookie is not None: + match = re.search(r"(sysauth=[a-f0-9]+)", cookie) + if match: + self._cookie = match.group(1) + _LOGGER.debug(f"cookie={self._cookie}") + + # Sometimes server responses with incorrect content type, so disable the check + response_json = await response.json(content_type=None) + _LOGGER.debug( + "%s: response_json %s", + context, + response_json, + ) + if "error_code" in response_json: + error_code = response_json.get("error_code") + if error_code != 0: + raise Exception(f"{context} error: {error_code}") + + return response_json + except asyncio.TimeoutError as err: + _LOGGER.error( + "%s timed out", + context, + ) + raise err + except (aiohttp.ClientResponseError) as err: + _LOGGER.error( + "%s client response error: %s", + context, + err, + ) + if err.status == 401 or err.status == 403: + self._clear_auth() + raise AuthException from err + raise err + except (aiohttp.ClientError) as err: + _LOGGER.error( + "%s client error: %s", + context, + err, + ) + raise err + except Exception as err: # pylint: disable=broad-except + _LOGGER.error( + "%s error: %s", + context, + err, + ) + raise err + + def _encode_payload(self, payload: Any): + data = self._encode_data(payload) + sign = self._encode_sign(len(data)) + # Must URI encode data after calculating data length + payload = f"sign={sign}&data={quote_plus(data)}" + return payload + + def _encode_sign(self, data_len: int): + seq_with_data_len = self._seq + data_len + auth_hash = ( + hashlib.md5(f"{self._username}{self._password}".encode()).digest().hex() + ) + sign_text = ( + f"k={self._aes_key}&i={self._aes_iv}&h={auth_hash}&s={seq_with_data_len}" + ) + sign = rsa_encrypt(self._sign_rsa_n, self._sign_rsa_e, sign_text.encode()) + return sign + + def _encode_data(self, payload: Any): + payload_json = json.dumps(payload, separators=(",", ":")) + + data_encrypted = aes_encrypt( + self._aes_key_bytes, self._aes_iv_bytes, payload_json.encode() + ) + data = base64.b64encode(data_encrypted).decode() + return data + + def _clear_auth(self): + self._seq = None + self._stok = None + self._cookie = None + + def _decrypt_data(self, context: str, data: str): + if data == "": + self._clear_auth() + raise Exception("Need to re-login") + + data_decoded = base64.b64decode(data) + data_decrypted = aes_decrypt( + self._aes_key_bytes, self._aes_iv_bytes, data_decoded + ) + # Remove the PKCS #7 padding + num_padding_bytes = int(data_decrypted[-1]) + data_decrypted = data_decrypted[:-num_padding_bytes].decode() + data_json = json.loads(data_decrypted) + _LOGGER.debug( + "%s data_json: %s", + context, + data_json, + ) + return data_json diff --git a/custom_components/tplink_deco/config_flow.py b/custom_components/tplink_deco/config_flow.py new file mode 100644 index 0000000..56dc002 --- /dev/null +++ b/custom_components/tplink_deco/config_flow.py @@ -0,0 +1,134 @@ +"""Adds config flow for TP-Link Deco.""" +import asyncio +import logging +from typing import Any + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME +from homeassistant.components.device_tracker.const import CONF_SCAN_INTERVAL +from homeassistant.components.device_tracker.const import DEFAULT_CONSIDER_HOME +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_PASSWORD +from homeassistant.const import CONF_USERNAME +from homeassistant.core import callback +from homeassistant.core import HomeAssistant + +from .__init__ import create_api +from .const import DOMAIN +from .exceptions import AuthException + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +def _get_schema(data: dict[str:Any]): + if data is None: + data = {} + return vol.Schema( + { + vol.Required(CONF_HOST, default=data.get(CONF_HOST, "192.168.0.1")): str, + vol.Required(CONF_USERNAME, default=data.get(CONF_USERNAME, "admin")): str, + vol.Required(CONF_PASSWORD, default=data.get(CONF_PASSWORD, "")): str, + vol.Optional( + CONF_SCAN_INTERVAL, + default=data.get(CONF_SCAN_INTERVAL, 30), + ): vol.All(vol.Coerce(int), vol.Clamp(min=1)), + vol.Optional( + CONF_CONSIDER_HOME, + default=data.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=0)), + } + ) + + +async def _async_test_credentials(hass: HomeAssistant, data: dict[str:Any]): + """Return true if credentials is valid.""" + try: + api = create_api(hass, data) + await api.async_list_clients() + return {} + except asyncio.TimeoutError: + return {"base": "timeout_connect"} + except AuthException as err: + _LOGGER.warn("Error authenticating credentials: %s", err) + return {"base": "invalid_auth"} + except Exception as err: # pylint: disable=broad-except + _LOGGER.warn("Error testing credentials: %s", err) + return {"base": "unknown"} + + +class TplinkDecoFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for tplink_deco.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + self._errors = await _async_test_credentials(self.hass, user_input) + if len(self._errors) == 0: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return await self._show_config_form(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry): + return TplinkDecoOptionsFlowHandler(config_entry) + + async def _show_config_form( + self, user_input: dict[str:Any] + ): # pylint: disable=unused-argument + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id="user", + data_schema=_get_schema(user_input), + errors=self._errors, + ) + + +class TplinkDecoOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options handler for tplink_deco.""" + + def __init__(self, config_entry: ConfigEntry): + """Initialize HACS options flow.""" + self.config_entry = config_entry + self.data = dict(config_entry.data) + self._errors = {} + + async def async_step_init( + self, user_input: dict[str:Any] = None + ): # pylint: disable=unused-argument + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input: dict[str:Any] = None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + self.data.update(user_input) + + self._errors = await _async_test_credentials(self.hass, self.data) + if len(self._errors) == 0: + return self.async_create_entry( + title=self.config_entry.data.get(CONF_HOST), data=self.data + ) + + return self.async_show_form( + step_id="user", + data_schema=_get_schema(self.data), + errors=self._errors, + ) diff --git a/custom_components/tplink_deco/const.py b/custom_components/tplink_deco/const.py new file mode 100644 index 0000000..456f4d2 --- /dev/null +++ b/custom_components/tplink_deco/const.py @@ -0,0 +1,25 @@ +"""Constants for TP-Link Deco.""" +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN + +# Base component constants +NAME = "TP-Link Deco" +DOMAIN = "tplink_deco" +VERSION = "0.0.0" + +ISSUE_URL = "https://github.com/amosyuen/tplink-deco/issues" + +# Signals +SIGNAL_CLIENT_ADDED = f"{DOMAIN}-client-added" + +# Platforms +PLATFORMS = [DEVICE_TRACKER_DOMAIN] + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" diff --git a/custom_components/tplink_deco/coordinator.py b/custom_components/tplink_deco/coordinator.py new file mode 100644 index 0000000..d56539f --- /dev/null +++ b/custom_components/tplink_deco/coordinator.py @@ -0,0 +1,125 @@ +"""TP-Link Deco Coordinator""" +import logging +from collections.abc import Callable +from datetime import datetime +from datetime import timedelta +from typing import Any + +from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.util import dt as dt_util + +from .api import TplinkDecoApi +from .const import DOMAIN +from .const import SIGNAL_CLIENT_ADDED + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +class TPLinkDecoClient: + """Class to manage TP-Link Deco Client.""" + + def __init__(self, mac: str) -> None: + self.mac = mac + self.name = None + self.ip_address = None + self.online = False + self.connection_type = None + self.interface = None + self.down_kb_per_s = None + self.up_kb_per_s = None + self.last_activity = None + + def update( + self, + data: dict[str:Any], + utc_point_in_time: datetime, + ) -> None: + self.name = data["name"] + self.ip_address = data["ip"] + self.online = data["online"] + self.connection_type = data["connection_type"] + self.interface = data["interface"] + self.down_kb_per_s = data["down_speed"] + self.up_kb_per_s = data["up_speed"] + self.last_activity = utc_point_in_time + + +class TplinkDecoDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + api: TplinkDecoApi, + scan_interval_seconds: int, + consider_home_seconds: int, + data: dict[str:TPLinkDecoClient], + ) -> None: + """Initialize.""" + self._api = api + self._consider_home_seconds = consider_home_seconds + self._on_close: list[Callable] = [] + self.data = data + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval_seconds), + ) + + async def _async_update_data(self): + """Update data via library.""" + try: + new_clients = await self._api.async_list_clients() + + old_clients = self.data or {} + data = {} + utc_point_in_time = dt_util.utcnow() + + client_added = False + for mac in new_clients: + client = old_clients.get(mac) + if client is None: + client_added = True + client = TPLinkDecoClient(mac) + client.update(new_clients[mac], utc_point_in_time) + data[mac] = client + else: + client.update(new_clients[mac], utc_point_in_time) + + # Copy over clients no longer online + for client in old_clients.values(): + mac = client.mac + if mac not in data: + data[mac] = client + client.online = ( + utc_point_in_time - client.last_activity + ).total_seconds() < self._consider_home_seconds + + if client_added: + async_dispatcher_send(self.hass, SIGNAL_CLIENT_ADDED) + + return data + except Exception as err: + _LOGGER.error("Error updating coordinator: %s", err) + raise UpdateFailed() from err + + @callback + def on_close(self, func: CALLBACK_TYPE) -> None: + """Add a function to call when coordinator is closed.""" + self._on_close.append(func) + + async def async_close(self) -> None: + """Call functions on close.""" + for func in self._on_close: + try: + await func() + except Exception as err: + _LOGGER.error("Error calling on_close function %s: %s", func, err) + self._on_close.clear() diff --git a/custom_components/tplink_deco/device_tracker.py b/custom_components/tplink_deco/device_tracker.py new file mode 100644 index 0000000..b00c0cf --- /dev/null +++ b/custom_components/tplink_deco/device_tracker.py @@ -0,0 +1,109 @@ +"""TP-Link Deco.""" +import logging + +from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .const import SIGNAL_CLIENT_ADDED +from .coordinator import TPLinkDecoClient +from .coordinator import TplinkDecoDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Setup binary_sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + tracked = set() + + @callback + def add_untracked_entities(): + """Add new tracker entities from the router.""" + new_entities = [] + + for mac in coordinator.data: + if mac in tracked: + continue + + new_entities.append( + TplinkDecoDeviceTracker(coordinator, coordinator.data[mac]) + ) + tracked.add(mac) + + if new_entities: + async_add_entities(new_entities) + + coordinator.on_close( + async_dispatcher_connect(hass, SIGNAL_CLIENT_ADDED, add_untracked_entities) + ) + + add_untracked_entities() + + +class TplinkDecoDeviceTracker(CoordinatorEntity, ScannerEntity): + """TP Link Deco Entity.""" + + def __init__( + self, coordinator: TplinkDecoDataUpdateCoordinator, client: TPLinkDecoClient + ) -> None: + """Initialize a AsusWrt device.""" + self._client = client + super().__init__(coordinator) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return self.mac_address + + @property + def source_type(self): + """Return the source type.""" + return SOURCE_TYPE_ROUTER + + @property + def name(self): + """Return the name for this entity.""" + return self._client.name + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:lan-connect" if self.is_connected else "mdi:lan-disconnect" + + @property + def is_connected(self): + """Return true if the device is connected to the router.""" + return self._client.online + + @property + def ip_address(self) -> str: + """Return the primary ip address of the device.""" + return self._client.ip_address + + @property + def mac_address(self) -> str: + """Return the mac address of the device.""" + return self._client.mac + + @property + def extra_state_attributes(self): + """Return extra state attributes.""" + return { + "connection_type": self._client.connection_type, + "interface": self._client.interface, + "down_kb_per_s": self._client.up_kb_per_s, + "up_kb_per_s": self._client.up_kb_per_s, + } + + @callback + async def async_on_demand_update(self): + """Update state.""" + await self.coordinator.async_request_refresh() diff --git a/custom_components/tplink_deco/exceptions.py b/custom_components/tplink_deco/exceptions.py new file mode 100644 index 0000000..f224b0c --- /dev/null +++ b/custom_components/tplink_deco/exceptions.py @@ -0,0 +1,2 @@ +class AuthException(Exception): + """ Auth exception """ diff --git a/custom_components/tplink_deco/manifest.json b/custom_components/tplink_deco/manifest.json new file mode 100644 index 0000000..e47e1e0 --- /dev/null +++ b/custom_components/tplink_deco/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "tplink_deco", + "name": "TP-Link Deco", + "documentation": "https://github.com/amosyuen/ha-tplink-deco", + "issue_tracker": "https://github.com/amosyuen/ha-tplink-deco/issues", + "iot_class": "local_polling", + "version": "0.0.0", + "config_flow": true, + "dependencies": [], + "codeowners": ["@amosyuen"], + "requirements": ["pycryptodome>=3.12.0"] +} diff --git a/custom_components/tplink_deco/strings.json b/custom_components/tplink_deco/strings.json new file mode 100644 index 0000000..75a21cd --- /dev/null +++ b/custom_components/tplink_deco/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "title": "TP-Link Deco", + "description": "Use the credentials for the admin web portal. See https://github.com/amosyuen/tplink-deco", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "interval_seconds": "Seconds between updates", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "user": { + "title": "TP-Link Deco", + "description": "Use the credentials for the admin web portal. See https://github.com/amosyuen/tplink-deco", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "interval_seconds": "Seconds between updates", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/custom_components/tplink_deco/translations/en.json b/custom_components/tplink_deco/translations/en.json new file mode 100644 index 0000000..d2ad4e0 --- /dev/null +++ b/custom_components/tplink_deco/translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "title": "TP-Link Deco", + "description": "Use the credentials for the admin web portal. See https://github.com/amosyuen/tplink-deco", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "interval_seconds": "Seconds between updates", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." + } + } + }, + "error": { + "invalid_auth": "Invalid authentication", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + } + }, + "options": { + "step": { + "user": { + "title": "TP-Link Deco", + "description": "Use the credentials for the admin web portal. See https://github.com/amosyuen/tplink-deco", + "data": { + "host": "Host", + "username": "Username", + "password": "Password", + "interval_seconds": "Seconds between updates", + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." + } + } + }, + "error": { + "invalid_auth": "Invalid authentication", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + } + } +} diff --git a/custom_components/tplink_deco/translations/fr.json b/custom_components/tplink_deco/translations/fr.json new file mode 100644 index 0000000..15a3232 --- /dev/null +++ b/custom_components/tplink_deco/translations/fr.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "TP-Link Deco", + "data": { + "username": "Identifiant", + "password": "Mot de Passe" + } + } + }, + "error": {} + }, + "options": { + "step": { + "user": { + "title": "TP-Link Deco", + "data": { + "username": "Identifiant", + "password": "Mot de Passe" + } + } + } + } +} diff --git a/custom_components/tplink_deco/translations/nb.json b/custom_components/tplink_deco/translations/nb.json new file mode 100644 index 0000000..4731781 --- /dev/null +++ b/custom_components/tplink_deco/translations/nb.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "TP-Link Deco", + "data": { + "username": "Brukernavn", + "password": "Passord" + } + } + }, + "error": {} + }, + "options": { + "step": { + "user": { + "title": "TP-Link Deco", + "data": { + "username": "Brukernavn", + "password": "Passord" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..445253d --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "TP-Link Deco", + "hacs": "1.6.0", + "domains": ["device_tracker"], + "iot_class": "Local Polling", + "homeassistant": "0.118.0", + "render_readme": true +} diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..7ebece4 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,46 @@ +[flake8] +exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build +doctests = True +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +# https://github.com/timothycrosley/isort +# https://github.com/timothycrosley/isort/wiki/isort-Settings +# splits long import on multiple lines indented by 4 spaces +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " +# by default isort don't check module indexes +not_skip = __init__.py +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER +default_section = THIRDPARTY +known_first_party = custom_components.tplink_deco +combine_as_imports = true + +[tool:pytest] +addopts = -qq --cov=custom_components.tplink_deco +console_output_style = count + +[coverage:run] +branch = False + +[coverage:report] +show_missing = true +fail_under = 100