From 09310c0882710a73a11447d2451dc0aa95d3526a Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 21 Mar 2023 11:01:14 +0000 Subject: [PATCH 1/7] clean blueprint --- .devcontainer.json | 2 +- .github/ISSUE_TEMPLATE/bug.yml | 2 +- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- .github/workflows/release.yml | 8 +- README.md | 75 ++++++---------- README_EXAMPLE.md | 59 ------------ .../integration_blueprint/__init__.py | 55 ------------ .../integration_blueprint/api.py | 90 ------------------- .../integration_blueprint/binary_sensor.py | 50 ----------- .../integration_blueprint/config_flow.py | 80 ----------------- .../integration_blueprint/const.py | 9 -- .../integration_blueprint/coordinator.py | 49 ---------- .../integration_blueprint/entity.py | 25 ------ .../integration_blueprint/manifest.json | 12 --- .../integration_blueprint/sensor.py | 46 ---------- .../integration_blueprint/switch.py | 56 ------------ .../translations/en.json | 18 ---- hacs.json | 7 +- 18 files changed, 38 insertions(+), 607 deletions(-) delete mode 100644 README_EXAMPLE.md delete mode 100644 custom_components/integration_blueprint/__init__.py delete mode 100644 custom_components/integration_blueprint/api.py delete mode 100644 custom_components/integration_blueprint/binary_sensor.py delete mode 100644 custom_components/integration_blueprint/config_flow.py delete mode 100644 custom_components/integration_blueprint/const.py delete mode 100644 custom_components/integration_blueprint/coordinator.py delete mode 100644 custom_components/integration_blueprint/entity.py delete mode 100644 custom_components/integration_blueprint/manifest.json delete mode 100644 custom_components/integration_blueprint/sensor.py delete mode 100644 custom_components/integration_blueprint/switch.py delete mode 100644 custom_components/integration_blueprint/translations/en.json diff --git a/.devcontainer.json b/.devcontainer.json index 5fda428..68ae254 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,5 +1,5 @@ { - "name": "ludeeus/integration_blueprint", + "name": "bluecurrent/ha-bluecurrent", "image": "mcr.microsoft.com/vscode/devcontainers/python:0-3.10-bullseye", "postCreateCommand": "scripts/setup", "forwardPorts": [ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 9c65fef..dc30e9b 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -22,7 +22,7 @@ body: 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 currently [previous issues](https://github.com/ludeeus/integration_blueprint/issues?q=is%3Aissue+label%3A%22Bug%22+).. + - label: This issue is not a duplicate issue of currently [previous issues](https://github.com/bluecurrent/ha-bluecurrent/issues?q=is%3Aissue+label%3A%22Bug%22+).. required: true - type: textarea attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 433467b..c615423 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -14,7 +14,7 @@ body: 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+). + - label: This issue is not a duplicate feature request of [previous feature requests](https://github.com/bluecurrent/ha-bluecurrent/issues?q=is%3Aissue+label%3A%22Feature+Request%22+). required: true - type: textarea diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0b7eeb..43cd557 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,15 +17,15 @@ jobs: shell: "bash" run: | yq -i -o json '.version="${{ github.event.release.tag_name }}"' \ - "${{ github.workspace }}/custom_components/integration_blueprint/manifest.json" + "${{ github.workspace }}/custom_components/blue_current/manifest.json" - name: "ZIP the integration directory" shell: "bash" run: | - cd "${{ github.workspace }}/custom_components/integration_blueprint" - zip integration_blueprint.zip -r ./ + cd "${{ github.workspace }}/custom_components/blue_current" + zip blue_current.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 + files: ${{ github.workspace }}/custom_components/blue_current/blue_current.zip diff --git a/README.md b/README.md index a6db09e..ed54d51 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,28 @@ -# 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 `scrtipts/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). +# Integration Blueprint + +[![hacs][hacsbadge]][hacs] + +**This integration will set up the following platforms.** + +Platform | Description +-- | -- +`sensor` | Show data from a charge point. +`switch` | Switch something `True` or `False`. +`button` | Run an action. + +## 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 `blue_current`. +1. Download _all_ the files from the `custom_components/blue_current/` 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 + + +*** +[hacs]: https://github.com/hacs/integration +[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file diff --git a/README_EXAMPLE.md b/README_EXAMPLE.md deleted file mode 100644 index 566d4dd..0000000 --- a/README_EXAMPLE.md +++ /dev/null @@ -1,59 +0,0 @@ -# Integration Blueprint - -[![GitHub Release][releases-shield]][releases] -[![GitHub Activity][commits-shield]][commits] -[![License][license-shield]](LICENSE) - -[![hacs][hacsbadge]][hacs] -![Project Maintenance][maintenance-shield] -[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] - -[![Discord][discord-shield]][discord] -[![Community Forum][forum-shield]][forum] - -_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 -[hacs]: https://github.com/hacs/integration -[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/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/custom_components/integration_blueprint/__init__.py b/custom_components/integration_blueprint/__init__.py deleted file mode 100644 index a9adfdc..0000000 --- a/custom_components/integration_blueprint/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -"""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 deleted file mode 100644 index a738040..0000000 --- a/custom_components/integration_blueprint/api.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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 deleted file mode 100644 index fff5b21..0000000 --- a/custom_components/integration_blueprint/binary_sensor.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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 deleted file mode 100644 index a474163..0000000 --- a/custom_components/integration_blueprint/config_flow.py +++ /dev/null @@ -1,80 +0,0 @@ -"""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 deleted file mode 100644 index 66c28f3..0000000 --- a/custom_components/integration_blueprint/const.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index d427a1a..0000000 --- a/custom_components/integration_blueprint/coordinator.py +++ /dev/null @@ -1,49 +0,0 @@ -"""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 deleted file mode 100644 index 4325227..0000000 --- a/custom_components/integration_blueprint/entity.py +++ /dev/null @@ -1,25 +0,0 @@ -"""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 deleted file mode 100644 index 817cd7b..0000000 --- a/custom_components/integration_blueprint/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 06201fe..0000000 --- a/custom_components/integration_blueprint/sensor.py +++ /dev/null @@ -1,46 +0,0 @@ -"""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 deleted file mode 100644 index 33340a2..0000000 --- a/custom_components/integration_blueprint/switch.py +++ /dev/null @@ -1,56 +0,0 @@ -"""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 deleted file mode 100644 index 049f7a4..0000000 --- a/custom_components/integration_blueprint/translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 index 7524a63..d57b88c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,8 +1,7 @@ { - "name": "Integration blueprint", - "filename": "integration_blueprint.zip", + "name": "Blue Current", "hide_default_branch": true, - "homeassistant": "2022.2.0", + "homeassistant": "2022.3.0", "render_readme": true, - "zip_release": true + "country": ["NL"] } \ No newline at end of file From e6679922944473179a3f381b89bcfba6b67dc40e Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 21 Mar 2023 11:01:28 +0000 Subject: [PATCH 2/7] add blue_current integration --- custom_components/blue_current/__init__.py | 246 +++++++++++++ custom_components/blue_current/button.py | 72 ++++ custom_components/blue_current/config_flow.py | 137 +++++++ custom_components/blue_current/const.py | 11 + .../blue_current/device_condition.py | 106 ++++++ .../blue_current/device_trigger.py | 113 ++++++ custom_components/blue_current/entity.py | 45 +++ custom_components/blue_current/manifest.json | 9 + custom_components/blue_current/sensor.py | 342 ++++++++++++++++++ custom_components/blue_current/services.yaml | 35 ++ custom_components/blue_current/strings.json | 80 ++++ custom_components/blue_current/switch.py | 139 +++++++ .../blue_current/translations/en.json | 80 ++++ 13 files changed, 1415 insertions(+) create mode 100755 custom_components/blue_current/__init__.py create mode 100755 custom_components/blue_current/button.py create mode 100755 custom_components/blue_current/config_flow.py create mode 100755 custom_components/blue_current/const.py create mode 100755 custom_components/blue_current/device_condition.py create mode 100755 custom_components/blue_current/device_trigger.py create mode 100755 custom_components/blue_current/entity.py create mode 100755 custom_components/blue_current/manifest.json create mode 100755 custom_components/blue_current/sensor.py create mode 100755 custom_components/blue_current/services.yaml create mode 100755 custom_components/blue_current/strings.json create mode 100755 custom_components/blue_current/switch.py create mode 100755 custom_components/blue_current/translations/en.json diff --git a/custom_components/blue_current/__init__.py b/custom_components/blue_current/__init__.py new file mode 100755 index 0000000..1a5686c --- /dev/null +++ b/custom_components/blue_current/__init__.py @@ -0,0 +1,246 @@ +"""The Blue Current integration.""" +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import entity_registry +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import CARD, DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE + +PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.BUTTON] +CHARGE_POINTS = "CHARGE_POINTS" +DATA = "data" +SMALL_DELAY = 1 +LARGE_DELAY = 20 + +GRID = "GRID" +OBJECT = "object" +VALUE_TYPES = ("CH_STATUS", "CH_SETTINGS") +SETTINGS = ("PUBLIC_CHARGING", "PLUG_AND_CHARGE") +RESULT = "result" +ACTIVITY = "activity" +UNAVAILABLE = "unavailable" +OPERATIVE = "operative" +SERVICES = ("SOFT_RESET", "REBOOT", "START_SESSION", "STOP_SESSION") +SUCCESS = "success" +RESET = "reset" +REBOOT = "reboot" +START_SESSION = "start_session" +STOP_SESSION = "stop_session" + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Blue Current as a config entry.""" + hass.data.setdefault(DOMAIN, {}) + client = Client() + api_token = config_entry.data[CONF_API_TOKEN] + connector = Connector(hass, config_entry, client) + try: + await connector.connect(api_token) + except InvalidApiToken as err: + raise ConfigEntryAuthFailed("Invalid API token.") from err + except BlueCurrentException as err: + raise ConfigEntryNotReady from err + + hass.loop.create_task(connector.start_loop()) + await client.get_charge_points() + + await client.wait_for_response() + hass.data[DOMAIN][config_entry.entry_id] = connector + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + async def _async_disconnect_websocket(_: Event) -> None: + await connector.disconnect() + + config_entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket + ) + ) + + async def handle_reset(call: ServiceCall) -> None: + evse_id = call.data.get(EVSE_ID) + await client.reset(evse_id) + + async def handle_reboot(call: ServiceCall) -> None: + evse_id = call.data.get(EVSE_ID) + await client.reboot(evse_id) + + async def handle_start_session(call: ServiceCall) -> None: + evse_id = call.data.get(EVSE_ID) + await client.start_session(evse_id, config_entry.data[CARD]) + + async def handle_stop_session(call: ServiceCall) -> None: + evse_id = call.data.get(EVSE_ID) + await client.stop_session(evse_id) + + hass.services.async_register(DOMAIN, "reset", handle_reset) + hass.services.async_register(DOMAIN, "reboot", handle_reboot) + hass.services.async_register(DOMAIN, "start_session", handle_start_session) + hass.services.async_register(DOMAIN, "stop_session", handle_stop_session) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload the Blue Current config entry.""" + connector: Connector = hass.data[DOMAIN].pop(config_entry.entry_id) + hass.async_create_task(connector.disconnect()) + + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +def set_entities_unavalible(hass: HomeAssistant, config_id: str) -> None: + """Set all Blue Current entities to unavailable.""" + registry = entity_registry.async_get(hass) + entries = entity_registry.async_entries_for_config_entry(registry, config_id) + + for entry in entries: + entry.write_unavailable_state(hass) + + +class Connector: + """Define a class that connects to the Blue Current websocket API.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Initialize.""" + self.config: ConfigEntry = config + self.hass: HomeAssistant = hass + self.client: Client = client + self.charge_points: dict[str, dict] = {} + self.grid: dict[str, Any] = {} + + async def connect(self, token: str) -> None: + """Register on_data and connect to the websocket.""" + await self.client.connect(token) + + async def on_data(self, message: dict) -> None: + """Handle received data.""" + + async def handle_charge_points(data: list) -> None: + """Loop over the charge points and get their data.""" + for entry in data: + evse_id = entry[EVSE_ID] + model = entry[MODEL_TYPE] + self.add_charge_point(evse_id, model) + await self.get_charge_point_data(evse_id) + await self.client.get_grid_status(data[0][EVSE_ID]) + + object_name: str = message[OBJECT] + + # gets charge point ids + if object_name == CHARGE_POINTS: + charge_points_data: list = message[DATA] + await handle_charge_points(charge_points_data) + + # gets charge point key / values + elif object_name in VALUE_TYPES: + value_data: dict = message[DATA] + evse_id = value_data.pop(EVSE_ID) + self.update_charge_point(evse_id, value_data) + + # gets grid key / values + elif GRID in object_name: + data: dict = message[DATA] + self.grid = data + self.dispatch_grid_update_signal() + + # setting change responses + elif object_name in SETTINGS: + evse_id = message.pop(EVSE_ID) + key = object_name.lower() + result = message[RESULT] + new_data = {key: result} + self.update_charge_point(evse_id, new_data) + + # service responses + elif object_name in SERVICES: + state = "successful" + success: bool = message[SUCCESS] + if not success: + state = "un" + state + LOGGER.debug("%s was %s ", object_name, state) + + async def get_charge_point_data(self, evse_id: str) -> None: + """Get all the data of a charge point.""" + await self.client.get_status(evse_id) + await self.client.get_settings(evse_id) + + def add_charge_point(self, evse_id: str, model: str) -> None: + """Add a charge point to charge_points.""" + self.charge_points[evse_id] = {MODEL_TYPE: model} + + def update_charge_point(self, evse_id: str, data: dict) -> None: + """Update the charge point data.""" + + def handle_activity(data: dict) -> None: + activity = data.get(ACTIVITY) + if activity != UNAVAILABLE: + data[OPERATIVE] = True + else: + data[OPERATIVE] = False + + if ACTIVITY in data: + handle_activity(data) + + self.charge_points[evse_id].update(data) + self.dispatch_value_update_signal(evse_id) + + def dispatch_value_update_signal(self, evse_id: str) -> None: + """Dispatch a value signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_value_update_{evse_id}") + + def dispatch_grid_update_signal(self) -> None: + """Dispatch a grid signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_grid_update") + + async def start_loop(self) -> None: + """Start the receive loop.""" + try: + await self.client.start_loop(self.on_data) + except BlueCurrentException as err: + LOGGER.warning( + "Disconnected from the Blue Current websocket. Retrying to connect in background. %s", + err, + ) + + async_call_later(self.hass, SMALL_DELAY, self.reconnect) + + async def reconnect(self, event_time: datetime | None = None) -> None: + """Keep trying to reconnect to the websocket.""" + try: + await self.connect(self.config.data[CONF_API_TOKEN]) + LOGGER.info("Reconnected to the Blue Current websocket") + self.hass.loop.create_task(self.start_loop()) + await self.client.get_charge_points() + except RequestLimitReached: + set_entities_unavalible(self.hass, self.config.entry_id) + async_call_later( + self.hass, self.client.get_next_reset_delta(), self.reconnect + ) + except WebsocketException: + set_entities_unavalible(self.hass, self.config.entry_id) + async_call_later(self.hass, LARGE_DELAY, self.reconnect) + + async def disconnect(self) -> None: + """Disconnect from the websocket.""" + with suppress(WebsocketException): + await self.client.disconnect() diff --git a/custom_components/blue_current/button.py b/custom_components/blue_current/button.py new file mode 100755 index 0000000..15f46ec --- /dev/null +++ b/custom_components/blue_current/button.py @@ -0,0 +1,72 @@ +"""Support for Blue Current buttons.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN, EVSE_ID +from .entity import BlueCurrentEntity + +BUTTONS = ( + ButtonEntityDescription( + key="reset", name="Reset", icon="mdi:restart", has_entity_name=True + ), + ButtonEntityDescription( + key="reboot", name="Reboot", icon="mdi:restart-alert", has_entity_name=True + ), + ButtonEntityDescription( + key="start_session", name="Start session", icon="mdi:play", has_entity_name=True + ), + ButtonEntityDescription( + key="stop_session", name="Stop session", icon="mdi:stop", has_entity_name=True + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current buttons.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + button_list = [] + for evse_id in connector.charge_points: + for button in BUTTONS: + button_list.append( + ChargePointButton( + connector, + evse_id, + button, + ) + ) + + async_add_entities(button_list) + + +class ChargePointButton(BlueCurrentEntity, ButtonEntity): + """Base Blue Current button.""" + + _attr_should_poll = False + + def __init__( + self, connector: Connector, evse_id: str, button: ButtonEntityDescription + ) -> None: + """Initialize the button.""" + assert button.name is not None + super().__init__(connector, evse_id) + + self.service = button.key + self.entity_description = button + self._attr_unique_id = f"{button.key}_{evse_id}" + + async def async_press(self) -> None: + """Handle the button press.""" + await self.hass.services.async_call( + DOMAIN, self.service, {EVSE_ID: self.evse_id} + ) + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the button.""" diff --git a/custom_components/blue_current/config_flow.py b/custom_components/blue_current/config_flow.py new file mode 100755 index 0000000..256ee50 --- /dev/null +++ b/custom_components/blue_current/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for Blue Current integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + AlreadyConnected, + InvalidApiToken, + NoCardsFound, + RequestLimitReached, + WebsocketException, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CARD, DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_API_TOKEN): str, vol.Optional("add_card"): bool} +) +DEFAULT_CARD = "BCU_APP" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the config flow for Blue Current.""" + + VERSION = 1 + + input: dict[str, Any] + client: Client + entry: config_entries.ConfigEntry | None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + self.client = Client() + errors = {} + if user_input is not None: + + api_token = user_input[CONF_API_TOKEN] + self._async_abort_entries_match({CONF_API_TOKEN: api_token}) + + try: + await self.client.validate_api_token(api_token) + email = await self.client.get_email() + except WebsocketException: + errors["base"] = "cannot_connect" + except RequestLimitReached: + errors["base"] = "limit_reached" + except AlreadyConnected: + errors["base"] = "already_connected" + except InvalidApiToken: + errors["base"] = "invalid_token" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + + self.entry = await self.async_set_unique_id(email) + self.input = {CONF_API_TOKEN: api_token} + + if self.entry: + await self.update_entry() + return self.async_abort(reason="reauth_successful") + self._abort_if_unique_id_configured() + + if user_input.get("add_card"): + return await self.async_step_card() + + self.input[CARD] = DEFAULT_CARD + return self.async_create_entry( + title=user_input[CONF_API_TOKEN][:5], data=self.input + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_card( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the card step.""" + errors = {} + api_token = self.input[CONF_API_TOKEN] + try: + cards = await self.client.get_charge_cards() + except WebsocketException: + errors["base"] = "cannot_connect" + except NoCardsFound: + errors["base"] = "no_cards_found" + except RequestLimitReached: + errors["base"] = "limit_reached" + except AlreadyConnected: + errors["base"] = "already_connected" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + card_names = [card[CONF_NAME] for card in cards] + card_schema = vol.Schema({vol.Required(CARD): vol.In(card_names)}) + + def check_card(card: dict) -> bool: + assert user_input is not None + return bool(card[CONF_NAME] == user_input[CARD]) + + if user_input is not None: + + selected_card = list(filter(check_card, cards))[0] + + self.input[CARD] = selected_card["uid"] + return self.async_create_entry(title=api_token[:5], data=self.input) + + return self.async_show_form(step_id=CARD, data_schema=card_schema) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle a reauthorization flow request.""" + return await self.async_step_user() + + async def update_entry(self) -> None: + """Update the config entry.""" + assert self.entry + self.hass.config_entries.async_update_entry( + self.entry, data=self.input, title=self.input[CONF_API_TOKEN][:5] + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) diff --git a/custom_components/blue_current/const.py b/custom_components/blue_current/const.py new file mode 100755 index 0000000..34500ee --- /dev/null +++ b/custom_components/blue_current/const.py @@ -0,0 +1,11 @@ +"""Constants for the Blue Current integration.""" + +import logging + +DOMAIN = "blue_current" + +LOGGER = logging.getLogger(__package__) + +EVSE_ID = "evse_id" +CARD = "card" +MODEL_TYPE = "model_type" diff --git a/custom_components/blue_current/device_condition.py b/custom_components/blue_current/device_condition.py new file mode 100755 index 0000000..4b6384a --- /dev/null +++ b/custom_components/blue_current/device_condition.py @@ -0,0 +1,106 @@ +"""Provide the device conditions for BlueCurrent.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import condition, config_validation as cv, device_registry +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN + +ACTIVITY_TYPES = { + "available", + "charging", + "unavailable", + "error", + "offline", +} +VEHICLE_STATUS_TYPES = { + "standby", + "vehicle_detected", + "ready", + "no_power", + "vehicle_error", +} + +ACTIVITY_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTIVITY_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_id, + } +) + +VEHICLE_STATUS_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(VEHICLE_STATUS_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_id, + } +) + +CONDITION_SCHEMA = vol.Any(ACTIVITY_CONDITION_SCHEMA, VEHICLE_STATUS_CONDITION_SCHEMA) + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device conditions for BlueCurrent devices.""" + conditions = [] + registry = device_registry.async_get(hass) + device = registry.async_get(device_id) + + assert device is not None + evse_id = list(device.identifiers)[0][1] + + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + conditions.extend( + [ + { + **base_condition, + CONF_TYPE: t, + CONF_ENTITY_ID: f"sensor.activity_{evse_id}", + } + for t in ACTIVITY_TYPES + ] + ) + + conditions.extend( + [ + { + **base_condition, + CONF_TYPE: t, + CONF_ENTITY_ID: f"sensor.vehicle_status_{evse_id}", + } + for t in VEHICLE_STATUS_TYPES + ] + ) + return conditions + + +@callback +def async_condition_from_config( + hass: HomeAssistant, config: ConfigType +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + state = config[CONF_TYPE] + + @callback + def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if an entity is a certain state.""" + return condition.state(hass, config[ATTR_ENTITY_ID], state) + + return test_is_state diff --git a/custom_components/blue_current/device_trigger.py b/custom_components/blue_current/device_trigger.py new file mode 100755 index 0000000..c970f53 --- /dev/null +++ b/custom_components/blue_current/device_trigger.py @@ -0,0 +1,113 @@ +"""Provides device triggers for BlueCurrent.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import state +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + +ACTIVITY_TYPES = { + "available", + "charging", + "unavailable", + "error", + "offline", +} +VEHICLE_STATUS_TYPES = { + "standby", + "vehicle_detected", + "ready", + "no_power", + "vehicle_error", +} + +ACTIVITY_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTIVITY_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_id, + } +) + +VEHICLE_STATUS_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(VEHICLE_STATUS_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_id, + } +) + +TRIGGER_SCHEMA = vol.Any(ACTIVITY_TRIGGER_SCHEMA, VEHICLE_STATUS_TRIGGER_SCHEMA) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for BlueCurrent devices.""" + triggers = [] + registry = device_registry.async_get(hass) + device = registry.async_get(device_id) + + assert device is not None + evse_id = list(device.identifiers)[0][1] + + base_trigger = { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: t, + CONF_ENTITY_ID: f"sensor.activity_{evse_id}", + } + for t in ACTIVITY_TYPES + ] + ) + + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: t, + CONF_ENTITY_ID: f"sensor.vehicle_status_{evse_id}", + } + for t in VEHICLE_STATUS_TYPES + ] + ) + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + state_config = { + state.CONF_PLATFORM: "state", + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_TO: config[CONF_TYPE], + } + + state_config = await state.async_validate_trigger_config(hass, state_config) + return await state.async_attach_trigger( + hass, state_config, action, trigger_info, platform_type="device" + ) diff --git a/custom_components/blue_current/entity.py b/custom_components/blue_current/entity.py new file mode 100755 index 0000000..beb9011 --- /dev/null +++ b/custom_components/blue_current/entity.py @@ -0,0 +1,45 @@ +"""Enitiy r.""" +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from . import Connector +from .const import DOMAIN, MODEL_TYPE + + +class BlueCurrentEntity(Entity): + """Define a base charge point entity.""" + + def __init__(self, connector: Connector, evse_id: str) -> None: + """Initialize the entity.""" + self.connector: Connector = connector + + self.evse_id = evse_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, evse_id)}, + name=evse_id, + manufacturer="Blue Current", + model=connector.charge_points[evse_id][MODEL_TYPE], + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"{DOMAIN}_value_update_{self.evse_id}", update + ) + ) + + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError diff --git a/custom_components/blue_current/manifest.json b/custom_components/blue_current/manifest.json new file mode 100755 index 0000000..f43c92c --- /dev/null +++ b/custom_components/blue_current/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "blue_current", + "name": "Blue Current", + "codeowners": ["@Floris272", "@gleeuwen"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/blue_current", + "iot_class": "cloud_push", + "requirements": ["bluecurrent-api==1.0.2"] +} diff --git a/custom_components/blue_current/sensor.py b/custom_components/blue_current/sensor.py new file mode 100755 index 0000000..69865ce --- /dev/null +++ b/custom_components/blue_current/sensor.py @@ -0,0 +1,342 @@ +"""Support for Blue Current sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN +from .entity import BlueCurrentEntity + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + +SENSORS = ( + SensorEntityDescription( + key="actual_v1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + name="Voltage Phase 1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="actual_v2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + name="Voltage Phase 2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="actual_v3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + name="Voltage Phase 3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="avg_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + name="Average Voltage", + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Current Phase 1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Current Phase 2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Current Phase 3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Average Current", + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="total_kw", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + name="Total kW", + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="actual_kwh", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + name="Energy Usage", + state_class=SensorStateClass.TOTAL_INCREASING, + has_entity_name=True, + ), + SensorEntityDescription( + key="start_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + name="Started On", + has_entity_name=True, + ), + SensorEntityDescription( + key="stop_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + name="Stopped On", + has_entity_name=True, + ), + SensorEntityDescription( + key="offline_since", + device_class=SensorDeviceClass.TIMESTAMP, + name="Offline Since", + has_entity_name=True, + ), + SensorEntityDescription( + key="total_cost", + native_unit_of_measurement="EUR", + device_class=SensorDeviceClass.MONETARY, + name="Total Cost", + has_entity_name=True, + ), + SensorEntityDescription( + key="vehicle_status", + name="Vehicle Status", + icon="mdi:car", + device_class=SensorDeviceClass.ENUM, + has_entity_name=True, + options=["standby", "vehicle_detected", "ready", "no_power", "vehicle_error"], + translation_key="vehicle_status", + ), + SensorEntityDescription( + key="activity", + name="Activity", + icon="mdi:ev-station", + device_class=SensorDeviceClass.ENUM, + has_entity_name=True, + options=["available", "charging", "unavailable", "error", "offline"], + translation_key="activity", + ), + SensorEntityDescription( + key="max_usage", + name="Max Usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="smartcharging_max_usage", + name="Smart Charging Max Usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="max_offline", + name="Offline Max Usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="current_left", + name="Remaining current", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), +) + +GRID_SENSORS = ( + SensorEntityDescription( + key="grid_actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Grid Current Phase 1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="grid_actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Grid Current Phase 2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="grid_actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Grid Current Phase 3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="grid_avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Average Grid Current", + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), + SensorEntityDescription( + key="grid_max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + name="Max Grid Current", + state_class=SensorStateClass.MEASUREMENT, + has_entity_name=True, + ), +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current sensors.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + sensor_list: list[SensorEntity] = [] + for evse_id in connector.charge_points: + for sensor in SENSORS: + sensor_list.append(ChargePointSensor(connector, sensor, evse_id)) + + for grid_sensor in GRID_SENSORS: + sensor_list.append(GridSensor(connector, grid_sensor)) + + async_add_entities(sensor_list) + + +class ChargePointSensor(BlueCurrentEntity, SensorEntity): + """Define a charge point sensor.""" + + _attr_should_poll = False + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + evse_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, evse_id) + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = f"{sensor.key}_{evse_id}" + + @callback + def update_from_latest_data(self) -> None: + """Update the sensor from the latest data.""" + + new_value = self.connector.charge_points[self.evse_id].get(self.key) + + if new_value is not None: + if self.key in TIMESTAMP_KEYS and not ( + self._attr_native_value is None or self._attr_native_value < new_value + ): + return + self._attr_available = True + self._attr_native_value = new_value + + elif self.key not in TIMESTAMP_KEYS: + self._attr_available = False + + +class GridSensor(SensorEntity): + """Define a grid sensor.""" + + _attr_should_poll = False + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = sensor.key + self.connector = connector + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove( + async_dispatcher_connect(self.hass, f"{DOMAIN}_grid_update", update) + ) + + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the grid sensor from the latest data.""" + + new_value = self.connector.grid.get(self.key) + + if new_value is not None: + self._attr_available = True + self._attr_native_value = new_value + + else: + self._attr_available = False diff --git a/custom_components/blue_current/services.yaml b/custom_components/blue_current/services.yaml new file mode 100755 index 0000000..3facde3 --- /dev/null +++ b/custom_components/blue_current/services.yaml @@ -0,0 +1,35 @@ +reset: + name: Reset chargepoint + description: resets the chargepoint. + fields: + evse_id: + name: Id + description: chargepoint id + required: true + +reboot: + name: Reboot chargepoint + description: reboots the chargepoint. + fields: + evse_id: + name: Id + description: chargepoint id + required: true + +start_session: + name: start a charge session + description: starts a charge session. + fields: + evse_id: + name: Id + description: chargepoint id + required: true + +stop_session: + name: stop the charge session + description: stops the charge session. + fields: + id: + name: Id + description: chargepoint id + required: true diff --git a/custom_components/blue_current/strings.json b/custom_components/blue_current/strings.json new file mode 100755 index 0000000..42b57e9 --- /dev/null +++ b/custom_components/blue_current/strings.json @@ -0,0 +1,80 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "add_card": "Do you want to use your own charge card?" + }, + "description": "Enter your Blue Current api token", + "title": "Authentication" + }, + "card": { + "data": { + "card": "Card" + }, + "description": "Enter the name of your charge card", + "title": "Card" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "limit_reached": "Request limit reached", + "invalid_token": "Invalid token", + "no_cards_found": "No charge cards found", + "already_connected": "IP is already connected", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "activity": { + "state": { + "available": "Available", + "charging": "Charging", + "unavailable": "Unavailable", + "error": "Error", + "offline": "Offline" + } + }, + "vehicle_status": { + "state": { + "standby": "Standby", + "vehicle_detected": "Detected", + "ready": "Ready", + "no_power": "No power", + "vehicle_error": "Error" + } + } + } + }, + "device_automation": { + "condition_type": { + "available": "charge point is available", + "charging": "charge point is charging", + "error": "charge point has error", + "no_power": "vehicle has no power", + "offline": "charge point is offline", + "ready": "vehicle is ready", + "standby": "vehicle is in standby", + "unavailable": "charge point is unavailable", + "vehicle_detected": "vehicle is detected", + "vehicle_error": "vehicle has error" + }, + "trigger_type": { + "available": "charge point becomes available", + "charging": "charge point started charging", + "error": "charge point has error", + "no_power": "vehicle has no power", + "offline": "charge point is offline", + "ready": "vehicle is ready", + "standby": "vehicle is in standby", + "unavailable": "charge point becomes unavailable", + "vehicle_detected": "vehicle is detected", + "vehicle_error": "vehicle has error" + } + } +} diff --git a/custom_components/blue_current/switch.py b/custom_components/blue_current/switch.py new file mode 100755 index 0000000..57da31f --- /dev/null +++ b/custom_components/blue_current/switch.py @@ -0,0 +1,139 @@ +"""Support for Blue Current switches.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from bluecurrent_api import Client + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN, LOGGER +from .entity import BlueCurrentEntity + + +@dataclass +class BlueCurrentSwitchEntityDescriptionMixin: + """Mixin for the called functions.""" + + function: Callable[[Client, str, bool], Any] + + +@dataclass +class BlueCurrentSwitchEntityDescription( + SwitchEntityDescription, BlueCurrentSwitchEntityDescriptionMixin +): + """Describes Blue Current switch entity.""" + + +SWITCHES: tuple[BlueCurrentSwitchEntityDescription, ...] = ( + BlueCurrentSwitchEntityDescription( + key="plug_and_charge", + device_class=SwitchDeviceClass.SWITCH, + name="Plug and charge", + icon="mdi:ev-plug-type2", + function=lambda client, evse_id, value: client.set_plug_and_charge( + evse_id, value + ), + has_entity_name=True, + ), + BlueCurrentSwitchEntityDescription( + key="public_charging", + device_class=SwitchDeviceClass.SWITCH, + name="Public charging", + icon="mdi:account-group", + function=lambda client, evse_id, value: client.set_public_charging( + evse_id, value + ), + has_entity_name=True, + ), + BlueCurrentSwitchEntityDescription( + key="operative", + device_class=SwitchDeviceClass.SWITCH, + name="Operative", + icon="mdi:power", + function=lambda client, evse_id, value: client.set_operative(evse_id, value), + has_entity_name=True, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current switches.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + + switch_list = [] + for evse_id in connector.charge_points: + for switch in SWITCHES: + switch_list.append( + ChargePointSwitch( + connector, + evse_id, + switch, + ) + ) + + async_add_entities(switch_list) + + +class ChargePointSwitch(BlueCurrentEntity, SwitchEntity): + """Base charge point switch.""" + + _attr_should_poll = False + + entity_description: BlueCurrentSwitchEntityDescription + + def __init__( + self, + connector: Connector, + evse_id: str, + switch: BlueCurrentSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(connector, evse_id) + + self.key = switch.key + self.entity_description = switch + self._attr_unique_id = f"{switch.key}_{evse_id}" + + async def call_function(self, value: bool) -> None: + """Call the function to set setting.""" + try: + await self.entity_description.function( + self.connector.client, self.evse_id, value + ) + except ConnectionError: + LOGGER.error("No connection") + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(True) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self.call_function(False) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the switch.""" + new_value = self.connector.charge_points[self.evse_id].get(self.key) + if new_value is not None: + self._attr_available = True + self._attr_is_on = new_value + else: + self._attr_available = False diff --git a/custom_components/blue_current/translations/en.json b/custom_components/blue_current/translations/en.json new file mode 100755 index 0000000..5f0b687 --- /dev/null +++ b/custom_components/blue_current/translations/en.json @@ -0,0 +1,80 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "already_connected": "IP is already connected", + "cannot_connect": "Failed to connect", + "invalid_token": "Invalid token", + "limit_reached": "Request limit reached", + "no_cards_found": "No charge cards found", + "unknown": "Unexpected error" + }, + "step": { + "card": { + "data": { + "card": "Card" + }, + "description": "Enter the name of your charge card", + "title": "Card" + }, + "user": { + "data": { + "add_card": "Do you want to use your own charge card?", + "api_token": "API Token" + }, + "description": "Enter your Blue Current api token", + "title": "Authentication" + } + } + }, + "device_automation": { + "condition_type": { + "available": "charge point is available", + "charging": "charge point is charging", + "error": "charge point has error", + "no_power": "vehicle has no power", + "offline": "charge point is offline", + "ready": "vehicle is ready", + "standby": "vehicle is in standby", + "unavailable": "charge point is unavailable", + "vehicle_detected": "vehicle is detected", + "vehicle_error": "vehicle has error" + }, + "trigger_type": { + "available": "charge point becomes available", + "charging": "charge point started charging", + "error": "charge point has error", + "no_power": "vehicle has no power", + "offline": "charge point is offline", + "ready": "vehicle is ready", + "standby": "vehicle is in standby", + "unavailable": "charge point becomes unavailable", + "vehicle_detected": "vehicle is detected", + "vehicle_error": "vehicle has error" + } + }, + "entity": { + "sensor": { + "activity": { + "state": { + "available": "Available", + "charging": "Charging", + "error": "Error", + "offline": "Offline", + "unavailable": "Unavailable" + } + }, + "vehicle_status": { + "state": { + "no_power": "No power", + "ready": "Ready", + "standby": "Standby", + "vehicle_detected": "Detected", + "vehicle_error": "Error" + } + } + } + } +} \ No newline at end of file From 1ed372016f2ec8ca76e66a6aacc841c1e5c7bb15 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 21 Mar 2023 12:33:21 +0000 Subject: [PATCH 3/7] add tests and update README --- README.md | 76 +++-- custom_components/blue_current/manifest.json | 3 +- hacs.json | 2 +- tests/__init__.py | 51 ++++ tests/test_button.py | 50 ++++ tests/test_config_flow.py | 281 ++++++++++++++++++ tests/test_device_condition.py | 289 ++++++++++++++++++ tests/test_device_trigger.py | 259 ++++++++++++++++ tests/test_init.py | 297 +++++++++++++++++++ tests/test_sensor.py | 180 +++++++++++ tests/test_switch.py | 89 ++++++ 11 files changed, 1557 insertions(+), 20 deletions(-) create mode 100755 tests/__init__.py create mode 100755 tests/test_button.py create mode 100755 tests/test_config_flow.py create mode 100755 tests/test_device_condition.py create mode 100755 tests/test_device_trigger.py create mode 100755 tests/test_init.py create mode 100755 tests/test_sensor.py create mode 100755 tests/test_switch.py diff --git a/README.md b/README.md index ed54d51..9ead7db 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,68 @@ # Integration Blueprint -[![hacs][hacsbadge]][hacs] +[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg?style=for-the-badge)](https://github.com/hacs/integration) -**This integration will set up the following platforms.** +The Blue Current integration allows you to connect to your blue current account to Home Assistant and monitor your charge point(s). + + +## Prerequisites +1. Log in to [my.bluecurrent](https://my.bluecurrent.nl/). +2. Goto settings and enable developer mode. +3. Generate an API token. -Platform | Description --- | -- -`sensor` | Show data from a charge point. -`switch` | Switch something `True` or `False`. -`button` | Run an action. ## 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 `blue_current`. -1. Download _all_ the files from the `custom_components/blue_current/` 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" +- [HACS](https://hacs.xyz/): add url https://github.com/bluecurrent/ha-bluecurrent as custom repository (HACS > Integration > option: Custom Repositories) +- Restart Home Assistant. +- Add 'Blue current' integration via HA Settings > 'Devices and Services' > 'Integrations'. +- Provide your api key. ## Configuration is done in the UI - -*** -[hacs]: https://github.com/hacs/integration -[hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge \ No newline at end of file +# Platforms + +## Sensor +The Blue Current integration provides the following sensors: +### Charge point sensors +- Activity +- Average current +- Average voltage +- Energy usage in kWh +- Max usage in Amps + - The max amps the charge point can use. +- Offline since +- Started on +- Stopped on +- Total cost in EUR +- Total kW (estimate) +- Vehicle status +The following sensors are created as well, but disabled by default: +- Current phase 1-3 +- offline max usage +- remaining current +- smart charging max usage +- Voltage phase 1-3 +### Grid sensors +- Grid average current +- Grid max current +The following sensors are created as well, but disabled by default: +- Grid current phase 1-3 + +## Switch +The Blue Current integration provides the following switches: + +- Operative + - Enables or disables a charge point. +- Plug and charge + - Allows you to start a session without having to scan a card. +- Public charging + - Allows other people to use your charge point. + +## Button +The Blue Current integration provides the following buttons: + +- Start session +- Stop session +- Reset +- Reboot \ No newline at end of file diff --git a/custom_components/blue_current/manifest.json b/custom_components/blue_current/manifest.json index f43c92c..8864bd2 100755 --- a/custom_components/blue_current/manifest.json +++ b/custom_components/blue_current/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", - "requirements": ["bluecurrent-api==1.0.2"] + "requirements": ["bluecurrent-api==1.0.2"], + "version": 1 } diff --git a/hacs.json b/hacs.json index d57b88c..e797b80 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Blue Current", "hide_default_branch": true, - "homeassistant": "2022.3.0", + "homeassistant": "2022.2.0", "render_readme": true, "country": ["NL"] } \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100755 index 0000000..f1b3683 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,51 @@ +"""Tests for the Blue Current integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bluecurrent_api import Client + +from homeassistant.components.blue_current import DOMAIN, Connector +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, platform, data: dict, grid=None +) -> MockConfigEntry: + """Set up the Blue Current integration in Home Assistant.""" + + if grid is None: + grid = {} + + def init( + self: Connector, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Mock grid and charge_points.""" + + self.config = config + self.hass = hass + self.client = client + self.charge_points = data + self.grid = grid + + with patch( + "homeassistant.components.blue_current.PLATFORMS", [platform] + ), patch.object(Connector, "__init__", init), patch( + "homeassistant.components.blue_current.Client", autospec=True + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_value_update_101") + return config_entry diff --git a/tests/test_button.py b/tests/test_button.py new file mode 100755 index 0000000..7cd6758 --- /dev/null +++ b/tests/test_button.py @@ -0,0 +1,50 @@ +"""The tests for Blue Current buttons.""" + +from datetime import datetime +from unittest.mock import patch + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import init_integration + +data = { + "101": { + "model_type": "hidden", + "evse_id": "101", + } +} + +buttons = ("start_session", "stop_session", "reset", "reboot") + + +async def test_buttons(hass: HomeAssistant): + """Test the underlying buttons.""" + await init_integration(hass, "button", data) + + entity_registry = er.async_get(hass) + + for button in buttons: + state = hass.states.get(f"button.101_{button}") + assert state is not None + assert state.state == "unknown" + entry = entity_registry.async_get(f"button.101_{button}") + assert entry and entry.unique_id == f"{button}_101" + + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + assert isinstance(now, datetime) + with patch("homeassistant.util.dt.utcnow", return_value=now): + await hass.services.async_call( + "button", + "press", + {"entity_id": f"button.101_{button}"}, + blocking=True, + ) + + state = hass.states.get(f"button.101_{button}") + assert state + assert state.state == now.isoformat() + + created_buttons = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(buttons) == len(created_buttons) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100755 index 0000000..e91a5d1 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,281 @@ +"""Test the Blue Current config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.blue_current.config_flow import ( + AlreadyConnected, + InvalidApiToken, + NoCardsFound, + RequestLimitReached, + WebsocketException, +) +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test if the form is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + +async def test_default_card(hass: HomeAssistant) -> None: + """Test if the default card is set.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "123" + assert result2["data"] == {"api_token": "123", "card": "BCU_APP"} + + +async def test_user_card(hass: HomeAssistant) -> None: + """Test if the user can set a custom card.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True,), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "bluecurrent_api.Client.get_charge_cards", + return_value=[{"name": "card 1", "uid": 1}, {"name": "card 2", "uid": 2}], + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_token": "123", "add_card": True}, + ) + await hass.async_block_till_done() + + with patch( + "bluecurrent_api.Client.get_charge_cards", + return_value=[{"name": "card 1", "uid": 1}, {"name": "card 2", "uid": 2}], + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {"card": "card 1"}, + ) + await hass.async_block_till_done() + + assert result3["title"] == "123" + assert result3["data"] == {"api_token": "123", "card": 1} + + +async def test_form_invalid_token(hass: HomeAssistant) -> None: + """Test if an invalid api token is handled.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=InvalidApiToken, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"] == {"base": "invalid_token"} + + +async def test_form_limit_reached(hass: HomeAssistant) -> None: + """Test if an limit reached error is handled.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=RequestLimitReached, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"] == {"base": "limit_reached"} + + +async def test_form_already_connected(hass: HomeAssistant) -> None: + """Test if an already connected error is handled.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=AlreadyConnected, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"] == {"base": "already_connected"} + + +async def test_form_exception(hass: HomeAssistant) -> None: + """Test if an exception is handled.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test if a connection error is handled.""" + + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=WebsocketException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_no_cards_found(hass: HomeAssistant) -> None: + """Test if a no cards error is handled.""" + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True,), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "bluecurrent_api.Client.get_charge_cards", + side_effect=NoCardsFound, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123", "add_card": True}, + ) + + assert result["errors"] == {"base": "no_cards_found"} + + +async def test_form_cannot_connect_card(hass: HomeAssistant) -> None: + """Test if a connection error on get_charge_cards is handled.""" + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True,), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "bluecurrent_api.Client.get_charge_cards", + side_effect=WebsocketException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123", "add_card": True}, + ) + + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_limit_reached_card(hass: HomeAssistant) -> None: + """Test if an limit reached error is handled.""" + with patch("bluecurrent_api.Client.validate_api_token", return_value=True,), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "bluecurrent_api.Client.get_charge_cards", + side_effect=RequestLimitReached, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123", "add_card": True}, + ) + assert result["errors"] == {"base": "limit_reached"} + + +async def test_form_already_connected_card(hass: HomeAssistant) -> None: + """Test if an already connected error is handled.""" + with patch("bluecurrent_api.Client.validate_api_token", return_value=True,), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "bluecurrent_api.Client.get_charge_cards", + side_effect=AlreadyConnected, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123", "add_card": True}, + ) + assert result["errors"] == {"base": "already_connected"} + + +async def test_form_exception_card(hass: HomeAssistant) -> None: + """Test if an exception is handled.""" + with patch("bluecurrent_api.Client.validate_api_token", return_value=True,), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "bluecurrent_api.Client.get_charge_cards", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123", "add_card": True}, + ) + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_reauth(hass: HomeAssistant): + """Test reauth step.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + return_value=True, + ), patch("bluecurrent_api.Client.get_email", return_value="test@email.com"): + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="test@email.com", + data={"api_token": "123"}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data={"api_token": "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"api_token": "1234567890"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data.copy() == {"api_token": "1234567890"} diff --git a/tests/test_device_condition.py b/tests/test_device_condition.py new file mode 100755 index 0000000..0d28ccf --- /dev/null +++ b/tests/test_device_condition.py @@ -0,0 +1,289 @@ +"""The tests for Blue Current device conditions.""" +from __future__ import annotations + +import pytest + +from homeassistant.components import automation +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass: HomeAssistant) -> device_registry.DeviceRegistry: + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass: HomeAssistant) -> entity_registry.EntityRegistry: + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_conditions( + hass: HomeAssistant, + device_reg: device_registry.DeviceRegistry, + entity_reg: entity_registry.EntityRegistry, +) -> None: + """Test we get the expected conditions from a blue current.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + evse_id = "101" + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, evse_id)}, + ) + entity_reg.async_get_or_create(DOMAIN, "activity", "101", device_id=device_entry.id) + entity_reg.async_get_or_create( + DOMAIN, "vehicle_status", "101", device_id=device_entry.id + ) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "available", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "charging", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "unavailable", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "error", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "offline", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "standby", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "vehicle_detected", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "ready", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "no_power", + "metadata": {}, + }, + { + "condition": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "vehicle_error", + "metadata": {}, + }, + ] + conditions = await async_get_device_automations( + hass, DeviceAutomationType.CONDITION, device_entry.id + ) + assert_lists_same(conditions, expected_conditions) + + +async def test_if_actvivity_state( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for activity condition.""" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": event_type}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "sensor.activity_101", + "type": activity_type, + } + ], + "action": { + "service": "test.automation", + "data_template": {"some": f"{activity_type} - {event_type}"}, + }, + } + for activity_type, event_type in ( + ("available", "test_event_1"), + ("charging", "test_event_2"), + ("unavailable", "test_event_3"), + ("error", "test_event_4"), + ("offline", "test_event_5"), + ) + ] + }, + ) + + hass.states.async_set("sensor.activity_101", "available") + for i in range(1, 6): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "available - test_event_1" + + hass.states.async_set("sensor.activity_101", "charging") + for i in range(1, 6): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "charging - test_event_2" + + hass.states.async_set("sensor.activity_101", "unavailable") + for i in range(1, 6): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "unavailable - test_event_3" + + hass.states.async_set("sensor.activity_101", "error") + for i in range(1, 6): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "error - test_event_4" + + hass.states.async_set("sensor.activity_101", "offline") + for i in range(1, 6): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "offline - test_event_5" + + +async def test_if_vehicle_status_state( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test for vehicle_status condition.""" + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": event_type}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "sensor.vehicle_status_101", + "type": activity_type, + } + ], + "action": { + "service": "test.automation", + "data_template": {"some": f"{activity_type} - {event_type}"}, + }, + } + for activity_type, event_type in ( + ("standby", "test_event_1"), + ("vehicle_detected", "test_event_2"), + ("ready", "test_event_3"), + ("no_power", "test_event_4"), + ("vehicle_error", "test_event_5"), + ) + ] + }, + ) + + hass.states.async_set("sensor.vehicle_status_101", "standby") + for i in range(1, 7): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "standby - test_event_1" + + hass.states.async_set("sensor.vehicle_status_101", "vehicle_detected") + for i in range(1, 7): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "vehicle_detected - test_event_2" + + hass.states.async_set("sensor.vehicle_status_101", "ready") + for i in range(1, 7): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "ready - test_event_3" + + hass.states.async_set("sensor.vehicle_status_101", "no_power") + for i in range(1, 7): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "no_power - test_event_4" + + hass.states.async_set("sensor.vehicle_status_101", "vehicle_error") + for i in range(1, 7): + hass.bus.async_fire(f"test_event_{i}") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "vehicle_error - test_event_5" diff --git a/tests/test_device_trigger.py b/tests/test_device_trigger.py new file mode 100755 index 0000000..75bf9b3 --- /dev/null +++ b/tests/test_device_trigger.py @@ -0,0 +1,259 @@ +"""The tests for Blue Current device triggers.""" +import pytest + +from homeassistant.components import automation +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, + device_reg: device_registry.DeviceRegistry, + entity_reg: entity_registry.EntityRegistry, +): + """Test we get the expected triggers from a bluecurrent.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + evse_id = "101" + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, evse_id)}, + ) + entity_reg.async_get_or_create(DOMAIN, "activity", "101", device_id=device_entry.id) + entity_reg.async_get_or_create( + DOMAIN, "vehicle_status", "101", device_id=device_entry.id + ) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "available", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "charging", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "unavailable", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "error", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.activity_101", + "type": "offline", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "standby", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "vehicle_detected", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "ready", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "no_power", + "metadata": {}, + }, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "entity_id": "sensor.vehicle_status_101", + "type": "vehicle_error", + "metadata": {}, + }, + ] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert_lists_same(triggers, expected_triggers) + + +async def test_if_activity_fires_on_state_change(hass: HomeAssistant, calls): + """Test for blue current activity trigger firing.""" + hass.states.async_set("sensor.activity_101", "unavailable") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "sensor.activity_101", + "type": activity_type, + }, + "action": { + "service": "test.automation", + "data_template": {"some": (activity_type)}, + }, + } + for activity_type in ( + "available", + "charging", + "unavailable", + "error", + "offline", + ) + ] + }, + ) + + hass.states.async_set("sensor.activity_101", "charging") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "charging" + + hass.states.async_set("sensor.activity_101", "available") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "available" + + hass.states.async_set("sensor.activity_101", "error") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "error" + + hass.states.async_set("sensor.activity_101", "offline") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "offline" + + hass.states.async_set("sensor.activity_101", "unavailable") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "unavailable" + + +async def test_if_vehicle_status_fires_on_state_change(hass: HomeAssistant, calls): + """Test for blue current vehicle_status trigger firing.""" + hass.states.async_set("sensor.vehicle_status_101", "vehicle_error") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "sensor.vehicle_status_101", + "type": vehicle_status_type, + }, + "action": { + "service": "test.automation", + "data_template": {"some": (vehicle_status_type)}, + }, + } + for vehicle_status_type in ( + "standby", + "vehicle_detected", + "ready", + "no_power", + "vehicle_error", + ) + ] + }, + ) + + hass.states.async_set("sensor.vehicle_status_101", "standby") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "standby" + + hass.states.async_set("sensor.vehicle_status_101", "vehicle_detected") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "vehicle_detected" + + hass.states.async_set("sensor.vehicle_status_101", "ready") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "ready" + + hass.states.async_set("sensor.vehicle_status_101", "no_power") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "no_power" + + hass.states.async_set("sensor.vehicle_status_101", "vehicle_error") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "vehicle_error" diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100755 index 0000000..a164aa0 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,297 @@ +"""Test Blue Current Init Component.""" + +from datetime import timedelta +from typing import Any +from unittest.mock import patch + +from bluecurrent_api.client import Client +from bluecurrent_api.exceptions import RequestLimitReached, WebsocketException +import pytest + +from homeassistant.components.blue_current import ( + DOMAIN, + Connector, + async_setup_entry, + set_entities_unavalible, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant): + """Test load and unload entry.""" + config_entry = await init_integration(hass, "sensor", {}) + assert config_entry.state == ConfigEntryState.LOADED + assert isinstance(hass.data[DOMAIN][config_entry.entry_id], Connector) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert hass.data[DOMAIN] == {} + + +async def test_config_not_ready(hass: HomeAssistant): + """Tests if ConfigEntryNotReady is raised when connect raises a WebsocketException.""" + with patch( + "bluecurrent_api.Client.connect", + side_effect=WebsocketException, + ), pytest.raises(ConfigEntryNotReady): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await async_setup_entry(hass, config_entry) + + +async def test_set_entities_unavalible(hass: HomeAssistant): + """Tests set_entities_unavailable.""" + + data = { + "101": { + "model_type": "hidden", + "evse_id": "101", + } + } + + charge_point = { + "actual_v1": 14, + "actual_v2": 18, + "actual_v3": 15, + "actual_p1": 19, + "actual_p2": 14, + "actual_p3": 15, + } + + entity_ids = [ + "voltage_phase_1", + "voltage_phase_2", + "voltage_phase_3", + "current_phase_1", + "current_phase_2", + "current_phase_3", + ] + + await init_integration(hass, "sensor", data, charge_point) + + set_entities_unavalible(hass, "uuid") + + for entity_id in entity_ids: + state = hass.states.get(f"sensor.101_{entity_id}") + assert state + assert state.state == "unavailable" + + +async def test_on_data(hass: HomeAssistant): + """Test on_data.""" + + await init_integration(hass, "sensor", {}) + + with patch( + "homeassistant.components.blue_current.async_dispatcher_send" + ) as test_async_dispatcher_send: + + connector: Connector = hass.data[DOMAIN]["uuid"] + + # test CHARGE_POINTS + data = { + "object": "CHARGE_POINTS", + "data": [{"evse_id": "101", "model_type": "hidden"}], + } + await connector.on_data(data) + assert connector.charge_points == {"101": {"model_type": "hidden"}} + + # test CH_STATUS + data2 = { + "object": "CH_STATUS", + "data": { + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + "evse_id": "101", + }, + } + await connector.on_data(data2) + assert connector.charge_points == { + "101": { + "model_type": "hidden", + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "operative": True, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + } + } + + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test GRID_STATUS + data3 = { + "object": "GRID_STATUS", + "data": { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + }, + } + await connector.on_data(data3) + assert connector.grid == { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + } + test_async_dispatcher_send.assert_called_with(hass, "blue_current_grid_update") + + # reset charge_point + connector.charge_points["101"] = {} + + # test CH_SETTINGS + data4: dict[str, Any] = { + "object": "CH_SETTINGS", + "data": { + "plug_and_charge": False, + "public_charging": False, + "evse_id": "101", + }, + } + await connector.on_data(data4) + assert connector.charge_points == { + "101": { + "plug_and_charge": False, + "public_charging": False, + } + } + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test PUBLIC_CHARGING + data5: dict[str, Any] = { + "object": "PUBLIC_CHARGING", + "result": True, + "evse_id": "101", + } + await connector.on_data(data5) + assert connector.charge_points == { + "101": { + "plug_and_charge": False, + "public_charging": True, + } + } + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test PLUG_AND_CHARGE + data7: dict[str, Any] = { + "object": "PLUG_AND_CHARGE", + "result": True, + "evse_id": "101", + } + await connector.on_data(data7) + assert connector.charge_points == { + "101": { + "plug_and_charge": True, + "public_charging": True, + } + } + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test SOFT_RESET + data7 = {"object": "STATUS_SOFT_RESET", "success": True} + await connector.on_data(data7) + + # test REBOOT + data8 = {"object": "STATUS_REBOOT", "success": False} + await connector.on_data(data8) + + +async def test_start_loop(hass: HomeAssistant): + """Tests start_loop.""" + + with patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + + with patch( + "bluecurrent_api.Client.start_loop", + side_effect=WebsocketException("unknown command"), + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + with patch( + "bluecurrent_api.Client.start_loop", side_effect=RequestLimitReached + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + +async def test_reconnect(hass: HomeAssistant): + """Tests reconnect.""" + + with patch("bluecurrent_api.Client.connect"), patch( + "bluecurrent_api.Client.connect", side_effect=WebsocketException + ), patch( + "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) + ), patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + await connector.reconnect() + + test_async_call_later.assert_called_with(hass, 20, connector.reconnect) + + with patch("bluecurrent_api.Client.connect", side_effect=RequestLimitReached): + await connector.reconnect() + test_async_call_later.assert_called_with( + hass, timedelta(hours=1), connector.reconnect + ) diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100755 index 0000000..7c3b7ba --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,180 @@ +"""The tests for Blue current sensors.""" +from datetime import datetime +from typing import Any + +from homeassistant.components.blue_current import Connector +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import init_integration + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + + +charge_point = { + "actual_v1": 14, + "actual_v2": 18, + "actual_v3": 15, + "actual_p1": 19, + "actual_p2": 14, + "actual_p3": 15, + "activity": "available", + "start_datetime": datetime.strptime("20211118 14:12:23+08:00", "%Y%m%d %H:%M:%S%z"), + "stop_datetime": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "offline_since": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "total_cost": 13.32, + "avg_current": 16, + "avg_voltage": 15.7, + "total_kw": 251.2, + "vehicle_status": "standby", + "actual_kwh": 11, + "max_usage": 10, + "max_offline": 7, + "smartcharging_max_usage": 6, + "current_left": 10, +} + +data: dict[str, Any] = { + "101": { + "model_type": "hidden", + "evse_id": "101", + **charge_point, + } +} + + +charge_point_entity_ids = { + "voltage_phase_1": "actual_v1", + "voltage_phase_2": "actual_v2", + "voltage_phase_3": "actual_v3", + "current_phase_1": "actual_p1", + "current_phase_2": "actual_p2", + "current_phase_3": "actual_p3", + "activity": "activity", + "started_on": "start_datetime", + "stopped_on": "stop_datetime", + "offline_since": "offline_since", + "total_cost": "total_cost", + "average_current": "avg_current", + "average_voltage": "avg_voltage", + "total_kw": "total_kw", + "vehicle_status": "vehicle_status", + "energy_usage": "actual_kwh", + "max_usage": "max_usage", + "offline_max_usage": "max_offline", + "smart_charging_max_usage": "smartcharging_max_usage", + "remaining_current": "current_left", +} + +grid = { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + "grid_max_current": 15, + "grid_avg_current": 13.7, +} + +grid_entity_ids = { + "grid_current_phase_1": "grid_actual_p1", + "grid_current_phase_2": "grid_actual_p2", + "grid_current_phase_3": "grid_actual_p3", + "max_grid_current": "grid_max_current", + "average_grid_current": "grid_avg_current", +} + + +async def test_sensors(hass: HomeAssistant): + """Test the underlying sensors.""" + await init_integration(hass, "sensor", data, grid) + + entity_registry = er.async_get(hass) + for entity_id, key in charge_point_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.101_{entity_id}") + assert entry + assert entry.unique_id == f"{key}_101" + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + + value = charge_point[key] + + if key in TIMESTAMP_KEYS: + assert datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") == value + else: + assert state.state == str(value) + + for entity_id, key in grid_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.{entity_id}") + assert entry + assert entry.unique_id == key + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.{entity_id}") + assert state is not None + assert state.state == str(grid[key]) + + sensors = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(charge_point.keys()) + len(grid.keys()) == len(sensors) + + +async def test_sensor_update(hass: HomeAssistant): + """Test if the sensors get updated when there is new data.""" + await init_integration(hass, "sensor", data, grid) + key = "avg_voltage" + entity_id = "average_voltage" + timestamp_key = "start_datetime" + timestamp_entity_id = "started_on" + grid_key = "grid_avg_current" + grid_entity_id = "average_grid_current" + + connector: Connector = hass.data["blue_current"]["uuid"] + + connector.charge_points = {"101": {key: 20, timestamp_key: None}} + connector.grid = {grid_key: 20} + async_dispatcher_send(hass, "blue_current_value_update_101") + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_grid_update") + await hass.async_block_till_done() + + # test data updated + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + assert state.state == str(20) + + # grid + state = hass.states.get(f"sensor.{grid_entity_id}") + assert state + assert state.state == str(20) + + # test unavailable + state = hass.states.get("sensor.101_energy_usage") + assert state + assert state.state == "unavailable" + + # test if timestamp keeps old value + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) + + # test if older timestamp is ignored + connector.charge_points = { + "101": { + timestamp_key: datetime.strptime( + "20211118 14:11:23+08:00", "%Y%m%d %H:%M:%S%z" + ) + } + } + async_dispatcher_send(hass, "blue_current_value_update_101") + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) diff --git a/tests/test_switch.py b/tests/test_switch.py new file mode 100755 index 0000000..7017060 --- /dev/null +++ b/tests/test_switch.py @@ -0,0 +1,89 @@ +"""The tests for Bluecurrent switches.""" +import asyncio +from typing import Any + +from homeassistant.components.blue_current import Connector +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import init_integration + +charge_point: dict[str, bool] = { + "plug_and_charge": True, + "public_charging": False, +} + +data: dict[str, Any] = { + "101": {"model_type": "hidden", "evse_id": "101", **charge_point} +} + + +async def test_switches(hass: HomeAssistant): + """Test the underlying switches.""" + + await init_integration(hass, "switch", data) + + entity_registry = er.async_get(hass) + for key, value in charge_point.items(): + state = hass.states.get(f"switch.101_{key}") + + if value: + check = "on" + else: + check = "off" + assert state and state.state == check + entry = entity_registry.async_get(f"switch.101_{key}") + assert entry and entry.unique_id == f"{key}_101" + + # operative + switches = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(charge_point.keys()) == len(switches) - 1 + + state = hass.states.get("switch.101_operative") + assert state and state.state == "unavailable" + entry = entity_registry.async_get("switch.101_operative") + assert entry and entry.unique_id == "operative_101" + + +async def test_toggle(hass: HomeAssistant): + """Test the on / off methods and if the switch gets updated.""" + + await init_integration(hass, "switch", data) + + state = hass.states.get("switch.101_public_charging") + + assert state and state.state == "off" + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.101_public_charging"}, + blocking=True, + ) + + connector: Connector = hass.data["blue_current"]["uuid"] + connector.charge_points = {"101": {"public_charging": True}} + async_dispatcher_send(hass, "blue_current_value_update_101") + + # wait + await asyncio.sleep(1) + + state = hass.states.get("switch.101_public_charging") + assert state and state.state == "on" + + await hass.services.async_call( + "switch", + "turn_off", + {"entity_id": "switch.101_public_charging"}, + blocking=True, + ) + + connector2: Connector = hass.data["blue_current"]["uuid"] + connector2.charge_points = {"101": {"public_charging": False}} + async_dispatcher_send(hass, "blue_current_value_update_101") + + # wait + await asyncio.sleep(1) + + state = hass.states.get("switch.101_public_charging") + assert state and state.state == "off" From 0ef80769546032a65996c4e30c928ab69e53e8fa Mon Sep 17 00:00:00 2001 From: Floris272 Date: Tue, 21 Mar 2023 14:21:22 +0000 Subject: [PATCH 4/7] fix tests + lint --- custom_components/__init__.py | 1 + custom_components/blue_current/__init__.py | 13 +++++------ custom_components/blue_current/button.py | 3 ++- custom_components/blue_current/config_flow.py | 13 ++++------- .../blue_current/device_condition.py | 19 +++++++--------- .../blue_current/device_trigger.py | 16 +++++--------- custom_components/blue_current/sensor.py | 18 +++++---------- custom_components/blue_current/switch.py | 8 ++----- requirements.test.txt | 4 ++++ requirements.txt | 4 ++-- scripts/test | 7 ++++++ tests/__init__.py | 9 ++++---- tests/conftest.py | 8 +++++++ tests/test_config_flow.py | 22 +++++++++---------- tests/test_device_condition.py | 14 ++++-------- tests/test_device_trigger.py | 14 ++++-------- tests/test_init.py | 22 ++++++++----------- tests/test_sensor.py | 3 ++- tests/test_switch.py | 3 ++- 19 files changed, 90 insertions(+), 111 deletions(-) create mode 100644 custom_components/__init__.py create mode 100644 requirements.test.txt create mode 100755 scripts/test create mode 100644 tests/conftest.py diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..b0327dd --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom Component for Home Assistant.""" diff --git a/custom_components/blue_current/__init__.py b/custom_components/blue_current/__init__.py index 1a5686c..a1157d2 100755 --- a/custom_components/blue_current/__init__.py +++ b/custom_components/blue_current/__init__.py @@ -6,15 +6,12 @@ from typing import Any from bluecurrent_api import Client -from bluecurrent_api.exceptions import ( - BlueCurrentException, - InvalidApiToken, - RequestLimitReached, - WebsocketException, -) - +from bluecurrent_api.exceptions import (BlueCurrentException, InvalidApiToken, + RequestLimitReached, + WebsocketException) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import (CONF_API_TOKEN, EVENT_HOMEASSISTANT_STOP, + Platform) from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry diff --git a/custom_components/blue_current/button.py b/custom_components/blue_current/button.py index 15f46ec..7c44f5c 100755 --- a/custom_components/blue_current/button.py +++ b/custom_components/blue_current/button.py @@ -1,7 +1,8 @@ """Support for Blue Current buttons.""" from __future__ import annotations -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import (ButtonEntity, + ButtonEntityDescription) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/custom_components/blue_current/config_flow.py b/custom_components/blue_current/config_flow.py index 256ee50..2892e65 100755 --- a/custom_components/blue_current/config_flow.py +++ b/custom_components/blue_current/config_flow.py @@ -4,16 +4,11 @@ from collections.abc import Mapping from typing import Any -from bluecurrent_api import Client -from bluecurrent_api.exceptions import ( - AlreadyConnected, - InvalidApiToken, - NoCardsFound, - RequestLimitReached, - WebsocketException, -) import voluptuous as vol - +from bluecurrent_api import Client +from bluecurrent_api.exceptions import (AlreadyConnected, InvalidApiToken, + NoCardsFound, RequestLimitReached, + WebsocketException) from homeassistant import config_entries from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.data_entry_flow import FlowResult diff --git a/custom_components/blue_current/device_condition.py b/custom_components/blue_current/device_condition.py index 4b6384a..4e027da 100755 --- a/custom_components/blue_current/device_condition.py +++ b/custom_components/blue_current/device_condition.py @@ -2,18 +2,15 @@ from __future__ import annotations import voluptuous as vol - -from homeassistant.const import ( - ATTR_ENTITY_ID, - CONF_CONDITION, - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_ENTITY_ID, - CONF_TYPE, -) +from homeassistant.const import (ATTR_ENTITY_ID, CONF_CONDITION, + CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, + CONF_TYPE) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import condition, config_validation as cv, device_registry -from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers import condition +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry +from homeassistant.helpers.config_validation import \ + DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN diff --git a/custom_components/blue_current/device_trigger.py b/custom_components/blue_current/device_trigger.py index c970f53..97cd8d7 100755 --- a/custom_components/blue_current/device_trigger.py +++ b/custom_components/blue_current/device_trigger.py @@ -4,18 +4,14 @@ from typing import Any import voluptuous as vol - -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation import \ + DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_ENTITY_ID, - CONF_PLATFORM, - CONF_TYPE, -) +from homeassistant.const import (CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, + CONF_PLATFORM, CONF_TYPE) from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import device_registry from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType diff --git a/custom_components/blue_current/sensor.py b/custom_components/blue_current/sensor.py index 69865ce..5669543 100755 --- a/custom_components/blue_current/sensor.py +++ b/custom_components/blue_current/sensor.py @@ -1,19 +1,13 @@ """Support for Blue Current sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) +from homeassistant.components.sensor import (SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, -) +from homeassistant.const import (UnitOfElectricCurrent, + UnitOfElectricPotential, UnitOfEnergy, + UnitOfPower) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/custom_components/blue_current/switch.py b/custom_components/blue_current/switch.py index 57da31f..4092bd3 100755 --- a/custom_components/blue_current/switch.py +++ b/custom_components/blue_current/switch.py @@ -6,12 +6,8 @@ from typing import Any from bluecurrent_api import Client - -from homeassistant.components.switch import ( - SwitchDeviceClass, - SwitchEntity, - SwitchEntityDescription, -) +from homeassistant.components.switch import (SwitchDeviceClass, SwitchEntity, + SwitchEntityDescription) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..01e33d1 --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,4 @@ +pytest +pytest-cov +pytest-asyncio +pytest-homeassistant-custom-component diff --git a/requirements.txt b/requirements.txt index 0c21984..184799f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ colorlog==6.7.0 -homeassistant==2022.2.0 +homeassistant==2023.3.5 pip>=8.0.3,<20.3 -ruff==0.0.257 +ruff==0.0.257 \ No newline at end of file diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..d4180b1 --- /dev/null +++ b/scripts/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +pytest --asyncio-mode=auto diff --git a/tests/__init__.py b/tests/__init__.py index f1b3683..7d028fe 100755 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,13 +4,12 @@ from unittest.mock import patch from bluecurrent_api import Client - -from homeassistant.components.blue_current import DOMAIN, Connector from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send +from pytest_homeassistant_custom_component.common import MockConfigEntry -from tests.common import MockConfigEntry +from custom_components.blue_current import DOMAIN, Connector async def init_integration( @@ -33,9 +32,9 @@ def init( self.grid = grid with patch( - "homeassistant.components.blue_current.PLATFORMS", [platform] + "custom_components.blue_current.PLATFORMS", [platform] ), patch.object(Connector, "__init__", init), patch( - "homeassistant.components.blue_current.Client", autospec=True + "custom_components.blue_current.Client", autospec=True ): config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fda3ef0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""Fixtures for testing.""" +import pytest + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + """Automatically enable loading custom integrations in all tests.""" + yield diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index e91a5d1..eb23cff 100755 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -2,20 +2,18 @@ from unittest.mock import patch from homeassistant import config_entries -from homeassistant.components.blue_current import DOMAIN -from homeassistant.components.blue_current.config_flow import ( - AlreadyConnected, - InvalidApiToken, - NoCardsFound, - RequestLimitReached, - WebsocketException, -) from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from pytest_homeassistant_custom_component.common import MockConfigEntry -from tests.common import MockConfigEntry +from custom_components.blue_current import DOMAIN +from custom_components.blue_current.config_flow import (AlreadyConnected, + InvalidApiToken, + NoCardsFound, + RequestLimitReached, + WebsocketException) async def test_form(hass: HomeAssistant) -> None: @@ -37,7 +35,7 @@ async def test_default_card(hass: HomeAssistant) -> None: with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( "bluecurrent_api.Client.get_email", return_value="test@email.com" ), patch( - "homeassistant.components.blue_current.async_setup_entry", + "custom_components.blue_current.async_setup_entry", return_value=True, ): result2 = await hass.config_entries.flow.async_configure( @@ -65,7 +63,7 @@ async def test_user_card(hass: HomeAssistant) -> None: "bluecurrent_api.Client.get_charge_cards", return_value=[{"name": "card 1", "uid": 1}, {"name": "card 2", "uid": 2}], ), patch( - "homeassistant.components.blue_current.async_setup_entry", + "custom_components.blue_current.async_setup_entry", return_value=True, ): result2 = await hass.config_entries.flow.async_configure( @@ -78,7 +76,7 @@ async def test_user_card(hass: HomeAssistant) -> None: "bluecurrent_api.Client.get_charge_cards", return_value=[{"name": "card 1", "uid": 1}, {"name": "card 2", "uid": 2}], ), patch( - "homeassistant.components.blue_current.async_setup_entry", + "custom_components.blue_current.async_setup_entry", return_value=True, ): result3 = await hass.config_entries.flow.async_configure( diff --git a/tests/test_device_condition.py b/tests/test_device_condition.py index 0d28ccf..e9690f1 100755 --- a/tests/test_device_condition.py +++ b/tests/test_device_condition.py @@ -2,22 +2,16 @@ from __future__ import annotations import pytest - from homeassistant.components import automation -from homeassistant.components.blue_current import DOMAIN from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry, entity_registry from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, assert_lists_same, async_get_device_automations, + async_mock_service, mock_device_registry, mock_registry) -from tests.common import ( - MockConfigEntry, - assert_lists_same, - async_get_device_automations, - async_mock_service, - mock_device_registry, - mock_registry, -) +from custom_components.blue_current import DOMAIN @pytest.fixture diff --git a/tests/test_device_trigger.py b/tests/test_device_trigger.py index 75bf9b3..8ba48ec 100755 --- a/tests/test_device_trigger.py +++ b/tests/test_device_trigger.py @@ -1,21 +1,15 @@ """The tests for Blue Current device triggers.""" import pytest - from homeassistant.components import automation -from homeassistant.components.blue_current import DOMAIN from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import ( + MockConfigEntry, assert_lists_same, async_get_device_automations, + async_mock_service, mock_device_registry, mock_registry) -from tests.common import ( - MockConfigEntry, - assert_lists_same, - async_get_device_automations, - async_mock_service, - mock_device_registry, - mock_registry, -) +from custom_components.blue_current import DOMAIN @pytest.fixture diff --git a/tests/test_init.py b/tests/test_init.py index a164aa0..a9a815f 100755 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,24 +4,23 @@ from typing import Any from unittest.mock import patch +import pytest from bluecurrent_api.client import Client from bluecurrent_api.exceptions import RequestLimitReached, WebsocketException -import pytest +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from pytest_homeassistant_custom_component.common import MockConfigEntry -from homeassistant.components.blue_current import ( +from custom_components.blue_current import ( DOMAIN, Connector, async_setup_entry, set_entities_unavalible, ) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from . import init_integration -from tests.common import MockConfigEntry - async def test_load_unload_entry(hass: HomeAssistant): """Test load and unload entry.""" @@ -96,9 +95,8 @@ async def test_on_data(hass: HomeAssistant): await init_integration(hass, "sensor", {}) with patch( - "homeassistant.components.blue_current.async_dispatcher_send" + "custom_components.blue_current.async_dispatcher_send" ) as test_async_dispatcher_send: - connector: Connector = hass.data[DOMAIN]["uuid"] # test CHARGE_POINTS @@ -241,9 +239,8 @@ async def test_start_loop(hass: HomeAssistant): """Tests start_loop.""" with patch( - "homeassistant.components.blue_current.async_call_later" + "custom_components.blue_current.async_call_later" ) as test_async_call_later: - config_entry = MockConfigEntry( domain=DOMAIN, entry_id="uuid", @@ -275,9 +272,8 @@ async def test_reconnect(hass: HomeAssistant): ), patch( "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) ), patch( - "homeassistant.components.blue_current.async_call_later" + "custom_components.blue_current.async_call_later" ) as test_async_call_later: - config_entry = MockConfigEntry( domain=DOMAIN, entry_id="uuid", diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 7c3b7ba..87ca863 100755 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -2,11 +2,12 @@ from datetime import datetime from typing import Any -from homeassistant.components.blue_current import Connector from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send +from custom_components.blue_current import Connector + from . import init_integration TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") diff --git a/tests/test_switch.py b/tests/test_switch.py index 7017060..d0a585b 100755 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -2,11 +2,12 @@ import asyncio from typing import Any -from homeassistant.components.blue_current import Connector from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send +from custom_components.blue_current import Connector + from . import init_integration charge_point: dict[str, bool] = { From e2544c4d63a9c47ccbf42061936d26a3369c9547 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Thu, 13 Apr 2023 13:46:23 +0000 Subject: [PATCH 5/7] updated dependencies --- hacs.json | 2 +- requirements.txt | 7 ++++--- tests/test_config_flow.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/hacs.json b/hacs.json index e797b80..63f0b25 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Blue Current", "hide_default_branch": true, - "homeassistant": "2022.2.0", + "homeassistant": "2023.4.3", "render_readme": true, "country": ["NL"] } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 184799f..3b8514d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ colorlog==6.7.0 -homeassistant==2023.3.5 -pip>=8.0.3,<20.3 -ruff==0.0.257 \ No newline at end of file +homeassistant==2023.4.3 +pip>=21.0,<23.1 +ruff==0.0.261 +pytest-homeassistant-custom-component>=0.13.20 \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index eb23cff..55ef314 100755 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -244,7 +244,7 @@ async def test_form_exception_card(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "unknown"} -async def test_flow_reauth(hass: HomeAssistant): +async def test_flow_reauth(hass: HomeAssistant) -> None: """Test reauth step.""" with patch( "bluecurrent_api.Client.validate_api_token", From 15ef25044eea6d992adbba98be760610f80a5553 Mon Sep 17 00:00:00 2001 From: Floris272 Date: Fri, 21 Apr 2023 13:19:52 +0000 Subject: [PATCH 6/7] fixed test and updated dependencies --- custom_components/blue_current/manifest.json | 2 +- hacs.json | 2 +- requirements.test.txt | 2 +- requirements.txt | 3 +-- tests/test_config_flow.py | 3 +++ 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/custom_components/blue_current/manifest.json b/custom_components/blue_current/manifest.json index 8864bd2..da9aecf 100755 --- a/custom_components/blue_current/manifest.json +++ b/custom_components/blue_current/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", - "requirements": ["bluecurrent-api==1.0.2"], + "requirements": ["bluecurrent-api==1.0.3"], "version": 1 } diff --git a/hacs.json b/hacs.json index 63f0b25..2c131a6 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,7 @@ { "name": "Blue Current", "hide_default_branch": true, - "homeassistant": "2023.4.3", + "homeassistant": "2023.4.5", "render_readme": true, "country": ["NL"] } \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index 01e33d1..0bfeaf7 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,4 +1,4 @@ pytest pytest-cov pytest-asyncio -pytest-homeassistant-custom-component +pytest-homeassistant-custom-component>=0.13.20 diff --git a/requirements.txt b/requirements.txt index 3b8514d..1af65ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ colorlog==6.7.0 -homeassistant==2023.4.3 +homeassistant==2023.4.5 pip>=21.0,<23.1 ruff==0.0.261 -pytest-homeassistant-custom-component>=0.13.20 \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 55ef314..e049636 100755 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -277,3 +277,6 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data.copy() == {"api_token": "1234567890"} + + assert await entry.async_unload(hass) + await hass.async_block_till_done() \ No newline at end of file From 620ec1daec42f0da527181ce3bd389ba7bb09e3e Mon Sep 17 00:00:00 2001 From: Floris272 Date: Fri, 5 May 2023 09:36:55 +0000 Subject: [PATCH 7/7] removed CONTRIBUTING.md --- CONTRIBUTING.md | 61 ------------------------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 797ded3..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,61 +0,0 @@ -# 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`](./configuration.yaml) -file. - -## License - -By contributing, you agree that your contributions will be licensed under its MIT License.