diff --git a/.coveragerc b/.coveragerc index 3d34939dbd9..adb0458d6f6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1243,6 +1243,7 @@ omit = homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py + homeassistant/components/swiss_public_transport/__init__.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index b6c0e75e674..1ed96218424 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1262,7 +1262,8 @@ build.json @home-assistant/supervisor /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/swiss_hydrological_data/ @fabaff -/homeassistant/components/swiss_public_transport/ @fabaff +/homeassistant/components/swiss_public_transport/ @fabaff @miaucl +/tests/components/swiss_public_transport/ @fabaff @miaucl /homeassistant/components/switch/ @home-assistant/core /tests/components/switch/ @home-assistant/core /homeassistant/components/switch_as_x/ @home-assistant/core diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index c53cb1f6934..37f1eeb6765 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -1 +1,64 @@ """The swiss_public_transport component.""" +import logging + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) + +from homeassistant import config_entries, core +from homeassistant.const import Platform +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DESTINATION, CONF_START, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up Swiss public transport from a config entry.""" + config = entry.data + + start = config[CONF_START] + destination = config[CONF_DESTINATION] + + session = async_get_clientsession(hass) + opendata = OpendataTransport(start, destination, session) + + try: + await opendata.async_get_data() + except OpendataTransportConnectionError as e: + raise ConfigEntryNotReady( + f"Timeout while connecting for entry '{start} {destination}'" + ) from e + except OpendataTransportError as e: + _LOGGER.error( + "Setup failed for entry '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names are valid", + start, + destination, + ) + raise ConfigEntryError( + f"Setup failed for entry '{start} {destination}' with invalid data" + ) from e + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = opendata + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py new file mode 100644 index 00000000000..534099f09e6 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -0,0 +1,101 @@ +"""Config flow for swiss_public_transport.""" +import logging +from typing import Any + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_DESTINATION, CONF_START, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_START): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Swiss public transport config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Async user step to set up the connection.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_START: user_input[CONF_START], + CONF_DESTINATION: user_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + user_input[CONF_START], user_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + errors["base"] = "cannot_connect" + except OpendataTransportError: + errors["base"] = "bad_config" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: + """Async import step to set up the connection.""" + self._async_abort_entries_match( + { + CONF_START: import_input[CONF_START], + CONF_DESTINATION: import_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + import_input[CONF_START], import_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + return self.async_abort(reason="cannot_connect") + except OpendataTransportError: + return self.async_abort(reason="bad_config") + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Unknown error raised by python-opendata-transport for '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names and your parameters are valid", + import_input[CONF_START], + import_input[CONF_DESTINATION], + ) + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=import_input[CONF_NAME], + data=import_input, + ) diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py new file mode 100644 index 00000000000..3ce351498ee --- /dev/null +++ b/homeassistant/components/swiss_public_transport/const.py @@ -0,0 +1,9 @@ +"""Constants for the swiss_public_transport integration.""" + +DOMAIN = "swiss_public_transport" + + +CONF_DESTINATION = "to" +CONF_START = "from" + +DEFAULT_NAME = "Next Destination" diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index fd9908bffeb..c68cee2c0e1 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -1,7 +1,8 @@ { "domain": "swiss_public_transport", "name": "Swiss public transport", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@miaucl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 12007e1741c..e8c6e429d36 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -4,21 +4,27 @@ from __future__ import annotations from datetime import timedelta import logging -from opendata_transport import OpendataTransport from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol +from homeassistant import config_entries, core from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util +from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN + _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=90) + ATTR_DEPARTURE_TIME1 = "next_departure" ATTR_DEPARTURE_TIME2 = "next_on_departure" ATTR_DURATION = "duration" @@ -30,14 +36,6 @@ ATTR_TRAIN_NUMBER = "train_number" ATTR_TRANSFERS = "transfers" ATTR_DELAY = "delay" -CONF_DESTINATION = "to" -CONF_START = "from" - -DEFAULT_NAME = "Next Departure" - - -SCAN_INTERVAL = timedelta(seconds=90) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DESTINATION): cv.string, @@ -47,31 +45,65 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + opendata = hass.data[DOMAIN][config_entry.entry_id] + + start = config_entry.data[CONF_START] + destination = config_entry.data[CONF_DESTINATION] + name = config_entry.title + + async_add_entities( + [SwissPublicTransportSensor(opendata, start, destination, name)], + update_before_add=True, + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Swiss public transport sensor.""" - - name = config.get(CONF_NAME) - start = config.get(CONF_START) - destination = config.get(CONF_DESTINATION) - - session = async_get_clientsession(hass) - opendata = OpendataTransport(start, destination, session) - - try: - await opendata.async_get_data() - except OpendataTransportError: - _LOGGER.error( - "Check at http://transport.opendata.ch/examples/stationboard.html " - "if your station names are valid" + """Set up the sensor platform.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Swiss public transport", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", ) - return - - async_add_entities([SwissPublicTransportSensor(opendata, start, destination, name)]) class SwissPublicTransportSensor(SensorEntity): @@ -86,7 +118,7 @@ class SwissPublicTransportSensor(SensorEntity): self._name = name self._from = start self._to = destination - self._remaining_time = "" + self._remaining_time = None @property def name(self): @@ -129,7 +161,7 @@ class SwissPublicTransportSensor(SensorEntity): """Get the latest data from opendata.ch and update the states.""" try: - if self._remaining_time.total_seconds() < 0: + if not self._remaining_time or self._remaining_time.total_seconds() < 0: await self._opendata.async_get_data() except OpendataTransportError: _LOGGER.error("Unable to retrieve data from transport.opendata.ch") diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json new file mode 100644 index 00000000000..097252634ea --- /dev/null +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "error": { + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check at [stationboard](http://transport.opendata.ch/examples/stationboard.html) if your station names are valid", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check at [stationboard](http://transport.opendata.ch/examples/stationboard.html) if your station names are valid", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "step": { + "user": { + "data": { + "from": "Start station", + "to": "End station" + }, + "description": "Provide start and end station for your connection\n\nCheck here for valid stations: [stationboard](http://transport.opendata.ch/examples/stationboard.html)", + "title": "Swiss Public Transport" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The swiss public transport YAML configuration import cannot connect to server", + "description": "Configuring swiss public transport using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the [opendata server](http://transport.opendata.ch). In case the server is down, try again later." + }, + "deprecated_yaml_import_issue_bad_config": { + "title": "The swiss public transport YAML configuration import request failed due to bad config", + "description": "Configuring swiss public transport using YAML is being removed but there was bad config imported in your YAML configuration..\n\nCheck here for valid stations: [stationboard](http://transport.opendata.ch/examples/stationboard.html)" + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The swiss public transport YAML configuration import failed with unknown error raised by python-opendata-transport", + "description": "Configuring swiss public transport using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 260efa41886..df69af184ac 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -475,6 +475,7 @@ FLOWS = { "sun", "sunweg", "surepetcare", + "swiss_public_transport", "switchbee", "switchbot", "switchbot_cloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 448b69e6da7..0a3229d73b2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5613,7 +5613,7 @@ "swiss_public_transport": { "name": "Swiss public transport", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "swisscom": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 665a4a5fa05..8509b76fd94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1655,6 +1655,9 @@ python-miio==0.5.12 # homeassistant.components.mystrom python-mystrom==2.2.0 +# homeassistant.components.swiss_public_transport +python-opendata-transport==0.3.0 + # homeassistant.components.opensky python-opensky==1.0.0 diff --git a/tests/components/swiss_public_transport/__init__.py b/tests/components/swiss_public_transport/__init__.py new file mode 100644 index 00000000000..3859a630c31 --- /dev/null +++ b/tests/components/swiss_public_transport/__init__.py @@ -0,0 +1 @@ +"""Tests for the swiss_public_transport integration.""" diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py new file mode 100644 index 00000000000..d84446db086 --- /dev/null +++ b/tests/components/swiss_public_transport/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the swiss_public_transport 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.swiss_public_transport.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py new file mode 100644 index 00000000000..55ad51c45c4 --- /dev/null +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -0,0 +1,200 @@ +"""Test the swiss_public_transport config flow.""" +from unittest.mock import AsyncMock, patch + +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.swiss_public_transport import config_flow +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_DATA_STEP = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", +} + + +async def test_flow_user_init_data_success(hass: HomeAssistant) -> None: + """Test success response.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["handler"] == "swiss_public_transport" + assert result["data_schema"] == config_flow.DATA_SCHEMA + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test unknown errors.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ) as mock_OpendataTransport: + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == text_error + + # Recover + mock_OpendataTransport.side_effect = None + mock_OpendataTransport.return_value = True + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_STEP, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +MOCK_DATA_IMPORT = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", + CONF_NAME: "test_name", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA_IMPORT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_import_cannot_connect_error( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test import flow cannot_connect error.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == text_error + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_IMPORT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured"