From bbdb9b61c49e3f0e2fcf0999209a8748ddfd264e Mon Sep 17 00:00:00 2001 From: Jan Rieger <jrieger@users.noreply.github.com> Date: Wed, 31 Jan 2024 18:38:14 +0100 Subject: [PATCH] Add config flow to GPSD (#106196) --- .coveragerc | 1 + CODEOWNERS | 3 +- homeassistant/components/gpsd/__init__.py | 20 +++- homeassistant/components/gpsd/config_flow.py | 57 ++++++++++ homeassistant/components/gpsd/const.py | 3 + homeassistant/components/gpsd/manifest.json | 3 +- homeassistant/components/gpsd/sensor.py | 104 +++++++++++-------- homeassistant/components/gpsd/strings.json | 19 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/gpsd/__init__.py | 1 + tests/components/gpsd/conftest.py | 14 +++ tests/components/gpsd/test_config_flow.py | 76 ++++++++++++++ 14 files changed, 259 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/gpsd/config_flow.py create mode 100644 homeassistant/components/gpsd/const.py create mode 100644 homeassistant/components/gpsd/strings.json create mode 100644 tests/components/gpsd/__init__.py create mode 100644 tests/components/gpsd/conftest.py create mode 100644 tests/components/gpsd/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 34b6dde9854..bcd4e349668 100644 --- a/.coveragerc +++ b/.coveragerc @@ -481,6 +481,7 @@ omit = homeassistant/components/google_cloud/tts.py homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_pubsub/__init__.py + homeassistant/components/gpsd/__init__.py homeassistant/components/gpsd/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/growatt_server/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 9691a8d72f6..af196548bb3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -507,7 +507,8 @@ build.json @home-assistant/supervisor /tests/components/govee_ble/ @bdraco @PierreAronnax /homeassistant/components/govee_light_local/ @Galorhallen /tests/components/govee_light_local/ @Galorhallen -/homeassistant/components/gpsd/ @fabaff +/homeassistant/components/gpsd/ @fabaff @jrieger +/tests/components/gpsd/ @fabaff @jrieger /homeassistant/components/gree/ @cmroche /tests/components/gree/ @cmroche /homeassistant/components/greeneye_monitor/ @jkeljo diff --git a/homeassistant/components/gpsd/__init__.py b/homeassistant/components/gpsd/__init__.py index 71656d4d13d..bdd5ddb13b0 100644 --- a/homeassistant/components/gpsd/__init__.py +++ b/homeassistant/components/gpsd/__init__.py @@ -1 +1,19 @@ -"""The gpsd component.""" +"""The GPSD integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up GPSD 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/gpsd/config_flow.py b/homeassistant/components/gpsd/config_flow.py new file mode 100644 index 00000000000..db1f9c5b0c1 --- /dev/null +++ b/homeassistant/components/gpsd/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for GPSD integration.""" +from __future__ import annotations + +import socket +from typing import Any + +from gps3.agps3threaded import GPSD_PORT as DEFAULT_PORT, HOST as DEFAULT_HOST +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +class GPSDConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for GPSD.""" + + VERSION = 1 + + async def async_step_import(self, import_data: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_data) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self._async_abort_entries_match(user_input) + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + sock.connect((user_input[CONF_HOST], user_input[CONF_PORT])) + sock.shutdown(2) + except OSError: + return self.async_abort(reason="cannot_connect") + + port = "" + if user_input[CONF_PORT] != DEFAULT_PORT: + port = f":{user_input[CONF_PORT]}" + + return self.async_create_entry( + title=user_input.get(CONF_NAME, f"GPS {user_input[CONF_HOST]}{port}"), + data=user_input, + ) + + return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA) diff --git a/homeassistant/components/gpsd/const.py b/homeassistant/components/gpsd/const.py new file mode 100644 index 00000000000..8a2aec140b5 --- /dev/null +++ b/homeassistant/components/gpsd/const.py @@ -0,0 +1,3 @@ +"""Constants for the GPSD integration.""" + +DOMAIN = "gpsd" diff --git a/homeassistant/components/gpsd/manifest.json b/homeassistant/components/gpsd/manifest.json index d202a6b0428..3f22c5bfab2 100644 --- a/homeassistant/components/gpsd/manifest.json +++ b/homeassistant/components/gpsd/manifest.json @@ -1,7 +1,8 @@ { "domain": "gpsd", "name": "GPSD", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@jrieger"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gpsd", "iot_class": "local_polling", "loggers": ["gps3"], diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 64b86434c3c..2b3fe756d8d 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -2,13 +2,17 @@ from __future__ import annotations import logging -import socket from typing import Any -from gps3.agps3threaded import AGPS3mechanism +from gps3.agps3threaded import ( + GPSD_PORT as DEFAULT_PORT, + HOST as DEFAULT_HOST, + AGPS3mechanism, +) import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -17,11 +21,15 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_CLIMB = "climb" @@ -29,9 +37,7 @@ ATTR_ELEVATION = "elevation" ATTR_GPS_TIME = "gps_time" ATTR_SPEED = "speed" -DEFAULT_HOST = "localhost" DEFAULT_NAME = "GPS" -DEFAULT_PORT = 2947 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -42,64 +48,74 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the GPSD component.""" + async_add_entities( + [ + GpsdSensor( + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.entry_id, + ) + ] + ) + + +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 GPSD component.""" - name = config[CONF_NAME] - host = config[CONF_HOST] - port = config[CONF_PORT] - - # Will hopefully be possible with the next gps3 update - # https://github.com/wadda/gps3/issues/11 - # from gps3 import gps3 - # try: - # gpsd_socket = gps3.GPSDSocket() - # gpsd_socket.connect(host=host, port=port) - # except GPSError: - # _LOGGER.warning('Not able to connect to GPSD') - # return False - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.connect((host, port)) - sock.shutdown(2) - _LOGGER.debug("Connection to GPSD possible") - except OSError: - _LOGGER.error("Not able to connect to GPSD") - return - - add_entities([GpsdSensor(hass, name, host, port)]) + """Initialize gpsd import from config.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.9.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "GPSD", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( self, - hass: HomeAssistant, - name: str, host: str, port: int, + unique_id: str, ) -> None: """Initialize the GPSD sensor.""" - self.hass = hass - self._name = name - self._host = host - self._port = port + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + self._attr_unique_id = unique_id self.agps_thread = AGPS3mechanism() - self.agps_thread.stream_data(host=self._host, port=self._port) + self.agps_thread.stream_data(host=host, port=port) self.agps_thread.run_thread() - @property - def name(self) -> str: - """Return the name.""" - return self._name - @property def native_value(self) -> str | None: """Return the state of GPSD.""" diff --git a/homeassistant/components/gpsd/strings.json b/homeassistant/components/gpsd/strings.json new file mode 100644 index 00000000000..ff91b239d0a --- /dev/null +++ b/homeassistant/components/gpsd/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "The hostname or IP address of GPSD." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 80d3f7310b0..aa3efde99bc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -203,6 +203,7 @@ FLOWS = { "google_travel_time", "govee_ble", "govee_light_local", + "gpsd", "gpslogger", "gree", "growatt_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fa143ddf151..21186272bb6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2321,7 +2321,7 @@ "gpsd": { "name": "GPSD", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "gpslogger": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a535c5cd01..c7207fc5398 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,6 +783,9 @@ govee-ble==0.27.3 # homeassistant.components.govee_light_local govee-local-api==1.4.1 +# homeassistant.components.gpsd +gps3==0.33.3 + # homeassistant.components.gree greeclimate==1.4.1 diff --git a/tests/components/gpsd/__init__.py b/tests/components/gpsd/__init__.py new file mode 100644 index 00000000000..d78331c94d9 --- /dev/null +++ b/tests/components/gpsd/__init__.py @@ -0,0 +1 @@ +"""Tests for the GPSD integration.""" diff --git a/tests/components/gpsd/conftest.py b/tests/components/gpsd/conftest.py new file mode 100644 index 00000000000..c2bd2b8564a --- /dev/null +++ b/tests/components/gpsd/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the GPSD tests.""" +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.gpsd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/gpsd/test_config_flow.py b/tests/components/gpsd/test_config_flow.py new file mode 100644 index 00000000000..0b0465b026d --- /dev/null +++ b/tests/components/gpsd/test_config_flow.py @@ -0,0 +1,76 @@ +"""Test the GPSD config flow.""" +from unittest.mock import AsyncMock, patch + +from gps3.agps3threaded import GPSD_PORT as DEFAULT_PORT + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.gpsd.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +HOST = "gpsd.local" + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with patch("socket.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.return_value = None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == f"GPS {HOST}" + assert result2["data"] == { + CONF_HOST: HOST, + CONF_PORT: DEFAULT_PORT, + } + mock_setup_entry.assert_called_once() + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test connection to host error.""" + with patch("socket.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.side_effect = OSError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_HOST: "nonexistent.local", CONF_PORT: 1234}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import(hass: HomeAssistant) -> None: + """Test import step.""" + with patch("homeassistant.components.gpsd.config_flow.socket") as mock_socket: + mock_connect = mock_socket.return_value.connect + mock_connect.return_value = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: HOST, CONF_PORT: 1234, CONF_NAME: "MyGPS"}, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "MyGPS" + assert result["data"] == { + CONF_HOST: HOST, + CONF_NAME: "MyGPS", + CONF_PORT: 1234, + } -- GitLab