diff --git a/.coveragerc b/.coveragerc index a06f4fa92d3..8a5b90b5d76 100644 --- a/.coveragerc +++ b/.coveragerc @@ -807,7 +807,8 @@ omit = homeassistant/components/nuki/sensor.py homeassistant/components/nx584/alarm_control_panel.py homeassistant/components/oasa_telematics/sensor.py - homeassistant/components/obihai/* + homeassistant/components/obihai/connectivity.py + homeassistant/components/obihai/sensor.py homeassistant/components/octoprint/__init__.py homeassistant/components/oem/climate.py homeassistant/components/ohmconnect/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 94360a4f45b..24036150fe5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -826,6 +826,7 @@ build.json @home-assistant/supervisor /homeassistant/components/nzbget/ @chriscla /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi +/tests/components/obihai/ @dshokouhi /homeassistant/components/octoprint/ @rfleming71 /tests/components/octoprint/ @rfleming71 /homeassistant/components/ohmconnect/ @robbiet480 diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 8e65423b73b..810b24dca20 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -1 +1,18 @@ """The Obihai integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py new file mode 100644 index 00000000000..dd2aa0db06d --- /dev/null +++ b/homeassistant/components/obihai/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow to configure the Obihai integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .connectivity import validate_auth +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional( + CONF_USERNAME, + default=DEFAULT_USERNAME, + ): str, + vol.Optional( + CONF_PASSWORD, + default=DEFAULT_PASSWORD, + ): str, + } +) + + +class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Obihai.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + if await self.hass.async_add_executor_job( + validate_auth, + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ): + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + errors["base"] = "cannot_connect" + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=data_schema, + ) + + # DEPRECATED + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) + return self.async_create_entry( + title=config.get(CONF_NAME, config[CONF_HOST]), + data={ + CONF_HOST: config[CONF_HOST], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_USERNAME: config[CONF_USERNAME], + }, + ) diff --git a/homeassistant/components/obihai/connectivity.py b/homeassistant/components/obihai/connectivity.py new file mode 100644 index 00000000000..4a5c25b2101 --- /dev/null +++ b/homeassistant/components/obihai/connectivity.py @@ -0,0 +1,67 @@ +"""Support for Obihai Connectivity.""" +from __future__ import annotations + +from pyobihai import PyObihai + +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, LOGGER + + +def get_pyobihai( + host: str, + username: str, + password: str, +) -> PyObihai: + """Retrieve an authenticated PyObihai.""" + return PyObihai(host, username, password) + + +def validate_auth( + host: str, + username: str, + password: str, +) -> bool: + """Test if the given setting works as expected.""" + obi = get_pyobihai(host, username, password) + + login = obi.check_account() + if not login: + LOGGER.debug("Invalid credentials") + return False + + return True + + +class ObihaiConnection: + """Contains a list of Obihai Sensors.""" + + def __init__( + self, + host: str, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD, + ) -> None: + """Store configuration.""" + self.sensors: list = [] + self.host = host + self.username = username + self.password = password + self.serial: list = [] + self.services: list = [] + self.line_services: list = [] + self.call_direction: list = [] + self.pyobihai: PyObihai = None + + def update(self) -> bool: + """Validate connection and retrieve a list of sensors.""" + if not self.pyobihai: + self.pyobihai = get_pyobihai(self.host, self.username, self.password) + + if not self.pyobihai.check_account(): + return False + + self.serial = self.pyobihai.get_device_serial() + self.services = self.pyobihai.get_state() + self.line_services = self.pyobihai.get_line_state() + self.call_direction = self.pyobihai.get_call_direction() + + return True diff --git a/homeassistant/components/obihai/const.py b/homeassistant/components/obihai/const.py new file mode 100644 index 00000000000..90bcd7736f8 --- /dev/null +++ b/homeassistant/components/obihai/const.py @@ -0,0 +1,15 @@ +"""Constants for the Obihai integration.""" + +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "obihai" +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" +OBIHAI = "Obihai" + +LOGGER = logging.getLogger(__package__) + +PLATFORMS: Final = [Platform.SENSOR] diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json index 867d7d875dc..d5bb07805d7 100644 --- a/homeassistant/components/obihai/manifest.json +++ b/homeassistant/components/obihai/manifest.json @@ -2,6 +2,7 @@ "domain": "obihai", "name": "Obihai", "codeowners": ["@dshokouhi"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/obihai", "iot_class": "local_polling", "loggers": ["pyobihai"], diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index cff4e6232e7..953193a5ab6 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -2,9 +2,7 @@ from __future__ import annotations from datetime import timedelta -import logging -from pyobihai import PyObihai import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,20 +10,19 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .connectivity import ObihaiConnection +from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI SCAN_INTERVAL = timedelta(seconds=5) -OBIHAI = "Obihai" -DEFAULT_USERNAME = "admin" -DEFAULT_PASSWORD = "admin" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -35,46 +32,58 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +# DEPRECATED +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Obihai sensor platform.""" + issue_registry.async_create_issue( + hass, + DOMAIN, + "manual_migration", + breaks_in_ha_version="2023.6.0", + is_fixable=False, + severity=issue_registry.IssueSeverity.ERROR, + translation_key="manual_migration", + ) - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - host = config[CONF_HOST] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Obihai sensor entries.""" + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + host = entry.data[CONF_HOST] + requester = ObihaiConnection(host, username, password) + + await hass.async_add_executor_job(requester.update) sensors = [] + for key in requester.services: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - pyobihai = PyObihai(host, username, password) + if requester.line_services is not None: + for key in requester.line_services: + sensors.append( + ObihaiServiceSensors(requester.pyobihai, requester.serial, key) + ) - login = pyobihai.check_account() - if not login: - _LOGGER.error("Invalid credentials") - return + for key in requester.call_direction: + sensors.append(ObihaiServiceSensors(requester.pyobihai, requester.serial, key)) - serial = pyobihai.get_device_serial() - - services = pyobihai.get_state() - - line_services = pyobihai.get_line_state() - - call_direction = pyobihai.get_call_direction() - - for key in services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - if line_services is not None: - for key in line_services: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - for key in call_direction: - sensors.append(ObihaiServiceSensors(pyobihai, serial, key)) - - add_entities(sensors) + async_add_entities(sensors, update_before_add=True) class ObihaiServiceSensors(SensorEntity): @@ -148,6 +157,10 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" + if not self._pyobihai.check_account(): + self._state = None + return + services = self._pyobihai.get_state() if self._service_name in services: diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json new file mode 100644 index 00000000000..053343b4501 --- /dev/null +++ b/homeassistant/components/obihai/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "manual_migration": { + "title": "Manual migration required for Obihai", + "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 28ceb593845..50555476769 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -293,6 +293,7 @@ FLOWS = { "nut", "nws", "nzbget", + "obihai", "octoprint", "omnilogic", "oncue", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cee5b2167a8..0f5e0ff08e2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3777,7 +3777,7 @@ "obihai": { "name": "Obihai", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "octoprint": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f428578537..58a40dbd5a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1322,6 +1322,9 @@ pynx584==0.5 # homeassistant.components.nzbget pynzbgetapi==0.2.0 +# homeassistant.components.obihai +pyobihai==1.3.2 + # homeassistant.components.octoprint pyoctoprintapi==0.1.11 diff --git a/tests/components/obihai/__init__.py b/tests/components/obihai/__init__.py new file mode 100644 index 00000000000..36d0f58fe4f --- /dev/null +++ b/tests/components/obihai/__init__.py @@ -0,0 +1,10 @@ +"""Tests for the Obihai Integration.""" + + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +USER_INPUT = { + CONF_HOST: "10.10.10.30", + CONF_PASSWORD: "admin", + CONF_USERNAME: "admin", +} diff --git a/tests/components/obihai/conftest.py b/tests/components/obihai/conftest.py new file mode 100644 index 00000000000..64e4d4b1a30 --- /dev/null +++ b/tests/components/obihai/conftest.py @@ -0,0 +1,15 @@ +"""Define test fixtures for Obihai.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.obihai.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py new file mode 100644 index 00000000000..07d00f15775 --- /dev/null +++ b/tests/components/obihai/test_config_flow.py @@ -0,0 +1,73 @@ +"""Test the Obihai config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.obihai.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import USER_INPUT + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the user initiated form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch("pyobihai.PyObihai.check_account"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "10.10.10.30" + assert result["data"] == {**USER_INPUT} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test we get the authentication error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.obihai.config_flow.validate_auth", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_yaml_import(hass: HomeAssistant) -> None: + """Test we get the YAML imported.""" + with patch( + "homeassistant.components.obihai.config_flow.validate_auth", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "errors" not in result