diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..c70f1f5 --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "ludeeus/integration_blueprint", + "image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye", + "postCreateCommand": "scripts/setup", + "forwardPorts": [ + 8123 + ], + "portsAttributes": { + "8123": { + "label": "Home Assistant", + "onAutoForward": "notify" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "github.vscode-pull-request-github", + "ryanluker.vscode-coverage-gutters", + "ms-python.vscode-pylance" + ], + "settings": { + "files.eol": "\n", + "editor.tabSize": 4, + "python.pythonPath": "/usr/bin/python3", + "python.analysis.autoSearchPaths": false, + "python.linting.pylintEnabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true + } + } + }, + "remoteUser": "vscode", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} 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/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..92fe7a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +--- +name: "Bug report" +description: "Report a bug with the integration" +labels: "Bug" +body: +- type: markdown + attributes: + value: Before you open a new issue, search through the existing issues to see if others have had the same problem. +- type: textarea + attributes: + label: "System Health details" + description: "Paste the data from the System Health card in Home Assistant (https://www.home-assistant.io/more-info/system-health#github-issues)" + validations: + required: true +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have enabled debug logging for my installation. + required: true + - label: I have filled out the issue template to the best of my ability. + required: true + - label: This issue only contains 1 issue (if you have multiple issues, open one issue for each issue). + required: true + - label: This issue is not a duplicate issue of any [previous issues](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Bug%22+).. + required: true +- type: textarea + attributes: + label: "Describe the issue" + description: "A clear and concise description of what the issue is." + validations: + required: true +- type: textarea + attributes: + label: Reproduction steps + description: "Without steps to reproduce, it will be hard to fix. It is very important that you fill out this part. Issues without it will be closed." + value: | + 1. + 2. + 3. + ... + validations: + required: true +- type: textarea + attributes: + label: "Debug logs" + description: "To enable debug logs check this https://www.home-assistant.io/integrations/logger/, this **needs** to include _everything_ from startup of Home Assistant to the point where you encounter the issue." + render: text + validations: + required: true + +- type: textarea + attributes: + label: "Diagnostics dump" + description: "Drag the diagnostics dump file here. (see https://www.home-assistant.io/integrations/diagnostics/ for info)" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..433467b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,47 @@ +--- +name: "Feature request" +description: "Suggest an idea for this project" +labels: "Feature+Request" +body: +- type: markdown + attributes: + value: Before you open a new feature request, search through the existing feature requests to see if others have had the same idea. +- type: checkboxes + attributes: + label: Checklist + options: + - label: I have filled out the template to the best of my ability. + required: true + - label: This only contains 1 feature request (if you have multiple feature requests, open one feature request for each feature request). + required: true + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). + required: true + +- type: textarea + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is." + placeholder: "I'm always frustrated when [...]" + validations: + required: true + +- type: textarea + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + +- type: textarea + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + validations: + required: true + +- type: textarea + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..04f2d40 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + ignore: + # Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json + - dependency-name: "homeassistant" \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..25bf6cc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: "Lint" + +on: + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + ruff: + name: "Ruff" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Set up Python" + uses: actions/setup-python@v4.7.1 + with: + python-version: "3.11" + cache: "pip" + + - name: "Install requirements" + run: python3 -m pip install -r requirements.txt + + - name: "Run" + run: python3 -m ruff check . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f27118 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: "Release" + +on: + release: + types: + - "published" + +permissions: {} + +jobs: + release: + name: "Release" + runs-on: "ubuntu-latest" + permissions: + contents: write + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Adjust version number" + shell: "bash" + run: | + yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ + "${{ github.workspace }}/custom_components/integration_blueprint/manifest.json" + + - name: "ZIP the integration directory" + shell: "bash" + run: | + cd "${{ github.workspace }}/custom_components/integration_blueprint" + zip integration_blueprint.zip -r ./ + + - name: "Upload the ZIP file to the release" + uses: softprops/action-gh-release@v0.1.15 + with: + files: ${{ github.workspace }}/custom_components/integration_blueprint/integration_blueprint.zip diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3f643d1 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: "Validate" + +on: + workflow_dispatch: + schedule: + - cron: "0 0 * * *" + push: + branches: + - "main" + pull_request: + branches: + - "main" + +jobs: + hassfest: # https://developers.home-assistant.io/blog/2020/04/16/hassfest + name: "Hassfest Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run hassfest validation" + uses: "home-assistant/actions/hassfest@master" + + hacs: # https://github.com/hacs/action + name: "HACS Validation" + runs-on: "ubuntu-latest" + steps: + - name: "Checkout the repository" + uses: "actions/checkout@v4.1.0" + + - name: "Run HACS validation" + uses: "hacs/action@main" + with: + category: "integration" + # Remove this 'ignore' key when you have added brand images for your integration to https://github.com/home-assistant/brands + ignore: "brands" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94fa16c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# artifacts +__pycache__ +.pytest* +*.egg-info +*/build/* +*/dist/* + + +# misc +.coverage +.vscode +coverage.xml + + +# Home Assistant configuration +config/* +!config/configuration.yaml \ No newline at end of file diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..7a8331a --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,48 @@ +# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml + +target-version = "py310" + +select = [ + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "C", # complexity + "D", # docstrings + "E", # pycodestyle + "F", # pyflakes/autoflake + "ICN001", # import concentions; {name} should be imported as {asname} + "PGH004", # Use specific rule codes when using noqa + "PLC0414", # Useless import alias. Import alias does not rename original package. + "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass + "SIM117", # Merge with-statements that use the same scope + "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() + "SIM201", # Use {left} != {right} instead of not {left} == {right} + "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} + "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. + "SIM401", # Use get from dict with default instead of an if block + "T20", # flake8-print + "TRY004", # Prefer TypeError exception for invalid type + "RUF006", # Store a reference to the return value of asyncio.create_task + "UP", # pyupgrade + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D404", # First word of the docstring should not be This + "D406", # Section name should end with a newline + "D407", # Section name underlining + "D411", # Missing blank line before section + "E501", # line too long + "E731", # do not assign a lambda expression, use a def +] + +[flake8-pytest-style] +fixture-parentheses = false + +[pyupgrade] +keep-runtime-typing = true + +[mccabe] +max-complexity = 25 \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..54ba9c4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Home Assistant on port 8123", + "type": "shell", + "command": "scripts/develop", + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..88f2fa7 --- /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 `main`. +2. If you've changed something, update the documentation. +3. Make sure your code lints (using `scripts/lint`). +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/ludeeus/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 +[`configuration.yaml`](./config/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..e56d915 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2023 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..e15e9ef --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# 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 | Documentation +-- | -- | -- +`.devcontainer.json` | Used for development/testing with Visual Studio Code. | [Documentation](https://code.visualstudio.com/docs/remote/containers) +`.github/ISSUE_TEMPLATE/*.yml` | Templates for the issue tracker | [Documentation](https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository) +`.vscode/tasks.json` | Tasks for the devcontainer. | [Documentation](https://code.visualstudio.com/docs/editor/tasks) +`custom_components/integration_blueprint/*` | Integration files, this is where everything happens. | [Documentation](https://developers.home-assistant.io/docs/creating_component_index) +`CONTRIBUTING.md` | Guidelines on how to contribute. | [Documentation](https://help.github.com/en/github/building-a-strong-community/setting-guidelines-for-repository-contributors) +`LICENSE` | The license file for the project. | [Documentation](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/licensing-a-repository) +`README.md` | The file you are reading now, should contain info about the integration, installation and configuration instructions. | [Documentation](https://help.github.com/en/github/writing-on-github/basic-writing-and-formatting-syntax) +`requirements.txt` | Python packages used for development/lint/testing this integration. | [Documentation](https://pip.pypa.io/en/stable/user_guide/#requirements-files) + +## How? + +1. Create a new repository in GitHub, using this repository as a template by clicking the "Use this template" button in the GitHub UI. +1. Open your new repository in Visual Studio Code devcontainer (Preferably with the "`Dev Containers: Clone Repository in Named Container Volume...`" option). +1. Rename all instances of the `integration_blueprint` to `custom_components/` (e.g. `custom_components/awesome_integration`). +1. Rename all instances of the `Integration Blueprint` to `` (e.g. `Awesome Integration`). +1. Run the `scripts/develop` to start HA and test out your new integration. + +## Next steps + +These are some next steps you may want to look into: +- Add tests to your integration, [`pytest-homeassistant-custom-component`](https://github.com/MatthewFlamm/pytest-homeassistant-custom-component) can help you get started. +- Add brand images (logo/icon) to https://github.com/home-assistant/brands. +- Create your first release. +- Share your integration on the [Home Assistant Forum](https://community.home-assistant.io/). +- Submit your integration to the [HACS](https://hacs.xyz/docs/publish/start). diff --git a/README_EXAMPLE.md b/README_EXAMPLE.md new file mode 100644 index 0000000..e6fe2c5 --- /dev/null +++ b/README_EXAMPLE.md @@ -0,0 +1,56 @@ +# Integration Blueprint + +[![GitHub Release][releases-shield]][releases] +[![GitHub Activity][commits-shield]][commits] +[![License][license-shield]](LICENSE) + +![Project Maintenance][maintenance-shield] +[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] + +[![Discord][discord-shield]][discord] +[![Community Forum][forum-shield]][forum] + +_Integration to integrate with [integration_blueprint][integration_blueprint]._ + +**This integration 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`. + +## Installation + +1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +1. If you do not have a `custom_components` directory (folder) there, you need to create it. +1. In the `custom_components` directory (folder) create a new folder called `integration_blueprint`. +1. Download _all_ the files from the `custom_components/integration_blueprint/` directory (folder) in this repository. +1. Place the files you downloaded in the new directory (folder) you created. +1. Restart Home Assistant +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Integration blueprint" + +## 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/ludeeus/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/ludeeus/integration_blueprint.svg?style=for-the-badge +[commits]: https://github.com/ludeeus/integration_blueprint/commits/main +[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/ludeeus/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/ludeeus/integration_blueprint.svg?style=for-the-badge +[releases]: https://github.com/ludeeus/integration_blueprint/releases diff --git a/config/configuration.yaml b/config/configuration.yaml new file mode 100644 index 0000000..8c0d4e4 --- /dev/null +++ b/config/configuration.yaml @@ -0,0 +1,8 @@ +# https://www.home-assistant.io/integrations/default_config/ +default_config: + +# https://www.home-assistant.io/integrations/logger/ +logger: + default: info + logs: + custom_components.integration_blueprint: debug diff --git a/custom_components/integration_blueprint/__init__.py b/custom_components/integration_blueprint/__init__.py new file mode 100644 index 0000000..a9adfdc --- /dev/null +++ b/custom_components/integration_blueprint/__init__.py @@ -0,0 +1,55 @@ +"""Custom integration to integrate integration_blueprint with Home Assistant. + +For more details about this integration, please refer to +https://github.com/ludeeus/integration_blueprint +""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import IntegrationBlueprintApiClient +from .const import DOMAIN +from .coordinator import BlueprintDataUpdateCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, + Platform.BINARY_SENSOR, + Platform.SWITCH, +] + + +# https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator = BlueprintDataUpdateCoordinator( + hass=hass, + client=IntegrationBlueprintApiClient( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ), + ) + # https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities + await coordinator.async_config_entry_first_refresh() + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + 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..a738040 --- /dev/null +++ b/custom_components/integration_blueprint/api.py @@ -0,0 +1,90 @@ +"""Sample API Client.""" +from __future__ import annotations + +import asyncio +import socket + +import aiohttp +import async_timeout + + +class IntegrationBlueprintApiClientError(Exception): + """Exception to indicate a general API error.""" + + +class IntegrationBlueprintApiClientCommunicationError( + IntegrationBlueprintApiClientError +): + """Exception to indicate a communication error.""" + + +class IntegrationBlueprintApiClientAuthenticationError( + IntegrationBlueprintApiClientError +): + """Exception to indicate an authentication error.""" + + +class IntegrationBlueprintApiClient: + """Sample API Client.""" + + 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) -> any: + """Get data from the API.""" + return await self._api_wrapper( + method="get", url="https://jsonplaceholder.typicode.com/posts/1" + ) + + async def async_set_title(self, value: str) -> any: + """Get data from the API.""" + return await self._api_wrapper( + method="patch", + url="https://jsonplaceholder.typicode.com/posts/1", + data={"title": value}, + headers={"Content-type": "application/json; charset=UTF-8"}, + ) + + async def _api_wrapper( + self, + method: str, + url: str, + data: dict | None = None, + headers: dict | None = None, + ) -> any: + """Get information from the API.""" + try: + async with async_timeout.timeout(10): + response = await self._session.request( + method=method, + url=url, + headers=headers, + json=data, + ) + if response.status in (401, 403): + raise IntegrationBlueprintApiClientAuthenticationError( + "Invalid credentials", + ) + response.raise_for_status() + return await response.json() + + except asyncio.TimeoutError as exception: + raise IntegrationBlueprintApiClientCommunicationError( + "Timeout error fetching information", + ) from exception + except (aiohttp.ClientError, socket.gaierror) as exception: + raise IntegrationBlueprintApiClientCommunicationError( + "Error fetching information", + ) from exception + except Exception as exception: # pylint: disable=broad-except + raise IntegrationBlueprintApiClientError( + "Something really wrong happened!" + ) from 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..fff5b21 --- /dev/null +++ b/custom_components/integration_blueprint/binary_sensor.py @@ -0,0 +1,50 @@ +"""Binary sensor platform for integration_blueprint.""" +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) + +from .const import DOMAIN +from .coordinator import BlueprintDataUpdateCoordinator +from .entity import IntegrationBlueprintEntity + +ENTITY_DESCRIPTIONS = ( + BinarySensorEntityDescription( + key="integration_blueprint", + name="Integration Blueprint Binary Sensor", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up the binary_sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices( + IntegrationBlueprintBinarySensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in ENTITY_DESCRIPTIONS + ) + + +class IntegrationBlueprintBinarySensor(IntegrationBlueprintEntity, BinarySensorEntity): + """integration_blueprint binary_sensor class.""" + + def __init__( + self, + coordinator: BlueprintDataUpdateCoordinator, + entity_description: BinarySensorEntityDescription, + ) -> None: + """Initialize the binary_sensor class.""" + super().__init__(coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """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..a474163 --- /dev/null +++ b/custom_components/integration_blueprint/config_flow.py @@ -0,0 +1,80 @@ +"""Adds config flow for Blueprint.""" +from __future__ import annotations + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .api import ( + IntegrationBlueprintApiClient, + IntegrationBlueprintApiClientAuthenticationError, + IntegrationBlueprintApiClientCommunicationError, + IntegrationBlueprintApiClientError, +) +from .const import DOMAIN, LOGGER + + +class BlueprintFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Blueprint.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict | None = None, + ) -> config_entries.FlowResult: + """Handle a flow initialized by the user.""" + _errors = {} + if user_input is not None: + try: + await self._test_credentials( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + except IntegrationBlueprintApiClientAuthenticationError as exception: + LOGGER.warning(exception) + _errors["base"] = "auth" + except IntegrationBlueprintApiClientCommunicationError as exception: + LOGGER.error(exception) + _errors["base"] = "connection" + except IntegrationBlueprintApiClientError as exception: + LOGGER.exception(exception) + _errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, + default=(user_input or {}).get(CONF_USERNAME), + ): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT + ), + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD + ), + ), + } + ), + errors=_errors, + ) + + async def _test_credentials(self, username: str, password: str) -> None: + """Validate credentials.""" + client = IntegrationBlueprintApiClient( + username=username, + password=password, + session=async_create_clientsession(self.hass), + ) + await client.async_get_data() diff --git a/custom_components/integration_blueprint/const.py b/custom_components/integration_blueprint/const.py new file mode 100644 index 0000000..66c28f3 --- /dev/null +++ b/custom_components/integration_blueprint/const.py @@ -0,0 +1,9 @@ +"""Constants for integration_blueprint.""" +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +NAME = "Integration blueprint" +DOMAIN = "integration_blueprint" +VERSION = "0.0.0" +ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" diff --git a/custom_components/integration_blueprint/coordinator.py b/custom_components/integration_blueprint/coordinator.py new file mode 100644 index 0000000..d427a1a --- /dev/null +++ b/custom_components/integration_blueprint/coordinator.py @@ -0,0 +1,49 @@ +"""DataUpdateCoordinator for integration_blueprint.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .api import ( + IntegrationBlueprintApiClient, + IntegrationBlueprintApiClientAuthenticationError, + IntegrationBlueprintApiClientError, +) +from .const import DOMAIN, LOGGER + + +# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities +class BlueprintDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: IntegrationBlueprintApiClient, + ) -> None: + """Initialize.""" + self.client = client + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + + async def _async_update_data(self): + """Update data via library.""" + try: + return await self.client.async_get_data() + except IntegrationBlueprintApiClientAuthenticationError as exception: + raise ConfigEntryAuthFailed(exception) from exception + except IntegrationBlueprintApiClientError as exception: + raise UpdateFailed(exception) from exception diff --git a/custom_components/integration_blueprint/entity.py b/custom_components/integration_blueprint/entity.py new file mode 100644 index 0000000..4325227 --- /dev/null +++ b/custom_components/integration_blueprint/entity.py @@ -0,0 +1,25 @@ +"""BlueprintEntity class.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN, NAME, VERSION +from .coordinator import BlueprintDataUpdateCoordinator + + +class IntegrationBlueprintEntity(CoordinatorEntity): + """BlueprintEntity class.""" + + _attr_attribution = ATTRIBUTION + + def __init__(self, coordinator: BlueprintDataUpdateCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = coordinator.config_entry.entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=NAME, + model=VERSION, + manufacturer=NAME, + ) diff --git a/custom_components/integration_blueprint/manifest.json b/custom_components/integration_blueprint/manifest.json new file mode 100644 index 0000000..817cd7b --- /dev/null +++ b/custom_components/integration_blueprint/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "integration_blueprint", + "name": "Integration blueprint", + "codeowners": [ + "@ludeeus" + ], + "config_flow": true, + "documentation": "https://github.com/ludeeus/integration_blueprint", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/ludeeus/integration_blueprint/issues", + "version": "0.0.0" +} \ 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..06201fe --- /dev/null +++ b/custom_components/integration_blueprint/sensor.py @@ -0,0 +1,46 @@ +"""Sensor platform for integration_blueprint.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription + +from .const import DOMAIN +from .coordinator import BlueprintDataUpdateCoordinator +from .entity import IntegrationBlueprintEntity + +ENTITY_DESCRIPTIONS = ( + SensorEntityDescription( + key="integration_blueprint", + name="Integration Sensor", + icon="mdi:format-quote-close", + ), +) + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up the sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices( + IntegrationBlueprintSensor( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in ENTITY_DESCRIPTIONS + ) + + +class IntegrationBlueprintSensor(IntegrationBlueprintEntity, SensorEntity): + """integration_blueprint Sensor class.""" + + def __init__( + self, + coordinator: BlueprintDataUpdateCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor class.""" + super().__init__(coordinator) + self.entity_description = entity_description + + @property + def native_value(self) -> str: + """Return the native value of the sensor.""" + return self.coordinator.data.get("body") diff --git a/custom_components/integration_blueprint/switch.py b/custom_components/integration_blueprint/switch.py new file mode 100644 index 0000000..33340a2 --- /dev/null +++ b/custom_components/integration_blueprint/switch.py @@ -0,0 +1,56 @@ +"""Switch platform for integration_blueprint.""" +from __future__ import annotations + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription + +from .const import DOMAIN +from .coordinator import BlueprintDataUpdateCoordinator +from .entity import IntegrationBlueprintEntity + +ENTITY_DESCRIPTIONS = ( + SwitchEntityDescription( + key="integration_blueprint", + name="Integration Switch", + icon="mdi:format-quote-close", + ), +) + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up the sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_devices( + IntegrationBlueprintSwitch( + coordinator=coordinator, + entity_description=entity_description, + ) + for entity_description in ENTITY_DESCRIPTIONS + ) + + +class IntegrationBlueprintSwitch(IntegrationBlueprintEntity, SwitchEntity): + """integration_blueprint switch class.""" + + def __init__( + self, + coordinator: BlueprintDataUpdateCoordinator, + entity_description: SwitchEntityDescription, + ) -> None: + """Initialize the switch class.""" + super().__init__(coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self.coordinator.data.get("title", "") == "foo" + + async def async_turn_on(self, **_: any) -> None: + """Turn on the switch.""" + await self.coordinator.api.async_set_title("bar") + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **_: any) -> None: + """Turn off the switch.""" + await self.coordinator.api.async_set_title("foo") + await self.coordinator.async_request_refresh() diff --git a/custom_components/integration_blueprint/translations/en.json b/custom_components/integration_blueprint/translations/en.json new file mode 100644 index 0000000..049f7a4 --- /dev/null +++ b/custom_components/integration_blueprint/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "description": "If you need help with the configuration have a look here: https://github.com/ludeeus/integration_blueprint", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "auth": "Username/Password is wrong.", + "connection": "Unable to connect to the server.", + "unknown": "Unknown error occurred." + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..0c12f64 --- /dev/null +++ b/hacs.json @@ -0,0 +1,8 @@ +{ + "name": "Integration blueprint", + "filename": "integration_blueprint.zip", + "hide_default_branch": true, + "homeassistant": "2023.8.0", + "render_readme": true, + "zip_release": true +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..96d2c84 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +colorlog==6.7.0 +homeassistant==2023.8.0 +pip>=21.0,<23.2 +ruff==0.0.292 diff --git a/scripts/develop b/scripts/develop new file mode 100755 index 0000000..89eda50 --- /dev/null +++ b/scripts/develop @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +# Create config dir if not present +if [[ ! -d "${PWD}/config" ]]; then + mkdir -p "${PWD}/config" + hass --config "${PWD}/config" --script ensure_config +fi + +# Set the path to custom_components +## This let's us have the structure we want /custom_components/integration_blueprint +## while at the same time have Home Assistant configuration inside /config +## without resulting to symlinks. +export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" + +# Start Home Assistant +hass --config "${PWD}/config" --debug diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..9b5b1df --- /dev/null +++ b/scripts/lint @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +ruff check . --fix diff --git a/scripts/setup b/scripts/setup new file mode 100755 index 0000000..141d19f --- /dev/null +++ b/scripts/setup @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +python3 -m pip install --requirement requirements.txt