diff --git a/.coveragerc b/.coveragerc index 34b6dde985469be98bd8e8b25dcc435e46cb2ed1..bcd4e349668672d2e9554aeeb2433b19f8a8ecc3 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 9691a8d72f6e3e60d63e04b3188607d48b3b9bac..af196548bb33a8e5dc367d967ca8d9a4b56d179e 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 71656d4d13d720c7ad847815f67ba2746b77fa4e..bdd5ddb13b00ceee4d1116e1ed207fd4976eaf92 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 0000000000000000000000000000000000000000..db1f9c5b0c1882ad84bbb8bf24f2d0a0bdb55670 --- /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 0000000000000000000000000000000000000000..8a2aec140b55dc4bc3952a2a48db868c7a01218f --- /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 d202a6b04285644bed11dfe12aa4299db29d18dc..3f22c5bfab2b3a89b2155e00ab27d52104a7a26a 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 64b86434c3c5d07b806b37086b13188ae8ea8abf..2b3fe756d8db9cb688aa495bbde250c660a224fb 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 0000000000000000000000000000000000000000..ff91b239d0a55d5295b7778f18dbc6f3dbc4fb3e --- /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 80d3f7310b09508b14c54bd6dc6260e8600a8d86..aa3efde99bc862f3062f683df8c254bcb0dee01c 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 fa143ddf1512a0e5e3ae62ef54e444ba40c3a812..21186272bb6c0cac3325158fc188082523aaae41 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 1a535c5cd018782586eb463e0dbe594b7a58289d..c7207fc5398431a318a452dab3b36796a5125a42 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 0000000000000000000000000000000000000000..d78331c94d968c0043c76e1979bc8a24cf6a24b9 --- /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 0000000000000000000000000000000000000000..c2bd2b8564a861ceaacf6ac1ab5ee5deb2be49cd --- /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 0000000000000000000000000000000000000000..0b0465b026d3d018c7cc09ae71d6967572624406 --- /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, + }