diff --git a/README.md b/README.md index 18b8a6f..9a8b64d 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,64 @@ -# Notice - -The component and platforms in this repository are not meant to be used by a -user, but as a "blueprint" that custom component developers can build -upon, to make more awesome stuff. - -HAVE FUN! 😎 - -## Why? - -This is simple, by having custom_components look (README + structure) the same -it is easier for developers to help each other and for users to start using them. - -If you are a developer and you want to add things to this "blueprint" that you think more -developers will have use for, please open a PR to add it :) - -## What? - -This repository contains multiple files, here is a overview: - -File | Purpose --- | -- -`.devcontainer/*` | Used for development/testing with VSCODE, more info in the readme file in that dir. -`.github/ISSUE_TEMPLATE/feature_request.md` | Template for Feature Requests -`.github/ISSUE_TEMPLATE/issue.md` | Template for issues -`.vscode/tasks.json` | Tasks for the devcontainer. -`custom_components/integration_blueprint/translations/*` | [Translation files.](https://developers.home-assistant.io/docs/internationalization/custom_integration) -`custom_components/integration_blueprint/__init__.py` | The component file for the integration. -`custom_components/integration_blueprint/api.py` | This is a sample API client. -`custom_components/integration_blueprint/binary_sensor.py` | Binary sensor platform for the integration. -`custom_components/integration_blueprint/config_flow.py` | Config flow file, this adds the UI configuration possibilities. -`custom_components/integration_blueprint/const.py` | A file to hold shared variables/constants for the entire integration. -`custom_components/integration_blueprint/manifest.json` | A [manifest file](https://developers.home-assistant.io/docs/en/creating_integration_manifest.html) for Home Assistant. -`custom_components/integration_blueprint/sensor.py` | Sensor platform for the integration. -`custom_components/integration_blueprint/switch.py` | Switch sensor platform for the integration. -`tests/__init__.py` | Makes the `tests` folder a module. -`tests/conftest.py` | Global [fixtures](https://docs.pytest.org/en/stable/fixture.html) used in tests to [patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) functions. -`tests/test_api.py` | Tests for `custom_components/integration_blueprint/api.py`. -`tests/test_config_flow.py` | Tests for `custom_components/integration_blueprint/config_flow.py`. -`tests/test_init.py` | Tests for `custom_components/integration_blueprint/__init__.py`. -`tests/test_switch.py` | Tests for `custom_components/integration_blueprint/switch.py`. -`CONTRIBUTING.md` | Guidelines on how to contribute. -`example.png` | Screenshot that demonstrate how it might look in the UI. -`info.md` | An example on a info file (used by [hacs][hacs]). -`LICENSE` | The license file for the project. -`README.md` | The file you are reading now, should contain info about the integration, installation and configuration instructions. -`requirements.txt` | Python packages used by this integration. -`requirements_dev.txt` | Python packages used to provide [IntelliSense](https://code.visualstudio.com/docs/editor/intellisense)/code hints during development of this integration, typically includes packages in `requirements.txt` but may include additional packages -`requirements_test.txt` | Python packages required to run the tests for this integration, typically includes packages in `requirements_dev.txt` but may include additional packages - -## How? - -If you want to use all the potential and features of this blueprint template you -should use Visual Studio Code to develop in a container. In this container you -will have all the tools to ease your python development and a dedicated Home -Assistant core instance to run your integration. See `.devcontainer/README.md` for more information. - -If you need to work on the python library in parallel of this integration -(`sampleclient` in this example) there are different options. The following one seems -easy to implement: - -- Create a dedicated branch for your python library on a public git repository (example: branch -`dev` on `https://github.com/ludeeus/sampleclient`) -- Update in the `manifest.json` file the `requirements` key to point on your development branch -( example: `"requirements": ["git+https://github.com/ludeeus/sampleclient.git@dev#devp==0.0.1beta1"]`) -- Each time you need to make a modification to your python library, push it to your -development branch and increase the number of the python library version in `manifest.json` file -to ensure Home Assistant update the code of the python library. (example `"requirements": ["git+https://...==0.0.1beta2"]`). - - -*** -README content if this was a published component: -*** - -# integration_blueprint +# NPM Switches Custom Integration [![GitHub Release][releases-shield]][releases] +![GitHub all releases][download-all] +![GitHub release (latest by SemVer)][download-latest] [![GitHub Activity][commits-shield]][commits] -[![License][license-shield]](LICENSE) + +[![License][license-shield]][license] [![hacs][hacsbadge]][hacs] ![Project Maintenance][maintenance-shield] -[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] - -[![Discord][discord-shield]][discord] [![Community Forum][forum-shield]][forum] -_Component to integrate with [integration_blueprint][integration_blueprint]._ - -**This component will set up the following platforms.** - -Platform | Description --- | -- -`binary_sensor` | Show something `True` or `False`. -`sensor` | Show info from blueprint API. -`switch` | Switch something `True` or `False`. -![example][exampleimg] ## Installation -1. Using the tool of choice open the directory (folder) for your HA configuration (where you find `configuration.yaml`). -2. If you do not have a `custom_components` directory (folder) there, you need to create it. -3. In the `custom_components` directory (folder) create a new folder called `integration_blueprint`. -4. Download _all_ the files from the `custom_components/integration_blueprint/` directory (folder) in this repository. -5. Place the files you downloaded in the new directory (folder) you created. -6. Restart Home Assistant -7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Integration blueprint" +### Recomended via HACs -Using your HA configuration directory (folder) as a starting point you should now also have this: +1. Use [HACS](https://hacs.xyz/docs/setup/download), in `HACS > Integrations > Explore & Add Repositories` search for "NPM Switches". +2. Restart Home Assistant. +3. [![Add Integration][add-integration-badge]][add-integration] or in the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Tesla Custom Integration". -```text -custom_components/integration_blueprint/translations/en.json -custom_components/integration_blueprint/translations/nb.json -custom_components/integration_blueprint/translations/sensor.nb.json -custom_components/integration_blueprint/__init__.py -custom_components/integration_blueprint/api.py -custom_components/integration_blueprint/binary_sensor.py -custom_components/integration_blueprint/config_flow.py -custom_components/integration_blueprint/const.py -custom_components/integration_blueprint/manifest.json -custom_components/integration_blueprint/sensor.py -custom_components/integration_blueprint/switch.py -``` +### Manual Download +1. Use the tool of choice to open the directory (folder) for your HA configuration (where you find `configuration.yaml`). +3. If you do not have a `custom_components` directory (folder) there, you need to create it. +4. Download this repository's files. +5. Move or Copy the the entire `npm_switches/` directory (folder) in your HA `custom_components` directory (folder). End result should look like `custom_components/npm_switches/`. +6. Restart Home Assistant. +7. [![Add Integration][add-integration-badge]][add-integration] or in the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Tesla Custom Integration". -## Configuration is done in the UI +## Usage - +'NPM Switches' offers integration with a local Nginx Proxy Manager server/instance. It will login into and retrieve a token from the NPM server every 24 hours. This token is used to querry the state of each proxy host every 60 seconds. It is also used to enable or disable a proxy host via local api call to the NPM server. -## Contributions are welcome! +This integration provides the following entities: +- Switches - One switch for every proxy host that is configured +- Sensors - Number of enabled proxy hosts, number of disabled proxy hosts + +Future features could include redirection host switches and 404 host switches. If you want to contribute to this please read the [Contribution guidelines](CONTRIBUTING.md) +_Component built with [integration_blueprint][integration_blueprint]._ + *** [integration_blueprint]: https://github.com/custom-components/integration_blueprint -[buymecoffee]: https://www.buymeacoffee.com/ludeeus -[buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge -[commits-shield]: https://img.shields.io/github/commit-activity/y/custom-components/blueprint.svg?style=for-the-badge -[commits]: https://github.com/custom-components/integration_blueprint/commits/master +[commits-shield]: https://img.shields.io/github/commit-activity/w/InTheDaylight14/nginx-proxy-manager-switches?style=for-the-badge +[commits]: https://github.com/InTheDaylight14/nginx-proxy-manager-switches/commits/master [hacs]: https://github.com/custom-components/hacs [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge -[discord]: https://discord.gg/Qa5fW2R -[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge -[exampleimg]: example.png + [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge [forum]: https://community.home-assistant.io/ -[license-shield]: https://img.shields.io/github/license/custom-components/blueprint.svg?style=for-the-badge -[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge -[releases-shield]: https://img.shields.io/github/release/custom-components/blueprint.svg?style=for-the-badge -[releases]: https://github.com/custom-components/integration_blueprint/releases +[license]: LICENSE +[license-shield]: https://img.shields.io/github/license/InTheDaylight14/nginx-proxy-manager-switches?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-@InTheDaylight14-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/InTheDaylight14/nginx-proxy-manager-switches?style=for-the-badge +[releases]: https://github.com/InTheDaylight14/nginx-proxy-manager-switches/releases +[add-integration]: https://my.home-assistant.io/redirect/config_flow_start?domain=npm_switches +[add-integration-badge]: https://my.home-assistant.io/badges/config_flow_start.svg +[download-all]: https://img.shields.io/github/downloads/InTheDaylight14/nginx-proxy-manager-switches/total?style=for-the-badge +[download-latest]: https://img.shields.io/github/downloads/InTheDaylight14/nginx-proxy-manager-switches/latest/total?style=for-the-badge diff --git a/custom_components/integration_blueprint/api.py b/custom_components/integration_blueprint/api.py deleted file mode 100644 index 5414e23..0000000 --- a/custom_components/integration_blueprint/api.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Sample API Client.""" -import logging -import asyncio -import socket -from typing import Optional -import aiohttp -import async_timeout - -TIMEOUT = 10 - - -_LOGGER: logging.Logger = logging.getLogger(__package__) - -HEADERS = {"Content-type": "application/json; charset=UTF-8"} - - -class IntegrationBlueprintApiClient: - def __init__( - self, username: str, password: str, session: aiohttp.ClientSession - ) -> None: - """Sample API Client.""" - self._username = username - self._password = password - self._session = session - - async def async_get_data(self) -> dict: - """Get data from the API.""" - url = "https://jsonplaceholder.typicode.com/posts/1" - return await self.api_wrapper("get", url) - - async def async_set_title(self, value: str) -> None: - """Get data from the API.""" - url = "https://jsonplaceholder.typicode.com/posts/1" - await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) - - async def api_wrapper( - self, method: str, url: str, data: dict = {}, headers: dict = {} - ) -> dict: - """Get information from the API.""" - try: - async with async_timeout.timeout(TIMEOUT): - if method == "get": - response = await self._session.get(url, headers=headers) - return await response.json() - - elif method == "put": - await self._session.put(url, headers=headers, json=data) - - elif method == "patch": - await self._session.patch(url, headers=headers, json=data) - - elif method == "post": - await self._session.post(url, headers=headers, json=data) - - except asyncio.TimeoutError as exception: - _LOGGER.error( - "Timeout error fetching information from %s - %s", - url, - exception, - ) - - except (KeyError, TypeError) as exception: - _LOGGER.error( - "Error parsing information from %s - %s", - url, - exception, - ) - except (aiohttp.ClientError, socket.gaierror) as exception: - _LOGGER.error( - "Error fetching information from %s - %s", - url, - exception, - ) - except Exception as exception: # pylint: disable=broad-except - _LOGGER.error("Something really wrong happened! - %s", exception) diff --git a/custom_components/integration_blueprint/binary_sensor.py b/custom_components/integration_blueprint/binary_sensor.py deleted file mode 100644 index 6b92f30..0000000 --- a/custom_components/integration_blueprint/binary_sensor.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Binary sensor platform for integration_blueprint.""" -from homeassistant.components.binary_sensor import BinarySensorEntity - -from .const import ( - BINARY_SENSOR, - BINARY_SENSOR_DEVICE_CLASS, - DEFAULT_NAME, - DOMAIN, -) -from .entity import IntegrationBlueprintEntity - - -async def async_setup_entry(hass, entry, async_add_devices): - """Setup binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([IntegrationBlueprintBinarySensor(coordinator, entry)]) - - -class IntegrationBlueprintBinarySensor(IntegrationBlueprintEntity, BinarySensorEntity): - """integration_blueprint binary_sensor class.""" - - @property - def name(self): - """Return the name of the binary_sensor.""" - return f"{DEFAULT_NAME}_{BINARY_SENSOR}" - - @property - def device_class(self): - """Return the class of this binary_sensor.""" - return BINARY_SENSOR_DEVICE_CLASS - - @property - def is_on(self): - """Return true if the binary_sensor is on.""" - return self.coordinator.data.get("title", "") == "foo" diff --git a/custom_components/integration_blueprint/manifest.json b/custom_components/integration_blueprint/manifest.json deleted file mode 100644 index 138b1e7..0000000 --- a/custom_components/integration_blueprint/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "integration_blueprint", - "name": "Integration blueprint", - "documentation": "https://github.com/custom-components/integration_blueprint", - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/custom-components/integration_blueprint/issues", - "version": "0.0.0", - "config_flow": true, - "codeowners": [ - "@ludeeus" - ] -} \ No newline at end of file diff --git a/custom_components/integration_blueprint/sensor.py b/custom_components/integration_blueprint/sensor.py deleted file mode 100644 index 9457312..0000000 --- a/custom_components/integration_blueprint/sensor.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Sensor platform for integration_blueprint.""" -from homeassistant.components.sensor import SensorEntity - -from .const import DEFAULT_NAME, DOMAIN, ICON, SENSOR -from .entity import IntegrationBlueprintEntity - - -async def async_setup_entry(hass, entry, async_add_devices): - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([IntegrationBlueprintSensor(coordinator, entry)]) - - -class IntegrationBlueprintSensor(IntegrationBlueprintEntity, SensorEntity): - """integration_blueprint Sensor class.""" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{SENSOR}" - - @property - def native_value(self): - """Return the native value of the sensor.""" - return self.coordinator.data.get("body") - - @property - def icon(self): - """Return the icon of the sensor.""" - return ICON diff --git a/custom_components/integration_blueprint/switch.py b/custom_components/integration_blueprint/switch.py deleted file mode 100644 index 8381e6f..0000000 --- a/custom_components/integration_blueprint/switch.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Switch platform for integration_blueprint.""" -from homeassistant.components.switch import SwitchEntity - -from .const import DEFAULT_NAME, DOMAIN, ICON, SWITCH -from .entity import IntegrationBlueprintEntity - - -async def async_setup_entry(hass, entry, async_add_devices): - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_devices([IntegrationBlueprintBinarySwitch(coordinator, entry)]) - - -class IntegrationBlueprintBinarySwitch(IntegrationBlueprintEntity, SwitchEntity): - """integration_blueprint switch class.""" - - async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument - """Turn on the switch.""" - await self.coordinator.api.async_set_title("bar") - await self.coordinator.async_request_refresh() - - async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument - """Turn off the switch.""" - await self.coordinator.api.async_set_title("foo") - await self.coordinator.async_request_refresh() - - @property - def name(self): - """Return the name of the switch.""" - return f"{DEFAULT_NAME}_{SWITCH}" - - @property - def icon(self): - """Return the icon of this switch.""" - return ICON - - @property - def is_on(self): - """Return true if the switch is on.""" - return self.coordinator.data.get("title", "") == "foo" diff --git a/custom_components/integration_blueprint/__init__.py b/custom_components/npm_switches/__init__.py similarity index 86% rename from custom_components/integration_blueprint/__init__.py rename to custom_components/npm_switches/__init__.py index 383ae65..a0f3f7c 100644 --- a/custom_components/integration_blueprint/__init__.py +++ b/custom_components/npm_switches/__init__.py @@ -14,9 +14,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import IntegrationBlueprintApiClient +from .api import NpmSwitchesApiClient from .const import ( + CONF_NPM_URL, CONF_PASSWORD, CONF_USERNAME, DOMAIN, @@ -24,7 +25,7 @@ STARTUP_MESSAGE, ) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=60) _LOGGER: logging.Logger = logging.getLogger(__package__) @@ -42,11 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): username = entry.data.get(CONF_USERNAME) password = entry.data.get(CONF_PASSWORD) + npm_url = entry.data.get(CONF_NPM_URL) session = async_get_clientsession(hass) - client = IntegrationBlueprintApiClient(username, password, session) + client = NpmSwitchesApiClient(username, password, npm_url, session) - coordinator = BlueprintDataUpdateCoordinator(hass, client=client) + coordinator = NpmSwitchesUpdateCoordinator(hass, client=client) await coordinator.async_refresh() if not coordinator.last_update_success: @@ -65,12 +67,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): return True -class BlueprintDataUpdateCoordinator(DataUpdateCoordinator): +class NpmSwitchesUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" - def __init__( - self, hass: HomeAssistant, client: IntegrationBlueprintApiClient - ) -> None: + def __init__(self, hass: HomeAssistant, client: NpmSwitchesApiClient) -> None: """Initialize.""" self.api = client self.platforms = [] @@ -80,7 +80,7 @@ def __init__( async def _async_update_data(self): """Update data via library.""" try: - return await self.api.async_get_data() + return await self.api.get_proxy_hosts() except Exception as exception: raise UpdateFailed() from exception diff --git a/custom_components/npm_switches/api.py b/custom_components/npm_switches/api.py new file mode 100644 index 0000000..b659c63 --- /dev/null +++ b/custom_components/npm_switches/api.py @@ -0,0 +1,189 @@ +"""Sample API Client.""" +import logging +import asyncio +import socket + +# from typing import Optional +# from datetime import datetime +import aiohttp +import async_timeout + +from homeassistant.util import dt + +TIMEOUT = 10 + + +_LOGGER: logging.Logger = logging.getLogger(__package__) + +HEADERS = {"Content-type": "application/json; charset=UTF-8"} + + +class NpmSwitchesApiClient: + """Handle api calls to NPM instance.""" + + def __init__( + self, username: str, password: str, npm_url: str, session: aiohttp.ClientSession + ) -> None: + """NPM API Client.""" + self._username = username + self._password = password + self._session = session + self._npm_url = npm_url + self._token = None + self._token_expires = dt.utcnow() + self._headers = None + self.proxy_hosts_data = None + self.num_enabled = 0 + self.num_disabled = 0 + + async def async_get_data(self) -> dict: + """Get data from the API.""" + url = "http://test:81" + return await self.api_wrapper("get", url) + + # async def async_set_title(self, value: str) -> None: + # """Get data from the API.""" + # url = "https://jsonplaceholder.typicode.com/posts/1" + # await self.api_wrapper("patch", url, data={"title": value}, headers=HEADERS) + + async def get_proxy_hosts(self) -> list(): + """Get a list of proxy-hosts.""" + self.num_enabled = 0 + self.num_disabled = 0 + + if self._token is None: + await self.async_get_new_token() + url = self._npm_url + "/api/nginx/proxy-hosts" + proxy_hosts_list = await self.api_wrapper("get", url, headers=self._headers) + self.proxy_hosts_data = {} + for proxy in proxy_hosts_list: + self.proxy_hosts_data[str(proxy["id"])] = proxy + if proxy["enabled"] == 1: + self.num_enabled += 1 + else: + self.num_disabled += 1 + + return self.proxy_hosts_data + + async def get_proxy(self, proxy_id: int) -> dict: + """Get a proxy by id.""" + return self.proxy_hosts_data[proxy_id] + + async def async_get_new_token(self) -> None: + """Get a new token.""" + url = self._npm_url + "/api/tokens" + response = await self.api_wrapper( + "token", + url, + data={ + "identity": self._username, + "secret": self._password, + }, + ) + + self._token = response["token"] + self._token_expires = dt.parse_datetime(response["expires"]) + self._headers = { + "Authorization": "Bearer " + self._token, + } + + async def async_check_token_expiration(self) -> None: + """Check if token expired.""" + utcnow = dt.utcnow() + + if utcnow > self._token_expires: + await self.async_get_new_token() + + async def enable_proxy(self, proxy_id: str) -> None: + """Enable the passed proxy""" + url = self._npm_url + "/api/nginx/proxy-hosts/" + proxy_id + "/enable" + response = await self.api_wrapper("post", url, headers=self._headers) + + if response is True: + self.proxy_hosts_data[proxy_id]["enabled"] = 1 + elif "error" in response.keys(): + _LOGGER.error( + "Error enabling proxy id %s. Error message: '%s'", + proxy_id, + response["error"]["message"], + ) + + async def disable_proxy(self, proxy_id: str) -> None: + """Disable the passed proxy""" + url = self._npm_url + "/api/nginx/proxy-hosts/" + proxy_id + "/disable" + + response = await self.api_wrapper("post", url, headers=self._headers) + if response is True: + self.proxy_hosts_data[proxy_id]["enabled"] = 0 + elif "error" in response.keys(): + _LOGGER.error( + "Error enabling proxy id %s. Error message: '%s'", + proxy_id, + response["error"]["message"], + ) + + def is_proxy_enabled(self, proxy_id: str) -> bool: + """Return True if the proxy is enabled""" + if self.proxy_hosts_data[proxy_id]["enabled"] == 1: + return True + return False + + @property + def get_num_enabled(self) -> int: + """Return the num enabled proxy hosts.""" + return self.num_enabled + + @property + def get_num_disabled(self) -> int: + """Return the num disabled proxy hosts.""" + return self.num_disabled + + @property + def get_npm_url(self) -> str: + """Return the npm url.""" + return self._npm_url + + async def api_wrapper( + self, method: str, url: str, data: dict = None, headers: dict = None + ) -> dict: + """Get information from the API.""" + if method != "token": + await self.async_check_token_expiration() + + try: + async with async_timeout.timeout(TIMEOUT): + if method == "get": + response = await self._session.get(url, headers=headers) + return await response.json() + + elif method == "put": + await self._session.put(url, headers=headers, json=data) + + elif method == "patch": + await self._session.patch(url, headers=headers, json=data) + + elif method == "post" or method == "token": + response = await self._session.post(url, headers=headers, json=data) + return await response.json() + + except asyncio.TimeoutError as exception: + _LOGGER.error( + "Timeout error fetching information from %s - %s", + url, + exception, + ) + + except (KeyError, TypeError) as exception: + _LOGGER.error( + "Error parsing information from %s - %s", + url, + exception, + ) + except (aiohttp.ClientError, socket.gaierror) as exception: + _LOGGER.error( + "Error fetching information from %s - %s", + url, + exception, + ) + except Exception as exception: # pylint: disable=broad-except + _LOGGER.error("Something really wrong happened! - %s", exception) diff --git a/custom_components/integration_blueprint/config_flow.py b/custom_components/npm_switches/config_flow.py similarity index 77% rename from custom_components/integration_blueprint/config_flow.py rename to custom_components/npm_switches/config_flow.py index 26f06a0..be8c671 100644 --- a/custom_components/integration_blueprint/config_flow.py +++ b/custom_components/npm_switches/config_flow.py @@ -4,8 +4,9 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession import voluptuous as vol -from .api import IntegrationBlueprintApiClient +from .api import NpmSwitchesApiClient from .const import ( + CONF_NPM_URL, CONF_PASSWORD, CONF_USERNAME, DOMAIN, @@ -32,8 +33,15 @@ async def async_step_user(self, user_input=None): # return self.async_abort(reason="single_instance_allowed") if user_input is not None: + existing_entry = self._async_entry_for_username(user_input[CONF_USERNAME]) + # if existing_entry and not self.reauth: + if existing_entry: + return self.async_abort(reason="already_configured") + valid = await self._test_credentials( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + user_input[CONF_NPM_URL], ) if valid: return self.async_create_entry( @@ -48,6 +56,7 @@ async def async_step_user(self, user_input=None): # Provide defaults for form user_input[CONF_USERNAME] = "" user_input[CONF_PASSWORD] = "" + user_input[CONF_NPM_URL] = "" return await self._show_config_form(user_input) @@ -64,22 +73,31 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen { vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD, default=user_input[CONF_PASSWORD]): str, + vol.Required(CONF_NPM_URL, default=user_input[CONF_NPM_URL]): str, } ), errors=self._errors, ) - async def _test_credentials(self, username, password): + async def _test_credentials(self, username, password, npm_url): """Return true if credentials is valid.""" try: session = async_create_clientsession(self.hass) - client = IntegrationBlueprintApiClient(username, password, session) - await client.async_get_data() + client = NpmSwitchesApiClient(username, password, npm_url, session) + await client.async_get_new_token() return True except Exception: # pylint: disable=broad-except pass return False + @callback + def _async_entry_for_username(self, username): + """Find an existing entry for a username.""" + for entry in self._async_current_entries(): + if entry.data.get(CONF_USERNAME) == username: + return entry + return None + class BlueprintOptionsFlowHandler(config_entries.OptionsFlow): """Blueprint config flow options handler.""" diff --git a/custom_components/integration_blueprint/const.py b/custom_components/npm_switches/const.py similarity index 81% rename from custom_components/integration_blueprint/const.py rename to custom_components/npm_switches/const.py index c9fa745..3bf8e57 100644 --- a/custom_components/integration_blueprint/const.py +++ b/custom_components/npm_switches/const.py @@ -1,7 +1,7 @@ -"""Constants for integration_blueprint.""" +"""Constants for NPM Switches.""" # Base component constants -NAME = "Integration blueprint" -DOMAIN = "integration_blueprint" +NAME = "NPM Switches" +DOMAIN = "npm_switches" DOMAIN_DATA = f"{DOMAIN}_data" VERSION = "0.0.1" ATTRIBUTION = "Data provided by http://jsonplaceholder.typicode.com/" @@ -14,16 +14,17 @@ BINARY_SENSOR_DEVICE_CLASS = "connectivity" # Platforms -BINARY_SENSOR = "binary_sensor" +# BINARY_SENSOR = "binary_sensor" SENSOR = "sensor" SWITCH = "switch" -PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] +PLATFORMS = [SENSOR, SWITCH] # Configuration and options CONF_ENABLED = "enabled" CONF_USERNAME = "username" CONF_PASSWORD = "password" +CONF_NPM_URL = "npm_url" # Defaults DEFAULT_NAME = DOMAIN diff --git a/custom_components/integration_blueprint/entity.py b/custom_components/npm_switches/entity.py similarity index 62% rename from custom_components/integration_blueprint/entity.py rename to custom_components/npm_switches/entity.py index 0ab5eff..03b4199 100644 --- a/custom_components/integration_blueprint/entity.py +++ b/custom_components/npm_switches/entity.py @@ -1,23 +1,29 @@ """BlueprintEntity class""" from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN, NAME, VERSION, ATTRIBUTION -class IntegrationBlueprintEntity(CoordinatorEntity): +class NpmSwitchesEntity(CoordinatorEntity): + """Init NPM user device.""" + def __init__(self, coordinator, config_entry): super().__init__(coordinator) self.config_entry = config_entry + self.proxy_id = None + self.friendly_name = None + self.coordinator = coordinator @property def unique_id(self): """Return a unique ID to use for this entity.""" - return self.config_entry.entry_id + return slugify(f"{self.config_entry.entry_id} {self.friendly_name}") @property def device_info(self): return { - "identifiers": {(DOMAIN, self.unique_id)}, + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, "name": NAME, "model": VERSION, "manufacturer": NAME, @@ -31,3 +37,8 @@ def extra_state_attributes(self): "id": str(self.coordinator.data.get("id")), "integration": DOMAIN, } + + @property + def name(self): + """Return the name of the switch.""" + return self.friendly_name diff --git a/custom_components/npm_switches/manifest.json b/custom_components/npm_switches/manifest.json new file mode 100644 index 0000000..f8f7c62 --- /dev/null +++ b/custom_components/npm_switches/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "npm_switches", + "name": "NPM Switches", + "documentation": "https://github.com/InTheDaylight14/nginx-proxy-manager-switches", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/InTheDaylight14/nginx-proxy-manager-switches/issues", + "version": "0.0.0", + "config_flow": true, + "codeowners": [ + "@InTheDaylight14" + ] +} \ No newline at end of file diff --git a/custom_components/npm_switches/sensor.py b/custom_components/npm_switches/sensor.py new file mode 100644 index 0000000..0bded88 --- /dev/null +++ b/custom_components/npm_switches/sensor.py @@ -0,0 +1,51 @@ +"""Sensor platform for integration_blueprint.""" +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry + +from .const import DOMAIN +from .entity import NpmSwitchesEntity +from . import NpmSwitchesUpdateCoordinator + + +async def async_setup_entry(hass, entry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + entities.append(NpmSwitchesSensor(coordinator, entry, "enabled")) + entities.append(NpmSwitchesSensor(coordinator, entry, "disabled")) + + async_add_entities(entities, True) + + +class NpmSwitchesSensor(NpmSwitchesEntity, SensorEntity): + """integration_blueprint Sensor class.""" + + def __init__( + self, + coordinator: NpmSwitchesUpdateCoordinator, + entry: ConfigEntry, + name: str, + ) -> None: + """Initialize proxy switch entity.""" + super().__init__(coordinator, entry) + self.proxy_id = name # Unique ID relies on self.proxy_id + self.sensor_name = self.proxy_id + self._attr_icon = "mdi:steering" + self.friendly_name = "NPM " + self.sensor_name.capitalize() + " Proxy Hosts" + + # @property + # def name(self): + # """Return the name of the sensor.""" + # return "npm_" + self.sensor_name + "_proxy_hosts" + + @property + def native_value(self): + """Return the native value of the sensor.""" + if self.sensor_name == "enabled": + return self.coordinator.api.num_enabled + return self.coordinator.api.num_disabled + + @property + def icon(self): + """Return the icon of the sensor.""" + return self._attr_icon diff --git a/custom_components/npm_switches/switch.py b/custom_components/npm_switches/switch.py new file mode 100644 index 0000000..97ef88b --- /dev/null +++ b/custom_components/npm_switches/switch.py @@ -0,0 +1,86 @@ +"""Switch platform for integration_blueprint.""" +import logging +from homeassistant.components.switch import SwitchEntity + +# from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry + +from .const import DOMAIN +from .entity import NpmSwitchesEntity +from . import NpmSwitchesUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + api = hass.data[DOMAIN][entry.entry_id].api + proxy_hosts = await api.get_proxy_hosts() + print(proxy_hosts) + entities = [] + + for proxy in proxy_hosts.values(): + # print(vars(proxy)) + entities.append(NpmSwitchesBinarySwitch(coordinator, entry, proxy)) + + async_add_entities(entities, True) + # async_add_devices([NpmSwitchesBinarySwitch(coordinator, entry, "20")]) + + +class NpmSwitchesBinarySwitch(NpmSwitchesEntity, SwitchEntity): + """integration_blueprint switch class.""" + + def __init__( + self, + coordinator: NpmSwitchesUpdateCoordinator, + entry: ConfigEntry, + proxy: dict, + ) -> None: + """Initialize proxy switch entity.""" + super().__init__(coordinator, entry) + self.proxy = proxy + self.proxy_id = str(proxy["id"]) + self._attr_icon = "mdi:steering" + self.friendly_name = ( + "NPM " + self.proxy["domain_names"][0].replace(".", " ").capitalize() + ) + # print(self.friendly_name) + + async def async_turn_on(self, **kwargs): # pylint: disable=unused-argument + """Turn on the switch.""" + await self.coordinator.api.enable_proxy(self.proxy_id) + await self.async_update_ha_state() + self.proxy = await self.coordinator.api.get_proxy(self.proxy_id) + + async def async_turn_off(self, **kwargs): # pylint: disable=unused-argument + """Turn off the switch.""" + await self.coordinator.api.disable_proxy(self.proxy_id) + await self.async_update_ha_state() + self.proxy = await self.coordinator.api.get_proxy(self.proxy_id) + + # @property + # def name(self): + # """Return the name of the switch.""" + # return "NPM " + self.proxy["domain_names"][0].replace(".", " ").capitalize() + + @property + def icon(self): + """Return the icon of this switch.""" + if self.coordinator.api.is_proxy_enabled(self.proxy_id): + return "mdi:check-network" + return "mdi:close-network" + # return self._attr_icon + + @property + def is_on(self): + """Return true if the switch is on.""" + return self.coordinator.api.is_proxy_enabled(self.proxy_id) + + @property + def extra_state_attributes(self): + """Return device state attributes.""" + return { + "id": self.proxy["id"], + "domain_names": self.proxy["domain_names"], + } diff --git a/custom_components/integration_blueprint/translations/en.json b/custom_components/npm_switches/translations/en.json similarity index 73% rename from custom_components/integration_blueprint/translations/en.json rename to custom_components/npm_switches/translations/en.json index ecf368a..4443c00 100644 --- a/custom_components/integration_blueprint/translations/en.json +++ b/custom_components/npm_switches/translations/en.json @@ -2,11 +2,12 @@ "config": { "step": { "user": { - "title": "Blueprint", - "description": "If you need help with the configuration have a look here: https://github.com/custom-components/integration_blueprint", + "title": "NPM Instance", + "description": "If you need help with the configuration have a look here: https://github.com/InTheDaylight14/nginx-proxy-manager-switches", "data": { "username": "Username", - "password": "Password" + "password": "Password", + "npm_url": "NPM URL ex. http://ip:port" } } }, @@ -21,7 +22,6 @@ "step": { "user": { "data": { - "binary_sensor": "Binary sensor enabled", "sensor": "Sensor enabled", "switch": "Switch enabled" } diff --git a/custom_components/integration_blueprint/translations/fr.json b/custom_components/npm_switches/translations/fr.json similarity index 73% rename from custom_components/integration_blueprint/translations/fr.json rename to custom_components/npm_switches/translations/fr.json index 842caa7..56ff932 100644 --- a/custom_components/integration_blueprint/translations/fr.json +++ b/custom_components/npm_switches/translations/fr.json @@ -2,11 +2,12 @@ "config": { "step": { "user": { - "title": "Blueprint", - "description": "Si vous avez besoin d'aide pour la configuration, regardez ici: https://github.com/custom-components/integration_blueprint", + "title": "NPM Exemple", + "description": "Si vous avez besoin d'aide pour la configuration, regardez ici: https://github.com/InTheDaylight14/nginx-proxy-manager-switches", "data": { "username": "Identifiant", - "password": "Mot de Passe" + "password": "Mot de Passe", + "npm_url": "Nginx Proxy Manager URL ex. http://ip:port" } } }, diff --git a/custom_components/integration_blueprint/translations/nb.json b/custom_components/npm_switches/translations/nb.json similarity index 77% rename from custom_components/integration_blueprint/translations/nb.json rename to custom_components/npm_switches/translations/nb.json index e3ef188..b9e2b47 100644 --- a/custom_components/integration_blueprint/translations/nb.json +++ b/custom_components/npm_switches/translations/nb.json @@ -2,11 +2,12 @@ "config": { "step": { "user": { - "title": "Blueprint", - "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/custom-components/integration_blueprint", + "title": "NPM", + "description": "Hvis du trenger hjep til konfigurasjon ta en titt her: https://github.com/InTheDaylight14/nginx-proxy-manager-switches", "data": { "username": "Brukernavn", - "password": "Passord" + "password": "Passord", + "npm_url": "http://ip:port" } } }, diff --git a/example.png b/example.png deleted file mode 100644 index c2d4244..0000000 Binary files a/example.png and /dev/null differ diff --git a/hacs.json b/hacs.json index 38efd30..73009d3 100644 --- a/hacs.json +++ b/hacs.json @@ -1,10 +1,5 @@ { - "name": "Integration blueprint", + "name": "NPM Switches", "hacs": "1.6.0", - "domains": [ - "binary_sensor", - "sensor", - "switch" - ], - "homeassistant": "0.118.0" + "homeassistant": "2022.06.01" } \ No newline at end of file diff --git a/info.md b/info.md index 714b9e5..464f80a 100644 --- a/info.md +++ b/info.md @@ -4,33 +4,28 @@ [![hacs][hacsbadge]][hacs] [![Project Maintenance][maintenance-shield]][user_profile] -[![BuyMeCoffee][buymecoffeebadge]][buymecoffee] -[![Discord][discord-shield]][discord] [![Community Forum][forum-shield]][forum] -_Component to integrate with [integration_blueprint][integration_blueprint]._ **This component will set up the following platforms.** Platform | Description -- | -- -`binary_sensor` | Show something `True` or `False`. `sensor` | Show info from API. -`switch` | Switch something `True` or `False`. +`switch` | Switch proxy hosts to `Enabled` or `Disabled`. -![example][exampleimg] + {% if not installed %} ## Installation 1. Click install. -1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Blueprint". +1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "NPM Switches". {% endif %} - -## Configuration is done in the UI +_Component built with [integration_blueprint][integration_blueprint]._ @@ -39,18 +34,15 @@ Platform | Description [integration_blueprint]: https://github.com/custom-components/integration_blueprint [buymecoffee]: https://www.buymeacoffee.com/ludeeus [buymecoffeebadge]: https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?style=for-the-badge -[commits-shield]: https://img.shields.io/github/commit-activity/y/custom-components/integration_blueprint.svg?style=for-the-badge -[commits]: https://github.com/custom-components/integration_blueprint/commits/master +[commits-shield]: https://img.shields.io/github/commit-activity/w/InTheDaylight14/nginx-proxy-manager-switches?style=for-the-badge +[commits]: https://github.com/InTheDaylight14/nginx-proxy-manager-switches/commits/master [hacs]: https://hacs.xyz [hacsbadge]: https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge -[discord]: https://discord.gg/Qa5fW2R -[discord-shield]: https://img.shields.io/discord/330944238910963714.svg?style=for-the-badge -[exampleimg]: example.png + [forum-shield]: https://img.shields.io/badge/community-forum-brightgreen.svg?style=for-the-badge [forum]: https://community.home-assistant.io/ -[license]: https://github.com/custom-components/integration_blueprint/blob/main/LICENSE -[license-shield]: https://img.shields.io/github/license/custom-components/integration_blueprint.svg?style=for-the-badge -[maintenance-shield]: https://img.shields.io/badge/maintainer-Joakim%20Sørensen%20%40ludeeus-blue.svg?style=for-the-badge -[releases-shield]: https://img.shields.io/github/release/custom-components/integration_blueprint.svg?style=for-the-badge -[releases]: https://github.com/custom-components/integration_blueprint/releases -[user_profile]: https://github.com/ludeeus +[license-shield]: https://img.shields.io/github/license/InTheDaylight14/nginx-proxy-manager-switches?style=for-the-badge +[maintenance-shield]: https://img.shields.io/badge/maintainer-@InTheDaylight14-blue.svg?style=for-the-badge +[releases-shield]: https://img.shields.io/github/release/InTheDaylight14/nginx-proxy-manager-switches?style=for-the-badge +[releases]: https://github.com/InTheDaylight14/nginx-proxy-manager-switches/releases +[user_profile]: https://github.com/InTheDaylight14 diff --git a/requirements_test.txt b/requirements_test.txt index ee5e049..60527c5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1 +1,4 @@ -pytest-homeassistant-custom-component==0.4.0 +pytest-homeassistant-custom-component +homeassistant +pytest + diff --git a/tests/conftest.py b/tests/conftest.py index f7835f6..18f8445 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ # pytest includes fixtures OOB which you can use as defined on this page) from unittest.mock import patch +from .const import MOCK_PROXY_HOSTS_DICT, MOCK_TOKEN + import pytest pytest_plugins = "pytest_homeassistant_custom_component" @@ -40,24 +42,71 @@ def skip_notifications_fixture(): yield -# This fixture, when used, will result in calls to async_get_data to return None. To have the call +# This fixture, when used, will result in calls to get_proxy_hosts to return None. To have the call # return a value, we would add the `return_value=` parameter to the patch call. @pytest.fixture(name="bypass_get_data") def bypass_get_data_fixture(): """Skip calls to get data from API.""" with patch( - "custom_components.integration_blueprint.IntegrationBlueprintApiClient.async_get_data" + "custom_components.npm_switches.NpmSwitchesApiClient.get_proxy_hosts", + return_value=MOCK_PROXY_HOSTS_DICT, + ): + yield + + +@pytest.fixture(name="bypass_get_data_api") +def bypass_get_data_api_fixture(): + """Skip calls to get data from API.""" + with patch( + "custom_components.npm_switches.NpmSwitchesApiClient.get_proxy_hosts", + return_value=MOCK_PROXY_HOSTS_DICT, ): yield +@pytest.fixture(name="bypass_new_token") +def bypass_get_new_token_fixture(): + """Skip calls to get data from API.""" + with patch( + "custom_components.npm_switches.NpmSwitchesApiClient.async_get_new_token", + return_value=MOCK_TOKEN, + ): + yield + + +# @pytest.fixture(name="bypass_get_data") +# def bypass_get_data_fixture(): +# """Skip calls to get data from API.""" +# with patch("custom_components.npm_switches.NpmSwitchesApiClient.get_proxy_hosts"): +# yield + +##I don't know if we need this long-term??? +# @pytest.fixture(name="bypass_check_token_expiration") +# def bypass_check_token_expiration(): +# """Skip calls to check token expiration.""" +# with patch( +# "custom_components.npm_switches.NpmSwitchesApiClient.async_check_token_expiration" +# ): +# yield + + # In this fixture, we are forcing calls to async_get_data to raise an Exception. This is useful # for exception handling. @pytest.fixture(name="error_on_get_data") def error_get_data_fixture(): """Simulate error when retrieving data from API.""" with patch( - "custom_components.integration_blueprint.IntegrationBlueprintApiClient.async_get_data", + "custom_components.npm_switches.NpmSwitchesApiClient.async_get_data", + side_effect=Exception, + ): + yield + + +@pytest.fixture(name="error_on_get_new_token") +def error_get_new_token_fixture(): + """Simulate error when retrieving data from API.""" + with patch( + "custom_components.npm_switches.NpmSwitchesApiClient.async_get_data", side_effect=Exception, ): yield diff --git a/tests/const.py b/tests/const.py index 83c523f..805bca9 100644 --- a/tests/const.py +++ b/tests/const.py @@ -1,5 +1,138 @@ """Constants for integration_blueprint tests.""" -from custom_components.integration_blueprint.const import CONF_PASSWORD, CONF_USERNAME +from custom_components.npm_switches.const import ( + CONF_PASSWORD, + CONF_USERNAME, + CONF_NPM_URL, +) # Mock config data to be used across multiple tests -MOCK_CONFIG = {CONF_USERNAME: "test_username", CONF_PASSWORD: "test_password"} +MOCK_CONFIG = { + CONF_USERNAME: "test_username", + CONF_PASSWORD: "test_password", + CONF_NPM_URL: "http://test:81", +} + +MOCK_NPM_URL = "http://test:81" + +MOCK_TOKEN = { + "token": "abcd12345", + "expires": "2023-01-25T01:37:00.107Z", +} + +MOCK_PROXY_HOSTS_LIST = [ + { + "id": 33, + "created_on": "2022-11-27T22:46:21.000Z", + "modified_on": "2022-12-11T22:48:53.000Z", + "owner_user_id": 1, + "domain_names": ["my.domain.com"], + "forward_host": "192.168.1.1", + "forward_port": 8123, + "access_list_id": 0, + "certificate_id": 35, + "ssl_forced": 0, + "caching_enabled": 0, + "block_exploits": 0, + "advanced_config": "", + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False, + "nginx_online": True, + "nginx_err": None, + }, + "allow_websocket_upgrade": 0, + "http2_support": 0, + "forward_scheme": "https", + "enabled": 1, + "locations": [], + "hsts_enabled": 0, + "hsts_subdomains": 0, + }, + { + "id": 32, + "created_on": "2022-11-17T00:49:25.000Z", + "modified_on": "2023-01-24T00:36:53.000Z", + "owner_user_id": 1, + "domain_names": ["other.domain.com"], + "forward_host": "192.168.1.2", + "forward_port": 8080, + "access_list_id": 0, + "certificate_id": 35, + "ssl_forced": 0, + "caching_enabled": 0, + "block_exploits": 1, + "advanced_config": "", + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False, + "nginx_online": True, + "nginx_err": None, + }, + "allow_websocket_upgrade": 1, + "http2_support": 0, + "forward_scheme": "http", + "enabled": 0, + "locations": [], + "hsts_enabled": 0, + "hsts_subdomains": 0, + }, +] + +MOCK_PROXY_HOSTS_DICT = { + "33": { + "id": 33, + "created_on": "2022-11-27T22:46:21.000Z", + "modified_on": "2022-12-11T22:48:53.000Z", + "owner_user_id": 1, + "domain_names": ["my.domain.com"], + "forward_host": "192.168.1.1", + "forward_port": 8123, + "access_list_id": 0, + "certificate_id": 35, + "ssl_forced": 0, + "caching_enabled": 0, + "block_exploits": 0, + "advanced_config": "", + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False, + "nginx_online": True, + "nginx_err": None, + }, + "allow_websocket_upgrade": 0, + "http2_support": 0, + "forward_scheme": "https", + "enabled": 1, + "locations": [], + "hsts_enabled": 0, + "hsts_subdomains": 0, + }, + "32": { + "id": 32, + "created_on": "2022-11-17T00:49:25.000Z", + "modified_on": "2023-01-24T00:36:53.000Z", + "owner_user_id": 1, + "domain_names": ["other.domain.com"], + "forward_host": "192.168.1.2", + "forward_port": 8080, + "access_list_id": 0, + "certificate_id": 35, + "ssl_forced": 0, + "caching_enabled": 0, + "block_exploits": 1, + "advanced_config": "", + "meta": { + "letsencrypt_agree": False, + "dns_challenge": False, + "nginx_online": True, + "nginx_err": None, + }, + "allow_websocket_upgrade": 1, + "http2_support": 0, + "forward_scheme": "http", + "enabled": 0, + "locations": [], + "hsts_enabled": 0, + "hsts_subdomains": 0, + }, +} diff --git a/tests/test_api.py b/tests/test_api.py index 65ab7f3..a328887 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,33 +1,48 @@ """Tests for integration_blueprint api.""" import asyncio +import pytest import aiohttp from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt +from custom_components.npm_switches.api import NpmSwitchesApiClient -from custom_components.integration_blueprint.api import IntegrationBlueprintApiClient +from .const import ( + MOCK_NPM_URL, + MOCK_PROXY_HOSTS_LIST, + MOCK_PROXY_HOSTS_DICT, + MOCK_TOKEN, +) + +pytestmark = pytest.mark.asyncio async def test_api(hass, aioclient_mock, caplog): """Test API calls.""" # To test the api submodule, we first create an instance of our API client - api = IntegrationBlueprintApiClient("test", "test", async_get_clientsession(hass)) + api = NpmSwitchesApiClient( + "test", "test", "http://test:81", async_get_clientsession(hass) + ) + + aioclient_mock.post( + MOCK_NPM_URL + "/api/tokens", + json=MOCK_TOKEN, + ) + + await api.async_get_new_token() + assert api._token == MOCK_TOKEN["token"] + assert api._token_expires == dt.parse_datetime(MOCK_TOKEN["expires"]) - # Use aioclient_mock which is provided by `pytest_homeassistant_custom_components` - # to mock responses to aiohttp requests. In this case we are telling the mock to - # return {"test": "test"} when a `GET` call is made to the specified URL. We then - # call `async_get_data` which will make that `GET` request. aioclient_mock.get( - "https://jsonplaceholder.typicode.com/posts/1", json={"test": "test"} + MOCK_NPM_URL + "/api/nginx/proxy-hosts", + json=MOCK_PROXY_HOSTS_LIST, ) - assert await api.async_get_data() == {"test": "test"} - # We do the same for `async_set_title`. Note the difference in the mock call - # between the previous step and this one. We use `patch` here instead of `get` - # because we know that `async_set_title` calls `api_wrapper` with `patch` as the - # first parameter - aioclient_mock.patch("https://jsonplaceholder.typicode.com/posts/1") - assert await api.async_set_title("test") is None + # print(await api.get_proxy_hosts()) + assert await api.get_proxy_hosts() == MOCK_PROXY_HOSTS_DICT + + assert api.get_npm_url == MOCK_NPM_URL # In order to get 100% coverage, we need to test `api_wrapper` to test the code # that isn't already called by `async_get_data` and `async_set_title`. Because the diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 326eb16..e4abc8c 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -5,8 +5,7 @@ import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.integration_blueprint.const import ( - BINARY_SENSOR, +from custom_components.npm_switches.const import ( DOMAIN, PLATFORMS, SENSOR, @@ -15,6 +14,8 @@ from .const import MOCK_CONFIG +pytestmark = pytest.mark.asyncio + # This fixture bypasses the actual setup of the integration # since we only want to test the config flow. We test the @@ -22,11 +23,8 @@ @pytest.fixture(autouse=True) def bypass_setup_fixture(): """Prevent setup.""" - with patch( - "custom_components.integration_blueprint.async_setup", - return_value=True, - ), patch( - "custom_components.integration_blueprint.async_setup_entry", + with patch("custom_components.npm_switches.async_setup", return_value=True,), patch( + "custom_components.npm_switches.async_setup_entry", return_value=True, ): yield @@ -35,14 +33,14 @@ def bypass_setup_fixture(): # Here we simiulate a successful config flow from the backend. # Note that we use the `bypass_get_data` fixture here because # we want the config flow validation to succeed during the test. -async def test_successful_config_flow(hass, bypass_get_data): +async def test_successful_config_flow(hass, bypass_new_token): """Test a successful config flow.""" # Initialize a config flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - # Check that the config flow shows the user form as the first step + # # Check that the config flow shows the user form as the first step assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -64,8 +62,10 @@ async def test_successful_config_flow(hass, bypass_get_data): # We use the `error_on_get_data` mock instead of `bypass_get_data` # (note the function parameters) to raise an Exception during # validation of the input config. -async def test_failed_config_flow(hass, error_on_get_data): +async def test_failed_config_flow(hass, error_on_get_new_token): """Test a failed config flow due to credential validation failure.""" + print("Can I see this?") + assert True result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -107,4 +107,4 @@ async def test_options_flow(hass): assert result["title"] == "test_username" # Verify that the options were updated - assert entry.options == {BINARY_SENSOR: True, SENSOR: False, SWITCH: True} + assert entry.options == {SENSOR: False, SWITCH: True} diff --git a/tests/test_init.py b/tests/test_init.py index 4d46ec4..71974ad 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,41 +3,42 @@ import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.integration_blueprint import ( - BlueprintDataUpdateCoordinator, +from custom_components.npm_switches import ( + NpmSwitchesUpdateCoordinator, async_reload_entry, async_setup_entry, async_unload_entry, ) -from custom_components.integration_blueprint.const import DOMAIN +from custom_components.npm_switches.const import DOMAIN from .const import MOCK_CONFIG +pytestmark = pytest.mark.asyncio # We can pass fixtures as defined in conftest.py to tell pytest to use the fixture # for a given test. We can also leverage fixtures and mocks that are available in # Home Assistant using the pytest_homeassistant_custom_component plugin. # Assertions allow you to verify that the return value of whatever is on the left # side of the assertion matches with the right side. -async def test_setup_unload_and_reload_entry(hass, bypass_get_data): +async def test_setup_unload_and_reload_entry(hass, bypass_get_data, bypass_new_token): """Test entry setup and unload.""" # Create a mock entry so we don't have to go through config flow config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") # Set up the entry and assert that the values set during setup are where we expect - # them to be. Because we have patched the BlueprintDataUpdateCoordinator.async_get_data + # them to be. Because we have patched the NpmSwitchesUpdateCoordinator.async_get_data # call, no code from custom_components/integration_blueprint/api.py actually runs. assert await async_setup_entry(hass, config_entry) assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] assert ( - type(hass.data[DOMAIN][config_entry.entry_id]) == BlueprintDataUpdateCoordinator + type(hass.data[DOMAIN][config_entry.entry_id]) == NpmSwitchesUpdateCoordinator ) # Reload the entry and assert that the data from above is still there assert await async_reload_entry(hass, config_entry) is None assert DOMAIN in hass.data and config_entry.entry_id in hass.data[DOMAIN] assert ( - type(hass.data[DOMAIN][config_entry.entry_id]) == BlueprintDataUpdateCoordinator + type(hass.data[DOMAIN][config_entry.entry_id]) == NpmSwitchesUpdateCoordinator ) # Unload the entry and verify that the data has been removed @@ -45,7 +46,7 @@ async def test_setup_unload_and_reload_entry(hass, bypass_get_data): assert config_entry.entry_id not in hass.data[DOMAIN] -async def test_setup_entry_exception(hass, error_on_get_data): +async def test_setup_entry_exception(hass, error_on_get_new_token): """Test ConfigEntryNotReady when API raises an exception during entry setup.""" config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..58c6614 --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,80 @@ +"""Test npm switches sensor.""" +from unittest.mock import call, patch + +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.helpers import entity_registry as er + +from custom_components.npm_switches import async_setup_entry +from custom_components.npm_switches.const import ( + DEFAULT_NAME, + DOMAIN, + SENSOR, +) + +from .const import ( + MOCK_CONFIG, + MOCK_PROXY_HOSTS_DICT, + MOCK_PROXY_HOSTS_LIST, + MOCK_NPM_URL, +) + +import pytest + +pytestmark = pytest.mark.asyncio + + +async def test_registry_entries(hass, aioclient_mock, bypass_new_token): + """Tests sensors are registered in the entity registry.""" + entry_id = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id=entry_id, options=None + ) + + # Mock the api call to get proxy data, this allows setup to complete successfully. + aioclient_mock.get( + MOCK_NPM_URL + "/api/nginx/proxy-hosts", + json=MOCK_PROXY_HOSTS_LIST, + ) + + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("sensor.npm_enabled_proxy_hosts") + assert entry.unique_id == entry_id + "_npm_enabled_proxy_hosts" + + entry = entity_registry.async_get("sensor.npm_disabled_proxy_hosts") + assert entry.unique_id == entry_id + "_npm_disabled_proxy_hosts" + + +async def test_sensor_states(hass, aioclient_mock, bypass_new_token): + """Test switch services.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", options=None + ) + + # Mock the api call to get proxy data, this allows setup to complete successfully. + aioclient_mock.get( + MOCK_NPM_URL + "/api/nginx/proxy-hosts", + json=MOCK_PROXY_HOSTS_LIST, + ) + + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + # Retrieve state of the enabled sesnor + state = hass.states.get("sensor.npm_enabled_proxy_hosts") + proxy_id = str(state.attributes["id"]) + + assert state.state == "1" + + # Retrieve state of the disabled sesnor + state = hass.states.get("sensor.npm_disabled_proxy_hosts") + proxy_id = str(state.attributes["id"]) + + assert state.state == "1" diff --git a/tests/test_switch.py b/tests/test_switch.py index a48d58e..9729a6f 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -3,42 +3,140 @@ from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry +from homeassistant.helpers import entity_registry as er -from custom_components.integration_blueprint import async_setup_entry -from custom_components.integration_blueprint.const import DEFAULT_NAME, DOMAIN, SWITCH +from custom_components.npm_switches import async_setup_entry +from custom_components.npm_switches.const import ( + DEFAULT_NAME, + DOMAIN, + SWITCH, +) -from .const import MOCK_CONFIG +from .const import ( + MOCK_CONFIG, + MOCK_PROXY_HOSTS_DICT, + MOCK_PROXY_HOSTS_LIST, + MOCK_NPM_URL, +) +import pytest -async def test_switch_services(hass): +pytestmark = pytest.mark.asyncio + + +async def test_registry_entries(hass, aioclient_mock, bypass_new_token): + """Tests devices are registered in the entity registry.""" + entry_id = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id=entry_id, options=None + ) + + # Mock the api call to get proxy data, this allows setup to complete successfully. + aioclient_mock.get( + MOCK_NPM_URL + "/api/nginx/proxy-hosts", + json=MOCK_PROXY_HOSTS_LIST, + ) + + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get("switch.npm_my_domain_com") + assert entry.unique_id == entry_id + "_npm_my_domain_com" + + entry = entity_registry.async_get("switch.npm_other_domain_com") + assert entry.unique_id == entry_id + "_npm_other_domain_com" + + +async def test_switch_services(hass, aioclient_mock, bypass_new_token): """Test switch services.""" # Create a mock entry so we don't have to go through config flow - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG, entry_id="test") + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", options=None + ) + + # Mock the api call to get proxy data, this allows setup to complete successfully. + aioclient_mock.get( + MOCK_NPM_URL + "/api/nginx/proxy-hosts", + json=MOCK_PROXY_HOSTS_LIST, + ) + assert await async_setup_entry(hass, config_entry) await hass.async_block_till_done() - # Functions/objects can be patched directly in test code as well and can be used to test - # additional things, like whether a function was called or what arguments it was called with + # Retrieve state of switch entity to test + state = hass.states.get("switch.npm_my_domain_com") + proxy_id = str(state.attributes["id"]) + + # Mock enable and diable api calls for this entity, make them return True for a successful api call. + aioclient_mock.post( + MOCK_NPM_URL + "/api/nginx/proxy-hosts/" + proxy_id + "/disable", + json=True, + ) + + aioclient_mock.post( + MOCK_NPM_URL + "/api/nginx/proxy-hosts/" + proxy_id + "/enable", + json=True, + ) + + # Ensure the enable/disable functions are called when turning the switch on/off with patch( - "custom_components.integration_blueprint.IntegrationBlueprintApiClient.async_set_title" - ) as title_func: + "custom_components.npm_switches.NpmSwitchesApiClient.enable_proxy" + ) as enable_proxy: await hass.services.async_call( SWITCH, - SERVICE_TURN_OFF, - service_data={ATTR_ENTITY_ID: f"{SWITCH}.{DEFAULT_NAME}_{SWITCH}"}, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.npm_my_domain_com"}, blocking=True, ) - assert title_func.called - assert title_func.call_args == call("foo") - - title_func.reset_mock() + assert enable_proxy.called + assert enable_proxy.call_args == call( + str(MOCK_PROXY_HOSTS_DICT[proxy_id]["id"]) + ) + with patch( + "custom_components.npm_switches.NpmSwitchesApiClient.disable_proxy" + ) as disable_proxy: await hass.services.async_call( SWITCH, - SERVICE_TURN_ON, - service_data={ATTR_ENTITY_ID: f"{SWITCH}.{DEFAULT_NAME}_{SWITCH}"}, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.npm_my_domain_com"}, blocking=True, ) - assert title_func.called - assert title_func.call_args == call("bar") + assert disable_proxy.called + assert disable_proxy.call_args == call( + str(MOCK_PROXY_HOSTS_DICT[proxy_id]["id"]) + ) + + +async def test_switch_states(hass, aioclient_mock, bypass_new_token): + """Test switch states.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", options=None + ) + + # Mock the api call to get proxy data, this allows setup to complete successfully. + aioclient_mock.get( + MOCK_NPM_URL + "/api/nginx/proxy-hosts", + json=MOCK_PROXY_HOSTS_LIST, + ) + + assert await async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + for proxy_host in MOCK_PROXY_HOSTS_LIST: + entity_id = "switch.npm_" + proxy_host["domain_names"][0].replace(".", "_") + state = hass.states.get(entity_id) + + if proxy_host["enabled"] == 1: + expected_state = "on" + else: + expected_state = "off" + + assert state.state == expected_state + assert state.attributes["id"] == proxy_host["id"] + assert state.attributes["domain_names"] == proxy_host["domain_names"]