diff --git a/.cookiecutter.json b/.cookiecutter.json new file mode 100644 index 0000000..356541b --- /dev/null +++ b/.cookiecutter.json @@ -0,0 +1,10 @@ +{ + "_template": "gh:oncleben31/cookiecutter-homeassistant-custom-component", + "class_name_prefix": "Myenergi", + "domain_name": "myenergi", + "friendly_name": "MyEnergi", + "github_user": "cjne", + "project_name": "myenergi", + "test_suite": "yes", + "version": "0.0.1" +} diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml new file mode 100644 index 0000000..af35bec --- /dev/null +++ b/.devcontainer/configuration.yaml @@ -0,0 +1,8 @@ +default_config: + +logger: + default: info + logs: + custom_components.myenergi: 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..24f2492 --- /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": "MyEnergi 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..8099cf5 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,85 @@ +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.3.4 + + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v2.2.1 + 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.3.4" + + - name: HACS validation + uses: "hacs/action@20.12.0" + with: + category: "integration" + ignore: brands + + hassfest: + runs-on: "ubuntu-latest" + name: Hassfest + steps: + - name: Check out the repository + uses: "actions/checkout@v2.3.4" + + - name: Hassfest validation + uses: "home-assistant/actions/hassfest@master" + tests: + runs-on: "ubuntu-latest" + name: Run tests + steps: + - name: Check out code from GitHub + uses: "actions/checkout@v2.3.4" + - name: Setup Python ${{ env.DEFAULT_PYTHON }} + uses: "actions/setup-python@v2.2.1" + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Install requirements + run: | + pip install --constraint=.github/workflows/constraints.txt pip + pip install -r requirements_test.txt + - name: Tests suite + run: | + pytest \ + --timeout=9 \ + --durations=10 \ + -n auto \ + -p no:sugar \ + tests 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..7f97668 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# 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). + +You should also verify that existing [tests](./tests) are still working +and you are encouraged to add new ones. +You can run the tests using the following commands from the root folder: + +```bash +# Create a virtual environment +python3 -m venv venv +source venv/bin/activate +# Install requirements +pip install -r requirements_test.txt +# Run tests and get a summary of successes/failures and code coverage +pytest --durations=10 --cov-report term-missing --cov=custom_components.myenergi tests +``` + +If any of the tests fail, make the necessary changes to the tests as part of +your changes to the integration. + +## 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..8e2e677 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 cjne + +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..1c919e5 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# MyEnergi + +[![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] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +**TO BE REMOVED: If you need help, as a developer, to use this custom component tempalte, +please look at the [User Guide in the Cookiecutter documentation](https://cookiecutter-homeassistant-custom-component.readthedocs.io/en/stable/quickstart.html)** + +**This component will set up the following platforms.** + +| Platform | Description | +| --------------- | ------------------------------------------------------------------------- | +| `binary_sensor` | Show something `True` or `False`. | +| `sensor` | Show info from MyEnergi 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 `myenergi`. +4. Download _all_ the files from the `custom_components/myenergi/` 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 "MyEnergi" + +Using your HA configuration directory (folder) as a starting point you should now also have this: + +```text +custom_components/myenergi/translations/en.json +custom_components/myenergi/translations/fr.json +custom_components/myenergi/translations/nb.json +custom_components/myenergi/translations/sensor.en.json +custom_components/myenergi/translations/sensor.fr.json +custom_components/myenergi/translations/sensor.nb.json +custom_components/myenergi/translations/sensor.nb.json +custom_components/myenergi/__init__.py +custom_components/myenergi/api.py +custom_components/myenergi/binary_sensor.py +custom_components/myenergi/config_flow.py +custom_components/myenergi/const.py +custom_components/myenergi/manifest.json +custom_components/myenergi/sensor.py +custom_components/myenergi/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) + +## 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.buymeacoffee.com/cjne +[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/cjne/myenergi.svg?style=for-the-badge +[commits]: https://github.com/cjne/myenergi/commits/main +[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-shield]: https://img.shields.io/github/license/cjne/myenergi.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40cjne-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/cjne/myenergi.svg?style=for-the-badge +[releases]: https://github.com/cjne/myenergi/releases +[user_profile]: https://github.com/cjne diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..9e5dc14 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Dummy init so that pytest works.""" diff --git a/custom_components/myenergi/__init__.py b/custom_components/myenergi/__init__.py new file mode 100644 index 0000000..d1f1a92 --- /dev/null +++ b/custom_components/myenergi/__init__.py @@ -0,0 +1,110 @@ +""" +Custom integration to integrate MyEnergi with Home Assistant. + +For more details about this integration, please refer to +https://github.com/cjne/myenergi +""" +import asyncio +import logging +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .api import MyenergiApiClient +from .const import CONF_PASSWORD +from .const import CONF_USERNAME +from .const import DOMAIN +from .const import PLATFORMS +from .const import 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 = MyenergiApiClient(username, password, session) + + coordinator = MyenergiDataUpdateCoordinator(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.add_update_listener(async_reload_entry) + return True + + +class MyenergiDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__( + self, + hass: HomeAssistant, + client: MyenergiApiClient, + ) -> 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/myenergi/api.py b/custom_components/myenergi/api.py new file mode 100644 index 0000000..5bd5424 --- /dev/null +++ b/custom_components/myenergi/api.py @@ -0,0 +1,75 @@ +"""Sample API Client.""" +import asyncio +import logging +import socket + +import aiohttp +import async_timeout + +TIMEOUT = 10 + + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + + +class MyenergiApiClient: + def __init__( + self, username: str, password: str, session: aiohttp.ClientSession + ) -> None: + """Sample API Client.""" + self._username = username + self._passeword = 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, loop=asyncio.get_event_loop()): + 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/myenergi/binary_sensor.py b/custom_components/myenergi/binary_sensor.py new file mode 100644 index 0000000..438d8f4 --- /dev/null +++ b/custom_components/myenergi/binary_sensor.py @@ -0,0 +1,33 @@ +"""Binary sensor platform for MyEnergi.""" +from homeassistant.components.binary_sensor import BinarySensorEntity + +from .const import BINARY_SENSOR +from .const import BINARY_SENSOR_DEVICE_CLASS +from .const import DEFAULT_NAME +from .const import DOMAIN +from .entity import MyenergiEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup binary_sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([MyenergiBinarySensor(coordinator, entry)]) + + +class MyenergiBinarySensor(MyenergiEntity, BinarySensorEntity): + """myenergi 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/myenergi/config_flow.py b/custom_components/myenergi/config_flow.py new file mode 100644 index 0000000..41e59b4 --- /dev/null +++ b/custom_components/myenergi/config_flow.py @@ -0,0 +1,105 @@ +"""Adds config flow for MyEnergi.""" +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from pymyenergi.client import MyEnergiClient +from pymyenergi.connection import Connection +from .const import CONF_PASSWORD +from .const import CONF_USERNAME +from .const import CONF_SCAN_INTERVAL +from .const import DOMAIN +from .const import PLATFORMS + + +class MyenergiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for myenergi.""" + + 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: + valid = await self._test_credentials( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if valid: + return self.async_create_entry( + title="Hub " + user_input[CONF_USERNAME], data=user_input + ) + self._errors["base"] = "auth" + + return await self._show_config_form(user_input) + + return await self._show_config_form(user_input) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return MyenergiOptionsFlowHandler(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): str, vol.Required(CONF_PASSWORD): str} + ), + errors=self._errors, + ) + + async def _test_credentials(self, username, password): + """Return true if credentials is valid.""" + try: + conn = Connection(username, password) + client = MyEnergiClient(conn) + await client.refresh() + return True + except Exception: # pylint: disable=broad-except + pass + return False + + +class MyenergiOptionsFlowHandler(config_entries.OptionsFlow): + """Config flow options handler for myenergi.""" + + 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() + + scan_interval = self.config_entry.options.get( + CONF_SCAN_INTERVAL, self.config_entry.data.get(CONF_SCAN_INTERVAL) + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): int, + } + ), + ) + + async def _update_options(self): + """Update config entry options.""" + return self.async_create_entry( + title=self.config_entry.data.get("Hub " + CONF_USERNAME), data=self.options + ) diff --git a/custom_components/myenergi/const.py b/custom_components/myenergi/const.py new file mode 100644 index 0000000..8b26886 --- /dev/null +++ b/custom_components/myenergi/const.py @@ -0,0 +1,39 @@ +"""Constants for MyEnergi.""" +# Base component constants +NAME = "MyEnergi" +DOMAIN = "myenergi" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.0.1" + +ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" +ISSUE_URL = "https://github.com/cjne/myenergi/issues" + +# Icons +ICON = "mdi:format-quote-close" + +# Device classes +BINARY_SENSOR_DEVICE_CLASS = "connectivity" + +# Platforms +SENSOR = "sensor" +PLATFORMS = [SENSOR] + + +# Configuration and options +CONF_SCAN_INTERVAL = "scan_interval" +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/myenergi/entity.py b/custom_components/myenergi/entity.py new file mode 100644 index 0000000..fbd6233 --- /dev/null +++ b/custom_components/myenergi/entity.py @@ -0,0 +1,36 @@ +"""MyenergiEntity class""" +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION +from .const import DOMAIN +from .const import NAME +from .const import VERSION + + +class MyenergiEntity(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 device_state_attributes(self): + """Return the state attributes.""" + return { + "attribution": ATTRIBUTION, + "id": str(self.coordinator.data.get("id")), + "integration": DOMAIN, + } diff --git a/custom_components/myenergi/manifest.json b/custom_components/myenergi/manifest.json new file mode 100644 index 0000000..711a792 --- /dev/null +++ b/custom_components/myenergi/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "myenergi", + "name": "MyEnergi", + "version": "0.0.1", + "documentation": "https://github.com/cjne/ha-myenergi", + "issue_tracker": "https://github.com/cjne/ha-myenergi/issues", + "dependencies": [], + "config_flow": true, + "codeowners": ["@cjne"], + "requirements": [] +} diff --git a/custom_components/myenergi/sensor.py b/custom_components/myenergi/sensor.py new file mode 100644 index 0000000..9f5077f --- /dev/null +++ b/custom_components/myenergi/sensor.py @@ -0,0 +1,36 @@ +"""Sensor platform for MyEnergi.""" +from .const import DEFAULT_NAME +from .const import DOMAIN +from .const import ICON +from .const import SENSOR +from .entity import MyenergiEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([MyenergiSensor(coordinator, entry)]) + + +class MyenergiSensor(MyenergiEntity): + """myenergi Sensor class.""" + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DEFAULT_NAME}_{SENSOR}" + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data.get("body") + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON + + @property + def device_class(self): + """Return de device class of the sensor.""" + return "myenergi__custom_device_class" diff --git a/custom_components/myenergi/switch.py b/custom_components/myenergi/switch.py new file mode 100644 index 0000000..6c85449 --- /dev/null +++ b/custom_components/myenergi/switch.py @@ -0,0 +1,43 @@ +"""Switch platform for MyEnergi.""" +from homeassistant.components.switch import SwitchEntity + +from .const import DEFAULT_NAME +from .const import DOMAIN +from .const import ICON +from .const import SWITCH +from .entity import MyenergiEntity + + +async def async_setup_entry(hass, entry, async_add_devices): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices([MyenergiBinarySwitch(coordinator, entry)]) + + +class MyenergiBinarySwitch(MyenergiEntity, SwitchEntity): + """myenergi 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/myenergi/translations/en.json b/custom_components/myenergi/translations/en.json new file mode 100644 index 0000000..ac7252b --- /dev/null +++ b/custom_components/myenergi/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "MyEnergi", + "description": "If you need help with the configuration have a look here: https://github.com/cjne/myenergi", + "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" + } + } + } + } +} diff --git a/custom_components/myenergi/translations/fr.json b/custom_components/myenergi/translations/fr.json new file mode 100644 index 0000000..b6ade2f --- /dev/null +++ b/custom_components/myenergi/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "MyEnergi", + "description": "Si vous avez besoin d'aide pour la configuration, regardez ici: https://github.com/cjne/myenergi", + "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é" + } + } + } + } +} diff --git a/custom_components/myenergi/translations/nb.json b/custom_components/myenergi/translations/nb.json new file mode 100644 index 0000000..cc7aece --- /dev/null +++ b/custom_components/myenergi/translations/nb.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "MyEnergi", + "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/cjne/myenergi", + "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" + } + } + } + } +} 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..e4fde04 --- /dev/null +++ b/hacs.json @@ -0,0 +1,7 @@ +{ + "name": "MyEnergi", + "hacs": "1.6.0", + "domains": ["binary_sensor", "sensor", "switch"], + "iot_class": "Cloud Polling", + "homeassistant": "0.118.0" +} diff --git a/info.md b/info.md new file mode 100644 index 0000000..1e78df3 --- /dev/null +++ b/info.md @@ -0,0 +1,60 @@ +[![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] + +**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 "MyEnergi". + +{% endif %} + +## Configuration is done in the UI + + + +## 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 +[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/cjne/myenergi.svg?style=for-the-badge +[commits]: https://github.com/cjne/myenergi/commits/main +[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/cjne/myenergi/blob/main/LICENSE +[license-shield]: https://img.shields.io/github/license/cjne/myenergi.svg?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-%40cjne-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/cjne/myenergi.svg?style=for-the-badge +[releases]: https://github.com/cjne/myenergi/releases +[user_profile]: https://github.com/cjne diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..5f04ff1 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,2 @@ +homeassistant +pymyenergi==0.0.1 diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..a39a876 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,2 @@ +-r requirements_dev.txt +pytest-homeassistant-custom-component==0.1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8a52d62 --- /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.myenergi, tests +combine_as_imports = true + +[tool:pytest] +addopts = -qq --cov=custom_components.myenergi +console_output_style = count + +[coverage:run] +branch = False + +[coverage:report] +show_missing = true +fail_under = 100 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0405515 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for MyEnergi integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..289de30 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +"""Global fixtures for MyEnergi integration.""" +from unittest.mock import patch + +import pytest + +pytest_plugins = "pytest_homeassistant_custom_component" + + +# 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.myenergi.MyenergiApiClient.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.myenergi.MyenergiApiClient.async_get_data", + side_effect=Exception, + ): + yield diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 0000000..899c35b --- /dev/null +++ b/tests/const.py @@ -0,0 +1,9 @@ +"""Constants for MyEnergi tests.""" +from custom_components.myenergi.const import ( + CONF_PASSWORD, +) +from custom_components.myenergi.const import ( + CONF_USERNAME, +) + +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..8b687f0 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,87 @@ +"""Tests for MyEnergi api.""" +import asyncio + +import aiohttp +from custom_components.myenergi.api import ( + MyenergiApiClient, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +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 = MyenergiApiClient("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..2c340a4 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,117 @@ +"""Test MyEnergi config flow.""" +from unittest.mock import patch + +import pytest +from custom_components.myenergi.const import ( + BINARY_SENSOR, +) +from custom_components.myenergi.const import ( + DOMAIN, +) +from custom_components.myenergi.const import ( + PLATFORMS, +) +from custom_components.myenergi.const import ( + SENSOR, +) +from custom_components.myenergi.const import ( + SWITCH, +) +from homeassistant import config_entries +from homeassistant import data_entry_flow +from pytest_homeassistant_custom_component.common import MockConfigEntry + +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.myenergi.async_setup", return_value=True,), patch( + "custom_components.myenergi.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 + await hass.config_entries.async_setup(entry.entry_id) + 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..e67b674 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,63 @@ +"""Test MyEnergi setup process.""" +import pytest +from custom_components.myenergi import ( + async_reload_entry, +) +from custom_components.myenergi import ( + async_setup_entry, +) +from custom_components.myenergi import ( + async_unload_entry, +) +from custom_components.myenergi import ( + MyenergiDataUpdateCoordinator, +) +from custom_components.myenergi.const import ( + DOMAIN, +) +from homeassistant.exceptions import ConfigEntryNotReady +from pytest_homeassistant_custom_component.common import MockConfigEntry + +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 MyenergiDataUpdateCoordinator.async_get_data + # call, no code from custom_components/myenergi/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]) == MyenergiDataUpdateCoordinator + ) + + # 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]) == MyenergiDataUpdateCoordinator + ) + + # 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..3aa6385 --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,55 @@ +"""Test MyEnergi switch.""" +from unittest.mock import call +from unittest.mock import patch + +from custom_components.myenergi import ( + async_setup_entry, +) +from custom_components.myenergi.const import ( + DEFAULT_NAME, +) +from custom_components.myenergi.const import ( + DOMAIN, +) +from custom_components.myenergi.const import ( + SWITCH, +) +from homeassistant.components.switch import SERVICE_TURN_OFF +from homeassistant.components.switch import SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID +from pytest_homeassistant_custom_component.common import MockConfigEntry + +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.myenergi.MyenergiApiClient.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")