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