diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..e304a9a --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,60 @@ +## Developing with Visual Studio Code + devcontainer + +The easiest way to get started with custom integration development is to use Visual Studio Code with devcontainers. This approach will create a preconfigured development environment with all the tools you need. + +In the container you will have a dedicated Home Assistant core instance running with your custom component code. You can configure this instance by updating the `./devcontainer/configuration.yaml` file. + +**Prerequisites** + +- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- Docker + - For Linux, macOS, or Windows 10 Pro/Enterprise/Education use the [current release version of Docker](https://docs.docker.com/install/) + - Windows 10 Home requires [WSL 2](https://docs.microsoft.com/windows/wsl/wsl2-install) and the current Edge version of Docker Desktop (see instructions [here](https://docs.docker.com/docker-for-windows/wsl-tech-preview/)). This can also be used for Windows Pro/Enterprise/Education. +- [Visual Studio code](https://code.visualstudio.com/) +- [Remote - Containers (VSC Extension)][extension-link] + +[More info about requirements and devcontainer in general](https://code.visualstudio.com/docs/remote/containers#_getting-started) + +[extension-link]: https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers + +**Getting started:** + +1. Fork the repository. +2. Clone the repository to your computer. +3. Open the repository using Visual Studio code. + +When you open this repository with Visual Studio code you are asked to "Reopen in Container", this will start the build of the container. + +_If you don't see this notification, open the command palette and select `Remote-Containers: Reopen Folder in Container`._ + +### Tasks + +The devcontainer comes with some useful tasks to help you with development, you can start these tasks by opening the command palette and select `Tasks: Run Task` then select the task you want to run. + +When a task is currently running (like `Run Home Assistant on port 9123` for the docs), it can be restarted by opening the command palette and selecting `Tasks: Restart Running Task`, then select the task you want to restart. + +The available tasks are: + +Task | Description +-- | -- +Run Home Assistant on port 9123 | Launch Home Assistant with your custom component code and the configuration defined in `.devcontainer/configuration.yaml`. +Run Home Assistant configuration against /config | Check the configuration. +Upgrade Home Assistant to latest dev | Upgrade the Home Assistant core version in the container to the latest version of the `dev` branch. +Install a specific version of Home Assistant | Install a specific version of Home Assistant core in the container. + +### Step by Step debugging + +With the development container, +you can test your custom component in Home Assistant with step by step debugging. + +You need to modify the `configuration.yaml` file in `.devcontainer` folder +by uncommenting the line: + +```yaml +# debugpy: +``` + +Then launch the task `Run Home Assistant on port 9123`, and launch the debugger +with the existing debugging configuration `Python: Attach Local`. + +For more information, look at [the Remote Python Debugger integration documentation](https://www.home-assistant.io/integrations/debugpy/). diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..38c0eb6 --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,9 @@ +default_config: + +logger: + default: info + logs: + custom_components.integration_blueprint: debug + +# If you need to debug uncomment 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..826daa2 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,30 @@ +// See https://aka.ms/vscode-remote/devcontainer.json for format details. +{ + "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "name": "Blueprint 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..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..507f06e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +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. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 0000000..70f3d5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,42 @@ +--- +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. + +``` \ No newline at end of file diff --git a/.github/workflows/cron.yaml b/.github/workflows/cron.yaml new file mode 100644 index 0000000..8d51d0f --- /dev/null +++ b/.github/workflows/cron.yaml @@ -0,0 +1,21 @@ +name: Cron actions + +on: + schedule: + - cron: '0 0 * * *' + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" \ No newline at end of file diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml new file mode 100644 index 0000000..d895c86 --- /dev/null +++ b/.github/workflows/pull.yml @@ -0,0 +1,55 @@ +name: Pull actions + +on: + pull_request: + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.integration_blueprint \ + -o console_output_style=count \ + -p no:sugar \ + tests diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..d0ff7bf --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,58 @@ +name: Push actions + +on: + push: + branches: + - master + - dev + +jobs: + validate: + runs-on: "ubuntu-latest" + name: Validate + steps: + - uses: "actions/checkout@v2" + + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" + ignore: brands + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + + style: + runs-on: "ubuntu-latest" + name: Check style formatting + steps: + - uses: "actions/checkout@v2" + - uses: "actions/setup-python@v1" + with: + python-version: "3.x" + - run: python3 -m pip install black + - run: black . + + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2" + - name: Setup Python + uses: "actions/setup-python@v1" + with: + python-version: "3.8" + - name: Install requirements + run: python3 -m pip install -r requirements_test.txt + - name: Run tests + run: | + pytest \ + -qq \ + --timeout=9 \ + --durations=10 \ + -n auto \ + --cov custom_components.integration_blueprint \ + -o console_output_style=count \ + -p no:sugar \ + tests \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..492cda3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +pythonenv* +venv +.venv +.coverage +.idea diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..555a62b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + // 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" + } + ] + } + ] + } + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a3d535d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.pythonPath": "/usr/local/bin/python", + "files.associations": { + "*.yaml": "home-assistant" + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d1a0ae7 --- /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": [] + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a24a872 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# 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) to make sure the code follows the style. + +## 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. + +## 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..8a418c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Joakim Sørensen @ludeeus + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..18b8a6f --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# Notice + +The component and platforms in this repository are not meant to be used by a +user, but as a "blueprint" that custom component developers can build +upon, to make more awesome stuff. + +HAVE FUN! 😎 + +## Why? + +This is simple, by having custom_components look (README + structure) the same +it is easier for developers to help each other and for users to start using them. + +If you are a developer and you want to add things to this "blueprint" that you think more +developers will have use for, please open a PR to add it :) + +## What? + +This repository contains multiple files, here is a overview: + +File | Purpose +-- | -- +`.devcontainer/*` | Used for development/testing with VSCODE, more info in the readme file in that dir. +`.github/ISSUE_TEMPLATE/feature_request.md` | Template for Feature Requests +`.github/ISSUE_TEMPLATE/issue.md` | Template for issues +`.vscode/tasks.json` | Tasks for the devcontainer. +`custom_components/integration_blueprint/translations/*` | [Translation files.](https://developers.home-assistant.io/docs/internationalization/custom_integration) +`custom_components/integration_blueprint/__init__.py` | The component file for the integration. +`custom_components/integration_blueprint/api.py` | This is a sample API client. +`custom_components/integration_blueprint/binary_sensor.py` | Binary sensor platform for the integration. +`custom_components/integration_blueprint/config_flow.py` | Config flow file, this adds the UI configuration possibilities. +`custom_components/integration_blueprint/const.py` | A file to hold shared variables/constants for the entire integration. +`custom_components/integration_blueprint/manifest.json` | A [manifest file](https://developers.home-assistant.io/docs/en/creating_integration_manifest.html) for Home Assistant. +`custom_components/integration_blueprint/sensor.py` | Sensor platform for the integration. +`custom_components/integration_blueprint/switch.py` | Switch sensor platform for the integration. +`tests/__init__.py` | Makes the `tests` folder a module. +`tests/conftest.py` | Global [fixtures](https://docs.pytest.org/en/stable/fixture.html) used in tests to [patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) functions. +`tests/test_api.py` | Tests for `custom_components/integration_blueprint/api.py`. +`tests/test_config_flow.py` | Tests for `custom_components/integration_blueprint/config_flow.py`. +`tests/test_init.py` | Tests for `custom_components/integration_blueprint/__init__.py`. +`tests/test_switch.py` | Tests for `custom_components/integration_blueprint/switch.py`. +`CONTRIBUTING.md` | Guidelines on how to contribute. +`example.png` | Screenshot that demonstrate how it might look in the UI. +`info.md` | An example on a info file (used by [hacs][hacs]). +`LICENSE` | The license file for the project. +`README.md` | The file you are reading now, should contain info about the integration, installation and configuration instructions. +`requirements.txt` | Python packages used by this integration. +`requirements_dev.txt` | Python packages used to provide [IntelliSense](https://code.visualstudio.com/docs/editor/intellisense)/code hints during development of this integration, typically includes packages in `requirements.txt` but may include additional packages +`requirements_test.txt` | Python packages required to run the tests for this integration, typically includes packages in `requirements_dev.txt` but may include additional packages + +## How? + +If you want to use all the potential and features of this blueprint template you +should use Visual Studio Code to develop in a container. In this container you +will have all the tools to ease your python development and a dedicated Home +Assistant core instance to run your integration. See `.devcontainer/README.md` for more information. + +If you need to work on the python library in parallel of this integration +(`sampleclient` in this example) there are different options. The following one seems +easy to implement: + +- Create a dedicated branch for your python library on a public git repository (example: branch +`dev` on `https://github.com/ludeeus/sampleclient`) +- Update in the `manifest.json` file the `requirements` key to point on your development branch +( example: `"requirements": ["git+https://github.com/ludeeus/sampleclient.git@dev#devp==0.0.1beta1"]`) +- Each time you need to make a modification to your python library, push it to your +development branch and increase the number of the python library version in `manifest.json` file +to ensure Home Assistant update the code of the python library. (example `"requirements": ["git+https://...==0.0.1beta2"]`). + + +*** +README content if this was a published component: +*** + +# integration_blueprint + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + +[![hacs][hacsbadge]][hacs] +![Project Maintenance][maintenance-shield] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +_Component to integrate with [integration_blueprint][integration_blueprint]._ + +**This component will set up the following platforms.** + +Platform | Description +-- | -- +`binary_sensor` | Show something `True` or `False`. +`sensor` | Show info from blueprint API. +`switch` | Switch something `True` or `False`. + +![example][exampleimg] + +## Installation + +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 `integration_blueprint`. +4. Download _all_ the files from the `custom_components/integration_blueprint/` 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 "Integration blueprint" + +Using your HA configuration directory (folder) as a starting point you should now also have this: + +```text +custom_components/integration_blueprint/translations/en.json +custom_components/integration_blueprint/translations/nb.json +custom_components/integration_blueprint/translations/sensor.nb.json +custom_components/integration_blueprint/__init__.py +custom_components/integration_blueprint/api.py +custom_components/integration_blueprint/binary_sensor.py +custom_components/integration_blueprint/config_flow.py +custom_components/integration_blueprint/const.py +custom_components/integration_blueprint/manifest.json +custom_components/integration_blueprint/sensor.py +custom_components/integration_blueprint/switch.py +``` + +## Configuration is done in the UI + + + +## Contributions are welcome! + +If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) + +*** + +[integration_blueprint]: https://github.com/custom-components/integration_blueprint +[buymecoffee]: https://www.buymeacoffee.com/ludeeus +[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/custom-components/blueprint.svg?style=for-the-badge +[commits]: https://github.com/custom-components/integration_blueprint/commits/master +[hacs]: https://github.com/custom-components/hacs +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[discord]: https://discord.gg/Qa5fW2R +[discord-shield]: https://img.shields.io/discord/330944238910963714.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/custom-components/blueprint.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/custom-components/blueprint.svg?style=for-the-badge +[releases]: https://github.com/custom-components/integration_blueprint/releases diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..f55f54d --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom components module.""" diff --git a/custom_components/integration_blueprint/__init__.py b/custom_components/integration_blueprint/__init__.py new file mode 100644 index 0000000..383ae65 --- /dev/null +++ b/custom_components/integration_blueprint/__init__.py @@ -0,0 +1,109 @@ +""" +Custom integration to integrate integration_blueprint with Home Assistant. + +For more details about this integration, please refer to +https://github.com/custom-components/integration_blueprint +""" +import asyncio +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import IntegrationBlueprintApiClient + +from .const import ( + CONF_PASSWORD, + CONF_USERNAME, + DOMAIN, + PLATFORMS, + STARTUP_MESSAGE, +) + +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +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.""" + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + _LOGGER.info(STARTUP_MESSAGE) + + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + + session = async_get_clientsession(hass) + client = IntegrationBlueprintApiClient(username, password, session) + + coordinator = BlueprintDataUpdateCoordinator(hass, client=client) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for platform in PLATFORMS: + if entry.options.get(platform, True): + coordinator.platforms.append(platform) + hass.async_add_job( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True + + +class BlueprintDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, hass: HomeAssistant, client: IntegrationBlueprintApiClient + ) -> None: + """Initialize.""" + self.api = client + self.platforms = [] + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self): + """Update data via library.""" + try: + return await self.api.async_get_data() + except Exception as exception: + raise UpdateFailed() from exception + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + if platform in coordinator.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) diff --git a/custom_components/integration_blueprint/api.py b/custom_components/integration_blueprint/api.py new file mode 100644 index 0000000..5414e23 --- /dev/null +++ b/custom_components/integration_blueprint/api.py @@ -0,0 +1,75 @@ +"""Sample API Client.""" +import logging +import asyncio +import socket +from typing import Optional +import aiohttp +import async_timeout + +TIMEOUT = 10 + + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + + +class IntegrationBlueprintApiClient: + def __init__( + self, username: str, password: str, session: aiohttp.ClientSession + ) -> None: + """Sample API Client.""" + self._username = username + self._password = password + self._session = session + + async def async_get_data(self) -> dict: + """Get data from the API.""" + url = "https://jsonplaceholder.typicode.com/posts/1" + return await self.api_wrapper("get", url) + + async def async_set_title(self, value: str) -> None: + """Get data from the API.""" + url = "https://jsonplaceholder.typicode.com/posts/1" + await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) + + async def api_wrapper( + self, method: str, url: str, data: dict = {}, headers: dict = {} + ) -> dict: + """Get information from the API.""" + try: + async with async_timeout.timeout(TIMEOUT): + if method == "get": + response = await self._session.get(url, headers=headers) + return await response.json() + + elif method == "put": + await self._session.put(url, headers=headers, json=data) + + elif method == "patch": + await self._session.patch(url, headers=headers, json=data) + + elif method == "post": + await self._session.post(url, headers=headers, json=data) + + except asyncio.TimeoutError as exception: + _LOGGER.error( + "Timeout error fetching information from %s - %s", + url, + exception, + ) + + except (KeyError, TypeError) as exception: + _LOGGER.error( + "Error parsing information from %s - %s", + url, + exception, + ) + except (aiohttp.ClientError, socket.gaierror) as exception: + _LOGGER.error( + "Error fetching information from %s - %s", + url, + exception, + ) + except Exception as exception: # pylint: disable=broad-except + _LOGGER.error("Something really wrong happened! - %s", exception) diff --git a/custom_components/integration_blueprint/binary_sensor.py b/custom_components/integration_blueprint/binary_sensor.py new file mode 100644 index 0000000..6b92f30 --- /dev/null +++ b/custom_components/integration_blueprint/binary_sensor.py @@ -0,0 +1,35 @@ +"""Binary sensor platform for integration_blueprint.""" +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .const import ( + BINARY_SENSOR, + BINARY_SENSOR_DEVICE_CLASS, + DEFAULT_NAME, + DOMAIN, +) +from .entity import IntegrationBlueprintEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup binary_sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([IntegrationBlueprintBinarySensor(coordinator, entry)]) + + +class IntegrationBlueprintBinarySensor(IntegrationBlueprintEntity, BinarySensorEntity): + """integration_blueprint binary_sensor class.""" + + @property + def name(self): + """Return the name of the binary_sensor.""" + return f"{DEFAULT_NAME}_{BINARY_SENSOR}" + + @property + def device_class(self): + """Return the class of this binary_sensor.""" + return BINARY_SENSOR_DEVICE_CLASS + + @property + def is_on(self): + """Return true if the binary_sensor is on.""" + return self.coordinator.data.get("title", "") == "foo" diff --git a/custom_components/integration_blueprint/config_flow.py b/custom_components/integration_blueprint/config_flow.py new file mode 100644 index 0000000..26f06a0 --- /dev/null +++ b/custom_components/integration_blueprint/config_flow.py @@ -0,0 +1,116 @@ +"""Adds config flow for Blueprint.""" +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession +import voluptuous as vol + +from .api import IntegrationBlueprintApiClient +from .const import ( + CONF_PASSWORD, + CONF_USERNAME, + DOMAIN, + PLATFORMS, +) + + +class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Blueprint.""" + + 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 = {} + + # Uncomment the next 2 lines if only a single instance of the integration is allowed: + # if self._async_current_entries(): + # return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + valid = await self._test_credentials( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if valid: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + else: + self._errors["base"] = "auth" + + return await self._show_config_form(user_input) + + user_input = {} + # Provide defaults for form + user_input[CONF_USERNAME] = "" + user_input[CONF_PASSWORD] = "" + + return await self._show_config_form(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return BlueprintOptionsFlowHandler(config_entry) + + async def _show_config_form(self, user_input): # pylint: disable=unused-argument + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, + vol.Required(CONF_PASSWORD, default=user_input[CONF_PASSWORD]): str, + } + ), + errors=self._errors, + ) + + async def _test_credentials(self, username, password): + """Return true if credentials is valid.""" + try: + session = async_create_clientsession(self.hass) + client = IntegrationBlueprintApiClient(username, password, session) + await client.async_get_data() + return True + except Exception: # pylint: disable=broad-except + pass + return False + + +class BlueprintOptionsFlowHandler(config_entries.OptionsFlow): + """Blueprint config flow options handler.""" + + def __init__(self, config_entry): + """Initialize HACS options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self.options.update(user_input) + return await self._update_options() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(x, default=self.options.get(x, True)): bool + for x in sorted(PLATFORMS) + } + ), + ) + + async def _update_options(self): + """Update config entry options.""" + return self.async_create_entry( + title=self.config_entry.data.get(CONF_USERNAME), data=self.options + ) diff --git a/custom_components/integration_blueprint/const.py b/custom_components/integration_blueprint/const.py new file mode 100644 index 0000000..c9fa745 --- /dev/null +++ b/custom_components/integration_blueprint/const.py @@ -0,0 +1,40 @@ +"""Constants for integration_blueprint.""" +# Base component constants +NAME = "Integration blueprint" +DOMAIN = "integration_blueprint" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.0.1" +ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" +ISSUE_URL = "https://github.com/custom-components/integration_blueprint/issues" + +# Icons +ICON = "mdi:format-quote-close" + +# Device classes +BINARY_SENSOR_DEVICE_CLASS = "connectivity" + +# Platforms +BINARY_SENSOR = "binary_sensor" +SENSOR = "sensor" +SWITCH = "switch" +PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] + + +# Configuration and options +CONF_ENABLED = "enabled" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" + +# Defaults +DEFAULT_NAME = 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/integration_blueprint/entity.py b/custom_components/integration_blueprint/entity.py new file mode 100644 index 0000000..0ab5eff --- /dev/null +++ b/custom_components/integration_blueprint/entity.py @@ -0,0 +1,33 @@ +"""BlueprintEntity class""" +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NAME, VERSION, ATTRIBUTION + + +class IntegrationBlueprintEntity(CoordinatorEntity): + def __init__(self, coordinator, config_entry): + super().__init__(coordinator) + self.config_entry = config_entry + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return self.config_entry.entry_id + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": NAME, + "model": VERSION, + "manufacturer": NAME, + } + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + "attribution": ATTRIBUTION, + "id": str(self.coordinator.data.get("id")), + "integration": DOMAIN, + } diff --git a/custom_components/integration_blueprint/manifest.json b/custom_components/integration_blueprint/manifest.json new file mode 100644 index 0000000..138b1e7 --- /dev/null +++ b/custom_components/integration_blueprint/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "integration_blueprint", + "name": "Integration blueprint", + "documentation": "https://github.com/custom-components/integration_blueprint", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/custom-components/integration_blueprint/issues", + "version": "0.0.0", + "config_flow": true, + "codeowners": [ + "@ludeeus" + ] +} \ No newline at end of file diff --git a/custom_components/integration_blueprint/sensor.py b/custom_components/integration_blueprint/sensor.py new file mode 100644 index 0000000..9457312 --- /dev/null +++ b/custom_components/integration_blueprint/sensor.py @@ -0,0 +1,30 @@ +"""Sensor platform for integration_blueprint.""" +from homeassistant.components.sensor import SensorEntity + +from .const import DEFAULT_NAME, DOMAIN, ICON, SENSOR +from .entity import IntegrationBlueprintEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([IntegrationBlueprintSensor(coordinator, entry)]) + + +class IntegrationBlueprintSensor(IntegrationBlueprintEntity, SensorEntity): + """integration_blueprint Sensor class.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DEFAULT_NAME}_{SENSOR}" + + @property + def native_value(self): + """Return the native value of the sensor.""" + return self.coordinator.data.get("body") + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON diff --git a/custom_components/integration_blueprint/switch.py b/custom_components/integration_blueprint/switch.py new file mode 100644 index 0000000..8381e6f --- /dev/null +++ b/custom_components/integration_blueprint/switch.py @@ -0,0 +1,40 @@ +"""Switch platform for integration_blueprint.""" +from homeassistant.components.switch import SwitchEntity + +from .const import DEFAULT_NAME, DOMAIN, ICON, SWITCH +from .entity import IntegrationBlueprintEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([IntegrationBlueprintBinarySwitch(coordinator, entry)]) + + +class IntegrationBlueprintBinarySwitch(IntegrationBlueprintEntity, SwitchEntity): + """integration_blueprint switch class.""" + + async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument + """Turn on the switch.""" + await self.coordinator.api.async_set_title("bar") + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument + """Turn off the switch.""" + await self.coordinator.api.async_set_title("foo") + await self.coordinator.async_request_refresh() + + @property + def name(self): + """Return the name of the switch.""" + return f"{DEFAULT_NAME}_{SWITCH}" + + @property + def icon(self): + """Return the icon of this switch.""" + return ICON + + @property + def is_on(self): + """Return true if the switch is on.""" + return self.coordinator.data.get("title", "") == "foo" diff --git a/custom_components/integration_blueprint/translations/en.json b/custom_components/integration_blueprint/translations/en.json new file mode 100644 index 0000000..ecf368a --- /dev/null +++ b/custom_components/integration_blueprint/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Blueprint", + "description": "If you need help with the configuration have a look here: https://github.com/custom-components/integration_blueprint", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "auth": "Username/Password is wrong." + }, + "abort": { + "single_instance_allowed": "Only a single instance is allowed." + } + }, + "options": { + "step": { + "user": { + "data": { + "binary_sensor": "Binary sensor enabled", + "sensor": "Sensor enabled", + "switch": "Switch enabled" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/integration_blueprint/translations/fr.json b/custom_components/integration_blueprint/translations/fr.json new file mode 100644 index 0000000..842caa7 --- /dev/null +++ b/custom_components/integration_blueprint/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Blueprint", + "description": "Si vous avez besoin d'aide pour la configuration, regardez ici: https://github.com/custom-components/integration_blueprint", + "data": { + "username": "Identifiant", + "password": "Mot de Passe" + } + } + }, + "error": { + "auth": "Identifiant ou mot de passe erroné." + }, + "abort": { + "single_instance_allowed": "Une seule instance est autorisée." + } + }, + "options": { + "step": { + "user": { + "data": { + "binary_sensor": "Capteur binaire activé", + "sensor": "Capteur activé", + "switch": "Interrupteur activé" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/integration_blueprint/translations/nb.json b/custom_components/integration_blueprint/translations/nb.json new file mode 100644 index 0000000..e3ef188 --- /dev/null +++ b/custom_components/integration_blueprint/translations/nb.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Blueprint", + "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/custom-components/integration_blueprint", + "data": { + "username": "Brukernavn", + "password": "Passord" + } + } + }, + "error": { + "auth": "Brukernavn/Passord er feil." + }, + "abort": { + "single_instance_allowed": "Denne integrasjonen kan kun konfigureres en gang." + } + }, + "options": { + "step": { + "user": { + "data": { + "binary_sensor": "Binær sensor aktivert", + "sensor": "Sensor aktivert", + "switch": "Bryter aktivert" + } + } + } + } +} \ No newline at end of file diff --git a/example.png b/example.png new file mode 100644 index 0000000..c2d4244 Binary files /dev/null and b/example.png differ diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..38efd30 --- /dev/null +++ b/hacs.json @@ -0,0 +1,10 @@ +{ + "name": "Integration blueprint", + "hacs": "1.6.0", + "domains": [ + "binary_sensor", + "sensor", + "switch" + ], + "homeassistant": "0.118.0" +} \ No newline at end of file diff --git a/info.md b/info.md new file mode 100644 index 0000000..714b9e5 --- /dev/null +++ b/info.md @@ -0,0 +1,56 @@ +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]][license] + +[![hacs][hacsbadge]][hacs] +[![Project Maintenance][maintenance-shield]][user_profile] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +_Component to integrate with [integration_blueprint][integration_blueprint]._ + +**This component will set up the following platforms.** + +Platform | Description +-- | -- +`binary_sensor` | Show something `True` or `False`. +`sensor` | Show info from API. +`switch` | Switch something `True` or `False`. + +![example][exampleimg] + +{% if not installed %} +## Installation + +1. Click install. +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Blueprint". + +{% endif %} + + +## Configuration is done in the UI + + + +*** + +[integration_blueprint]: https://github.com/custom-components/integration_blueprint +[buymecoffee]: https://www.buymeacoffee.com/ludeeus +[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/custom-components/integration_blueprint.svg?style=for-the-badge +[commits]: https://github.com/custom-components/integration_blueprint/commits/master +[hacs]: https://hacs.xyz +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge +[discord]: https://discord.gg/Qa5fW2R +[discord-shield]: https://img.shields.io/discord/330944238910963714.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]: https://github.com/custom-components/integration_blueprint/blob/main/LICENSE +[license-shield]: https://img.shields.io/github/license/custom-components/integration_blueprint.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/custom-components/integration_blueprint.svg?style=for-the-badge +[releases]: https://github.com/custom-components/integration_blueprint/releases +[user_profile]: https://github.com/ludeeus diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..7d78f01 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1 @@ +homeassistant diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..ee5e049 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1 @@ +pytest-homeassistant-custom-component==0.4.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4ee3655 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,35 @@ +[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.integration_blueprint, tests +combine_as_imports = true diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..90017d4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,24 @@ +# Why? + +While tests aren't required to publish a custom component for Home Assistant, they will generally make development easier because good tests will expose when changes you want to make to the component logic will break expected functionality. Home Assistant uses [`pytest`](https://docs.pytest.org/en/latest/) for its tests, and the tests that have been included are modeled after tests that are written for core Home Assistant integrations. These tests pass with 100% coverage (unless something has changed ;) ) and have comments to help you understand the purpose of different parts of the test. + +# Getting Started + +To begin, it is recommended to create a virtual environment to install dependencies: +```bash +python3 -m venv venv +source venv/bin/activate +``` + +You can then install the dependencies that will allow you to run tests: +`pip3 install -r requirements_test.txt.` + +This will install `homeassistant`, `pytest`, and `pytest-homeassistant-custom-component`, a plugin which allows you to leverage helpers that are available in Home Assistant for core integration tests. + +# Useful commands + +Command | Description +------- | ----------- +`pytest tests/` | This will run all tests in `tests/` and tell you how many passed/failed +`pytest --durations=10 --cov-report term-missing --cov=custom_components.integration_blueprint tests` | This tells `pytest` that your target module to test is `custom_components.integration_blueprint` so that it can give you a [code coverage](https://en.wikipedia.org/wiki/Code_coverage) summary, including % of code that was executed and the line numbers of missed executions. +`pytest tests/test_init.py -k test_setup_unload_and_reload_entry` | Runs the `test_setup_unload_and_reload_entry` test function located in `tests/test_init.py` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0ba5e33 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for integration_blueprint integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f7835f6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,63 @@ +"""Global fixtures for integration_blueprint integration.""" +# Fixtures allow you to replace functions with a Mock object. You can perform +# many options via the Mock to reflect a particular behavior from the original +# function that you want to see without going through the function's actual logic. +# Fixtures can either be passed into tests as parameters, or if autouse=True, they +# will automatically be used across all tests. +# +# Fixtures that are defined in conftest.py are available across all tests. You can also +# define fixtures within a particular test file to scope them locally. +# +# pytest_homeassistant_custom_component provides some fixtures that are provided by +# Home Assistant core. You can find those fixture definitions here: +# https://github.com/MatthewFlamm/pytest-homeassistant-custom-component/blob/master/pytest_homeassistant_custom_component/common.py +# +# See here for more info: https://docs.pytest.org/en/latest/fixture.html (note that +# pytest includes fixtures OOB which you can use as defined on this page) +from unittest.mock import patch + +import pytest + +pytest_plugins = "pytest_homeassistant_custom_component" + + +# This fixture enables loading custom integrations in all tests. +# Remove to enable selective use of this fixture +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield + + +# This fixture is used to prevent HomeAssistant from attempting to create and dismiss persistent +# notifications. These calls would fail without this fixture since the persistent_notification +# integration is never loaded during a test. +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture(): + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +# This fixture, when used, will result in calls to async_get_data to return None. To have the call +# return a value, we would add the `return_value=` parameter to the patch call. +@pytest.fixture(name="bypass_get_data") +def bypass_get_data_fixture(): + """Skip calls to get data from API.""" + with patch( + "custom_components.integration_blueprint.IntegrationBlueprintApiClient.async_get_data" + ): + yield + + +# In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful +# for exception handling. +@pytest.fixture(name="error_on_get_data") +def error_get_data_fixture(): + """Simulate error when retrieving data from API.""" + with patch( + "custom_components.integration_blueprint.IntegrationBlueprintApiClient.async_get_data", + side_effect=Exception, + ): + yield diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 0000000..83c523f --- /dev/null +++ b/tests/const.py @@ -0,0 +1,5 @@ +"""Constants for integration_blueprint tests.""" +from custom_components.integration_blueprint.const import CONF_PASSWORD, CONF_USERNAME + +# Mock config data to be used across multiple tests +MOCK_CONFIG = {CONF_USERNAME: "test_username", CONF_PASSWORD: "test_password"} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..65ab7f3 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,86 @@ +"""Tests for integration_blueprint api.""" +import asyncio + +import aiohttp +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from custom_components.integration_blueprint.api import IntegrationBlueprintApiClient + + +async def test_api(hass, aioclient_mock, caplog): + """Test API calls.""" + + # To test the api submodule, we first create an instance of our API client + api = IntegrationBlueprintApiClient("test", "test", async_get_clientsession(hass)) + + # Use aioclient_mock which is provided by `pytest_homeassistant_custom_components` + # to mock responses to aiohttp requests. In this case we are telling the mock to + # return {"test": "test"} when a `GET` call is made to the specified URL. We then + # call `async_get_data` which will make that `GET` request. + aioclient_mock.get( + "https://jsonplaceholder.typicode.com/posts/1", json={"test": "test"} + ) + assert await api.async_get_data() == {"test": "test"} + + # We do the same for `async_set_title`. Note the difference in the mock call + # between the previous step and this one. We use `patch` here instead of `get` + # because we know that `async_set_title` calls `api_wrapper` with `patch` as the + # first parameter + aioclient_mock.patch("https://jsonplaceholder.typicode.com/posts/1") + assert await api.async_set_title("test") is None + + # In order to get 100% coverage, we need to test `api_wrapper` to test the code + # that isn't already called by `async_get_data` and `async_set_title`. Because the + # only logic that lives inside `api_wrapper` that is not being handled by a third + # party library (aiohttp) is the exception handling, we also want to simulate + # raising the exceptions to ensure that the function handles them as expected. + # The caplog fixture allows access to log messages in tests. This is particularly + # useful during exception handling testing since often the only action as part of + # exception handling is a logging statement + caplog.clear() + aioclient_mock.put( + "https://jsonplaceholder.typicode.com/posts/1", exc=asyncio.TimeoutError + ) + assert ( + await api.api_wrapper("put", "https://jsonplaceholder.typicode.com/posts/1") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Timeout error fetching information from" in caplog.record_tuples[0][2] + ) + + caplog.clear() + aioclient_mock.post( + "https://jsonplaceholder.typicode.com/posts/1", exc=aiohttp.ClientError + ) + assert ( + await api.api_wrapper("post", "https://jsonplaceholder.typicode.com/posts/1") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Error fetching information from" in caplog.record_tuples[0][2] + ) + + caplog.clear() + aioclient_mock.post("https://jsonplaceholder.typicode.com/posts/2", exc=Exception) + assert ( + await api.api_wrapper("post", "https://jsonplaceholder.typicode.com/posts/2") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Something really wrong happened!" in caplog.record_tuples[0][2] + ) + + caplog.clear() + aioclient_mock.post("https://jsonplaceholder.typicode.com/posts/3", exc=TypeError) + assert ( + await api.api_wrapper("post", "https://jsonplaceholder.typicode.com/posts/3") + is None + ) + assert ( + len(caplog.record_tuples) == 1 + and "Error parsing information from" in caplog.record_tuples[0][2] + ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..326eb16 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test integration_blueprint config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.integration_blueprint.const import ( + BINARY_SENSOR, + DOMAIN, + PLATFORMS, + SENSOR, + SWITCH, +) + +from .const import MOCK_CONFIG + + +# This fixture bypasses the actual setup of the integration +# since we only want to test the config flow. We test the +# actual functionality of the integration in other test modules. +@pytest.fixture(autouse=True) +def bypass_setup_fixture(): + """Prevent setup.""" + with patch( + "custom_components.integration_blueprint.async_setup", + return_value=True, + ), patch( + "custom_components.integration_blueprint.async_setup_entry", + return_value=True, + ): + yield + + +# Here we simiulate a successful config flow from the backend. +# Note that we use the `bypass_get_data` fixture here because +# we want the config flow validation to succeed during the test. +async def test_successful_config_flow(hass, bypass_get_data): + """Test a successful config flow.""" + # Initialize a config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Check that the config flow shows the user form as the first step + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # If a user were to enter `test_username` for username and `test_password` + # for password, it would result in this function call + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + # Check that the config flow is complete and a new entry is created with + # the input data + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test_username" + assert result["data"] == MOCK_CONFIG + assert result["result"] + + +# In this case, we want to simulate a failure during the config flow. +# We use the `error_on_get_data` mock instead of `bypass_get_data` +# (note the function parameters) to raise an Exception during +# validation of the input config. +async def test_failed_config_flow(hass, error_on_get_data): + """Test a failed config flow due to credential validation failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "auth"} + + +# Our config flow also has an options flow, so we must test it as well. +async def test_options_flow(hass): + """Test an options flow.""" + # Create a new MockConfigEntry and add to HASS (we're bypassing config + # flow entirely) + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + entry.add_to_hass(hass) + + # Initialize an options flow + result = await hass.config_entries.options.async_init(entry.entry_id) + + # Verify that the first options step is a user form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # Enter some fake data into the form + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={platform: platform != SENSOR for platform in PLATFORMS}, + ) + + # Verify that the flow finishes + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test_username" + + # Verify that the options were updated + assert entry.options == {BINARY_SENSOR: True, SENSOR: False, SWITCH: True} diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..4d46ec4 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,56 @@ +"""Test integration_blueprint setup process.""" +from homeassistant.exceptions import ConfigEntryNotReady +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.integration_blueprint import ( + BlueprintDataUpdateCoordinator, + async_reload_entry, + async_setup_entry, + async_unload_entry, +) +from custom_components.integration_blueprint.const import DOMAIN + +from .const import MOCK_CONFIG + + +# We can pass fixtures as defined in conftest.py to tell pytest to use the fixture +# for a given test. We can also leverage fixtures and mocks that are available in +# Home Assistant using the pytest_homeassistant_custom_component plugin. +# Assertions allow you to verify that the return value of whatever is on the left +# side of the assertion matches with the right side. +async def test_setup_unload_and_reload_entry(hass, bypass_get_data): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + + # Set up the entry and assert that the values set during setup are where we expect + # them to be. Because we have patched the BlueprintDataUpdateCoordinator.async_get_data + # call, no code from custom_components/integration_blueprint/api.py actually runs. + assert await async_setup_entry(hass, config_entry) + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert ( + type(hass.data[DOMAIN][config_entry.entry_id]) == BlueprintDataUpdateCoordinator + ) + + # Reload the entry and assert that the data from above is still there + assert await async_reload_entry(hass, config_entry) is None + assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] + assert ( + type(hass.data[DOMAIN][config_entry.entry_id]) == BlueprintDataUpdateCoordinator + ) + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, config_entry) + assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_setup_entry_exception(hass, error_on_get_data): + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + + # In this case we are testing the condition where async_setup_entry raises + # ConfigEntryNotReady using the `error_on_get_data` fixture which simulates + # an error. + with pytest.raises(ConfigEntryNotReady): + assert await async_setup_entry(hass, config_entry) diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100644 index 0000000..a48d58e --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,44 @@ +"""Test integration_blueprint switch.""" +from unittest.mock import call, patch + +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.integration_blueprint import async_setup_entry +from custom_components.integration_blueprint.const import DEFAULT_NAME, DOMAIN, SWITCH + +from .const import MOCK_CONFIG + + +async def test_switch_services(hass): + """Test switch services.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + # Functions/objects can be patched directly in test code as well and can be used to test + # additional things, like whether a function was called or what arguments it was called with + with patch( + "custom_components.integration_blueprint.IntegrationBlueprintApiClient.async_set_title" + ) as title_func: + await hass.services.async_call( + SWITCH, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: f"{SWITCH}.{DEFAULT_NAME}_{SWITCH}"}, + blocking=True, + ) + assert title_func.called + assert title_func.call_args == call("foo") + + title_func.reset_mock() + + await hass.services.async_call( + SWITCH, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: f"{SWITCH}.{DEFAULT_NAME}_{SWITCH}"}, + blocking=True, + ) + assert title_func.called + assert title_func.call_args == call("bar")