diff --git a/homeassistant/components/nanogrid_air/api.py b/homeassistant/components/nanogrid_air/api.py deleted file mode 100644 index 6c2dbb58e3e26..0000000000000 --- a/homeassistant/components/nanogrid_air/api.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Fetch API for Nanogrid Air.""" - -import socket - -import aiohttp - -device_ip: str | None = None - - -async def get_ip(users_ip=None): - """Fetch IP address from hostname.""" - if users_ip: - globals()["device_ip"] = users_ip - return True - - try: - ip = socket.gethostbyname("ctek-ng-air.local") - if ip: - globals()["device_ip"] = ip - return True - except socket.gaierror: - return False - - -async def fetch_mac(): - """Fetch mac address from status API.""" - url_status = f"http://{device_ip}/status/" - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url_status) as api_status_response: - api_status_response.raise_for_status() - mac = await api_status_response.json() - return mac["deviceInfo"]["mac"] - except aiohttp.ClientError: - return {} - except aiohttp.HttpProcessingError: - return {} - - -async def fetch_meter_data(): - """Fetch data from the API and return as a dictionary.""" - url_meter = f"http://{device_ip}/meter/" - - async with aiohttp.ClientSession() as session: - try: - async with session.get(url_meter) as api_meter_response: - api_meter_response.raise_for_status() - return await api_meter_response.json() - except aiohttp.ClientError: - await get_ip() - return {} - except aiohttp.HttpProcessingError: - return {} diff --git a/homeassistant/components/nanogrid_air/config_flow.py b/homeassistant/components/nanogrid_air/config_flow.py index 66a9d22a616ca..234a3c051f093 100644 --- a/homeassistant/components/nanogrid_air/config_flow.py +++ b/homeassistant/components/nanogrid_air/config_flow.py @@ -2,15 +2,14 @@ from __future__ import annotations +from ctek import NanogridAir import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL -from .api import fetch_mac, get_ip from .const import DOMAIN -API_DEFAULT = "http://ctek-ng-air.local/meter/" TITLE = "Nanogrid Air" USER_DESC = "description" @@ -20,10 +19,6 @@ class NanogridAirConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - self._url = API_DEFAULT - async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle a flow initiated by the user.""" self._async_abort_entries_match() @@ -31,21 +26,22 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: errors = {} data_schema = vol.Schema( - {vol.Required(CONF_URL, default=self._url, description=USER_DESC): str} + {vol.Required(CONF_URL, default="", description=USER_DESC): str} ) # Attempt to automatically detect the device if user_input is None: try: - if await get_ip(): - mac_address = await fetch_mac() + if await NanogridAir().get_ip(): + status = await NanogridAir().fetch_status() + mac_address = status.device_info.mac if mac_address: unique_id = mac_address await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry( title=TITLE, - data={CONF_URL: self._url}, + data={CONF_URL: ""}, ) errors["base"] = "invalid_auth" else: @@ -61,8 +57,9 @@ async def async_step_user(self, user_input=None) -> ConfigFlowResult: # Handle user input try: - if await get_ip(user_input[CONF_URL]): - mac_address = await fetch_mac() + if await NanogridAir(device_ip=user_input[CONF_URL]).get_ip(): + status = await NanogridAir().fetch_status() + mac_address = status.device_info.mac if mac_address: unique_id = mac_address await self.async_set_unique_id(unique_id) diff --git a/homeassistant/components/nanogrid_air/manifest.json b/homeassistant/components/nanogrid_air/manifest.json index 45cea3820ba33..2ffdac0a7b45d 100644 --- a/homeassistant/components/nanogrid_air/manifest.json +++ b/homeassistant/components/nanogrid_air/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nanogrid_air", "integration_type": "device", - "iot_class": "local_polling" + "iot_class": "local_polling", + "requirements": ["ctek==0.2.0"] } diff --git a/homeassistant/components/nanogrid_air/sensor.py b/homeassistant/components/nanogrid_air/sensor.py index 48229b2b9158a..2b25210cc2d59 100644 --- a/homeassistant/components/nanogrid_air/sensor.py +++ b/homeassistant/components/nanogrid_air/sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta +from ctek import NanogridAir + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -24,8 +26,6 @@ DataUpdateCoordinator, ) -from .api import fetch_meter_data - SENSOR = { "current_0": SensorEntityDescription( key="current_0", @@ -106,7 +106,7 @@ async def async_setup_entry( """Set up sensor entities for the integration entry.""" async def update_data(): - return await fetch_meter_data() + return await NanogridAir().fetch_meter_data() coordinator = DataUpdateCoordinator( hass, @@ -149,23 +149,23 @@ def __init__( def native_value(self) -> str | None: """Return the state of the sensor.""" data = self.coordinator.data - if data is None: + if data is None or isinstance(data, dict): return None if self._sensor_id.startswith("current_"): index = int(self._sensor_id.split("_")[-1]) - return data.get("current", [None, None, None])[index] + return getattr(data, "current", [None])[index] if self._sensor_id.startswith("voltage_"): index = int(self._sensor_id.split("_")[-1]) - return data.get("voltage", [None, None, None])[index] + return getattr(data, "voltage", [None])[index] if self._sensor_id == "power_in": - return data.get("activePowerIn", None) + return getattr(data, "active_power_in", None) if self._sensor_id == "power_out": - return data.get("activePowerOut", None) + return getattr(data, "active_power_out", None) if self._sensor_id == "total_energy_import": - return data.get("totalEnergyActiveImport", None) + return getattr(data, "total_energy_active_import", None) if self._sensor_id == "total_energy_export": - return data.get("totalEnergyActiveExport", None) + return getattr(data, "total_energy_active_export", None) return None @property diff --git a/requirements_all.txt b/requirements_all.txt index 5fea8eca58c69..1a008a60d174e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -678,6 +678,9 @@ crownstone-sse==2.0.4 # homeassistant.components.crownstone crownstone-uart==2.1.0 +# homeassistant.components.nanogrid_air +ctek==0.2.0 + # homeassistant.components.datadog datadog==0.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05a922c872f34..f39da504f8eee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -562,6 +562,9 @@ crownstone-sse==2.0.4 # homeassistant.components.crownstone crownstone-uart==2.1.0 +# homeassistant.components.nanogrid_air +ctek==0.2.0 + # homeassistant.components.datadog datadog==0.15.0 diff --git a/tests/components/nanogrid_air/test_config_flow.py b/tests/components/nanogrid_air/test_config_flow.py index 30f60eb0b9ec8..acefbf648eea7 100644 --- a/tests/components/nanogrid_air/test_config_flow.py +++ b/tests/components/nanogrid_air/test_config_flow.py @@ -11,10 +11,20 @@ from homeassistant.data_entry_flow import FlowResultType +class MockStatus: # noqa: D101 + def __init__(self, mac=None) -> None: # noqa: D107 + self.device_info = MockDeviceInfo(mac) + + +class MockDeviceInfo: # noqa: D101 + def __init__(self, mac) -> None: # noqa: D107 + self.mac = mac + + @pytest.mark.parametrize( ( "get_ip_return", - "fetch_mac_return", + "fetch_status_return", "result_type", "title", "data", @@ -23,17 +33,17 @@ [ ( True, - "00:11:22:33:44:55", + MockStatus(mac="00:11:22:33:44:55"), FlowResultType.CREATE_ENTRY, "Nanogrid Air", - {CONF_URL: "http://ctek-ng-air.local/meter/"}, + {CONF_URL: ""}, None, ), (False, None, FlowResultType.FORM, None, None, {"base": "cannot_connect"}), - (True, None, FlowResultType.FORM, None, None, {"base": "invalid_auth"}), + (True, MockStatus(), FlowResultType.FORM, None, None, {"base": "invalid_auth"}), ( False, - "00:11:22:33:44:55", + MockStatus(mac="00:11:22:33:44:55"), FlowResultType.FORM, None, None, @@ -45,7 +55,7 @@ async def test_form_auto_detect( hass: HomeAssistant, mock_setup_entry: AsyncMock, get_ip_return, - fetch_mac_return, + fetch_status_return, result_type, title, data, @@ -53,14 +63,8 @@ async def test_form_auto_detect( ) -> None: """Test auto-detect scenarios with different return values.""" with ( - patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - return_value=get_ip_return, - ), - patch( - "homeassistant.components.nanogrid_air.config_flow.fetch_mac", - return_value=fetch_mac_return, - ), + patch("ctek.NanogridAir.get_ip", return_value=get_ip_return), + patch("ctek.NanogridAir.fetch_status", return_value=fetch_status_return), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -82,13 +86,10 @@ async def test_form_manual_entry_success( """Test manual entry success.""" user_input = {CONF_URL: "http://user-provided-url.local/meter/"} with ( + patch("ctek.NanogridAir.get_ip", return_value=True), patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - return_value=True, - ), - patch( - "homeassistant.components.nanogrid_air.config_flow.fetch_mac", - return_value="00:11:22:33:44:55", + "ctek.NanogridAir.fetch_status", + return_value=MockStatus(mac="00:11:22:33:44:55"), ), ): result = await hass.config_entries.flow.async_init( @@ -101,6 +102,7 @@ async def test_form_manual_entry_success( assert result["data"][CONF_URL] == "http://user-provided-url.local/meter/" assert len(mock_setup_entry.mock_calls) == 1 + # Additional checks for user inputs assert CONF_URL in result["data"] @@ -108,9 +110,7 @@ async def test_form_not_responding( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test device not responding.""" - with patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", return_value=False - ): + with patch("ctek.NanogridAir.get_ip", return_value=False): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -125,14 +125,8 @@ async def test_form_invalid_mac( ) -> None: """Test invalid MAC address handling.""" with ( - patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - return_value=True, - ), - patch( - "homeassistant.components.nanogrid_air.config_flow.fetch_mac", - return_value=None, - ), + patch("ctek.NanogridAir.get_ip", return_value=True), + patch("ctek.NanogridAir.fetch_status", return_value=MockStatus()), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -144,26 +138,20 @@ async def test_form_invalid_mac( @pytest.mark.parametrize( - ("get_ip_return", "fetch_mac_return", "expected_errors"), + ("get_ip_return", "fetch_status_return", "expected_errors"), [ (False, None, {"base": "cannot_connect"}), - (True, None, {"base": "invalid_auth"}), + (True, MockStatus(), {"base": "invalid_auth"}), ], ) async def test_form_error_handling( - hass: HomeAssistant, get_ip_return, fetch_mac_return, expected_errors + hass: HomeAssistant, get_ip_return, fetch_status_return, expected_errors ) -> None: """Test handling various error scenarios.""" user_input = {CONF_URL: "http://user-provided-url.local/meter/"} with ( - patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - return_value=get_ip_return, - ), - patch( - "homeassistant.components.nanogrid_air.config_flow.fetch_mac", - return_value=fetch_mac_return, - ), + patch("ctek.NanogridAir.get_ip", return_value=get_ip_return), + patch("ctek.NanogridAir.fetch_status", return_value=fetch_status_return), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=user_input @@ -178,10 +166,7 @@ async def test_form_network_failure( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test handling network failures.""" - with patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - side_effect=ConnectionError, - ): + with patch("ctek.NanogridAir.get_ip", side_effect=ConnectionError): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -198,13 +183,10 @@ async def test_form_user_input_exception( user_input = {CONF_URL: "http://user-provided-url.local/meter/"} with ( + patch("ctek.NanogridAir.get_ip", side_effect=Exception("Test exception")), patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - side_effect=Exception("Test exception"), - ), - patch( - "homeassistant.components.nanogrid_air.config_flow.fetch_mac", - return_value="00:11:22:33:44:55", + "ctek.NanogridAir.fetch_status", + return_value=MockStatus(mac="00:11:22:33:44:55"), ), ): result = await hass.config_entries.flow.async_init( @@ -212,6 +194,7 @@ async def test_form_user_input_exception( ) await hass.async_block_till_done() + # Check that the flow was aborted due to the exception assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_responding" @@ -223,13 +206,10 @@ async def test_form_user_input_connection_error( user_input = {CONF_URL: "http://user-provided-url.local/meter/"} with ( + patch("ctek.NanogridAir.get_ip", side_effect=ConnectionError), patch( - "homeassistant.components.nanogrid_air.config_flow.get_ip", - side_effect=ConnectionError, - ), - patch( - "homeassistant.components.nanogrid_air.config_flow.fetch_mac", - return_value="00:11:22:33:44:55", + "ctek.NanogridAir.fetch_status", + return_value=MockStatus(mac="00:11:22:33:44:55"), ), ): result = await hass.config_entries.flow.async_init( @@ -237,5 +217,6 @@ async def test_form_user_input_connection_error( ) await hass.async_block_till_done() + # Check that the flow was aborted due to the connection error assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_responding"