diff --git a/.coveragerc b/.coveragerc index 550bf857e1b..f0f3123d494 100644 --- a/.coveragerc +++ b/.coveragerc @@ -343,6 +343,8 @@ omit = homeassistant/components/hunterdouglas_powerview/sensor.py homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hvv_departures/sensor.py + homeassistant/components/hvv_departures/__init__.py homeassistant/components/hydrawise/* homeassistant/components/hyperion/light.py homeassistant/components/ialarm/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 1450ae90a76..d93ed8cdf31 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -185,6 +185,7 @@ homeassistant/components/huawei_lte/* @scop @fphammerle homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob homeassistant/components/hunterdouglas_powerview/* @bdraco +homeassistant/components/hvv_departures/* @vigonotion homeassistant/components/iammeter/* @lewei50 homeassistant/components/iaqualink/* @flz homeassistant/components/icloud/* @Quentame diff --git a/homeassistant/components/hvv_departures/__init__.py b/homeassistant/components/hvv_departures/__init__.py new file mode 100644 index 00000000000..853ed9460c8 --- /dev/null +++ b/homeassistant/components/hvv_departures/__init__.py @@ -0,0 +1,52 @@ +"""The HVV integration.""" +import asyncio + +from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .hub import GTIHub + +PLATFORMS = [DOMAIN_SENSOR] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the HVV component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up HVV from a config entry.""" + + hub = GTIHub( + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + aiohttp_client.async_get_clientsession(hass), + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = hub + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + return unload_ok diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py new file mode 100644 index 00000000000..720114413d9 --- /dev/null +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -0,0 +1,218 @@ +"""Config flow for HVV integration.""" +import logging + +from pygti.auth import GTI_DEFAULT_HOST +from pygti.exceptions import CannotConnect, InvalidAuth +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv + +from .const import ( # pylint:disable=unused-import + CONF_FILTER, + CONF_REAL_TIME, + CONF_STATION, + DOMAIN, +) +from .hub import GTIHub + +_LOGGER = logging.getLogger(__name__) + +SCHEMA_STEP_USER = vol.Schema( + { + vol.Required(CONF_HOST, default=GTI_DEFAULT_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +SCHEMA_STEP_STATION = vol.Schema({vol.Required(CONF_STATION): str}) + +SCHEMA_STEP_OPTIONS = vol.Schema( + { + vol.Required(CONF_FILTER): vol.In([]), + vol.Required(CONF_OFFSET, default=0): vol.All(int, vol.Range(min=0)), + vol.Optional(CONF_REAL_TIME, default=True): bool, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for HVV.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize component.""" + self.hub = None + self.data = None + self.stations = {} + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + session = aiohttp_client.async_get_clientsession(self.hass) + self.hub = GTIHub( + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session, + ) + + try: + response = await self.hub.authenticate() + _LOGGER.debug("Init gti: %r", response) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + + if not errors: + self.data = user_input + return await self.async_step_station() + + return self.async_show_form( + step_id="user", data_schema=SCHEMA_STEP_USER, errors=errors + ) + + async def async_step_station(self, user_input=None): + """Handle the step where the user inputs his/her station.""" + if user_input is not None: + + errors = {} + + check_name = await self.hub.gti.checkName( + {"theName": {"name": user_input[CONF_STATION]}, "maxList": 20} + ) + + stations = check_name.get("results") + + self.stations = { + f"{station.get('name')}": station + for station in stations + if station.get("type") == "STATION" + } + + if not self.stations: + errors["base"] = "no_results" + + return self.async_show_form( + step_id="station", data_schema=SCHEMA_STEP_STATION, errors=errors + ) + + # schema + + return await self.async_step_station_select() + + return self.async_show_form(step_id="station", data_schema=SCHEMA_STEP_STATION) + + async def async_step_station_select(self, user_input=None): + """Handle the step where the user inputs his/her station.""" + + schema = vol.Schema({vol.Required(CONF_STATION): vol.In(list(self.stations))}) + + if user_input is None: + return self.async_show_form(step_id="station_select", data_schema=schema) + + self.data.update({"station": self.stations[user_input[CONF_STATION]]}) + + title = self.data[CONF_STATION]["name"] + + return self.async_create_entry(title=title, data=self.data) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options flow handler.""" + + def __init__(self, config_entry): + """Initialize HVV Departures options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + self.departure_filters = {} + self.hub = None + + async def async_step_init(self, user_input=None): + """Manage the options.""" + errors = {} + if not self.departure_filters: + + departure_list = {} + self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + + try: + departure_list = await self.hub.gti.departureList( + { + "station": self.config_entry.data[CONF_STATION], + "time": {"date": "heute", "time": "jetzt"}, + "maxList": 5, + "maxTimeOffset": 200, + "useRealtime": True, + "returnFilters": True, + } + ) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + + if not errors: + self.departure_filters = { + str(i): departure_filter + for i, departure_filter in enumerate(departure_list.get("filter")) + } + + if user_input is not None and not errors: + + options = { + CONF_FILTER: [ + self.departure_filters[x] for x in user_input[CONF_FILTER] + ], + CONF_OFFSET: user_input[CONF_OFFSET], + CONF_REAL_TIME: user_input[CONF_REAL_TIME], + } + + return self.async_create_entry(title="", data=options) + + if CONF_FILTER in self.config_entry.options: + old_filter = [ + i + for (i, f) in self.departure_filters.items() + if f in self.config_entry.options.get(CONF_FILTER) + ] + else: + old_filter = [] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional(CONF_FILTER, default=old_filter): cv.multi_select( + { + key: f"{departure_filter['serviceName']}, {departure_filter['label']}" + for key, departure_filter in self.departure_filters.items() + } + ), + vol.Required( + CONF_OFFSET, + default=self.config_entry.options.get(CONF_OFFSET, 0), + ): vol.All(int, vol.Range(min=0)), + vol.Optional( + CONF_REAL_TIME, + default=self.config_entry.options.get(CONF_REAL_TIME, True), + ): bool, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/hvv_departures/const.py b/homeassistant/components/hvv_departures/const.py new file mode 100644 index 00000000000..ae03d1cf58a --- /dev/null +++ b/homeassistant/components/hvv_departures/const.py @@ -0,0 +1,10 @@ +"""Constants for the HVV Departure integration.""" + +DOMAIN = "hvv_departures" +DEFAULT_NAME = DOMAIN +MANUFACTURER = "HVV" +ATTRIBUTION = "Data provided by www.hvv.de" + +CONF_STATION = "station" +CONF_REAL_TIME = "real_time" +CONF_FILTER = "filter" diff --git a/homeassistant/components/hvv_departures/hub.py b/homeassistant/components/hvv_departures/hub.py new file mode 100644 index 00000000000..7cffbed345c --- /dev/null +++ b/homeassistant/components/hvv_departures/hub.py @@ -0,0 +1,20 @@ +"""Hub.""" + +from pygti.gti import GTI, Auth + + +class GTIHub: + """GTI Hub.""" + + def __init__(self, host, username, password, session): + """Initialize.""" + self.host = host + self.username = username + self.password = password + + self.gti = GTI(Auth(session, self.username, self.password, self.host)) + + async def authenticate(self): + """Test if we can authenticate with the host.""" + + return await self.gti.init() diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json new file mode 100644 index 00000000000..cdb8ed2524f --- /dev/null +++ b/homeassistant/components/hvv_departures/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "hvv_departures", + "name": "HVV Departures", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hvv_departures", + "requirements": [ + "pygti==0.6.0" + ], + "codeowners": [ + "@vigonotion" + ] +} \ No newline at end of file diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py new file mode 100644 index 00000000000..d3a02462eb9 --- /dev/null +++ b/homeassistant/components/hvv_departures/sensor.py @@ -0,0 +1,201 @@ +"""Sensor platform for hvv.""" +from datetime import timedelta +import logging + +from aiohttp import ClientConnectorError +from pygti.exceptions import InvalidAuth + +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ID, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.util.dt import utcnow + +from .const import ATTRIBUTION, CONF_STATION, DOMAIN, MANUFACTURER + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +MAX_LIST = 20 +MAX_TIME_OFFSET = 360 +ICON = "mdi:bus" +UNIT_OF_MEASUREMENT = "min" + +ATTR_DEPARTURE = "departure" +ATTR_LINE = "line" +ATTR_ORIGIN = "origin" +ATTR_DIRECTION = "direction" +ATTR_TYPE = "type" +ATTR_DELAY = "delay" +ATTR_NEXT = "next" + +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the sensor platform.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + session = aiohttp_client.async_get_clientsession(hass) + + sensor = HVVDepartureSensor(hass, config_entry, session, hub) + async_add_devices([sensor], True) + + +class HVVDepartureSensor(Entity): + """HVVDepartureSensor class.""" + + def __init__(self, hass, config_entry, session, hub): + """Initialize.""" + self.config_entry = config_entry + self.station_name = self.config_entry.data[CONF_STATION]["name"] + self.attr = {ATTR_ATTRIBUTION: ATTRIBUTION} + self._available = False + self._state = None + self._name = f"Departures at {self.station_name}" + self._last_error = None + + self.gti = hub.gti + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self, **kwargs): + """Update the sensor.""" + + departure_time = utcnow() + timedelta( + minutes=self.config_entry.options.get("offset", 0) + ) + + payload = { + "station": self.config_entry.data[CONF_STATION], + "time": { + "date": departure_time.strftime("%d.%m.%Y"), + "time": departure_time.strftime("%H:%M"), + }, + "maxList": MAX_LIST, + "maxTimeOffset": MAX_TIME_OFFSET, + "useRealtime": self.config_entry.options.get("realtime", False), + } + + if "filter" in self.config_entry.options: + payload.update({"filter": self.config_entry.options["filter"]}) + + try: + data = await self.gti.departureList(payload) + except InvalidAuth as error: + if self._last_error != InvalidAuth: + _LOGGER.error("Authentication failed: %r", error) + self._last_error = InvalidAuth + self._available = False + except ClientConnectorError as error: + if self._last_error != ClientConnectorError: + _LOGGER.warning("Network unavailable: %r", error) + self._last_error = ClientConnectorError + self._available = False + except Exception as error: # pylint: disable=broad-except + if self._last_error != error: + _LOGGER.error("Error occurred while fetching data: %r", error) + self._last_error = error + self._available = False + + if not (data["returnCode"] == "OK" and data.get("departures")): + self._available = False + return + + if self._last_error == ClientConnectorError: + _LOGGER.debug("Network available again") + + self._last_error = None + + departure = data["departures"][0] + line = departure["line"] + delay = departure.get("delay", 0) + self._available = True + self._state = ( + departure_time + + timedelta(minutes=departure["timeOffset"]) + + timedelta(seconds=delay) + ).isoformat() + + self.attr.update( + { + ATTR_LINE: line["name"], + ATTR_ORIGIN: line["origin"], + ATTR_DIRECTION: line["direction"], + ATTR_TYPE: line["type"]["shortInfo"], + ATTR_ID: line["id"], + ATTR_DELAY: delay, + } + ) + + departures = [] + for departure in data["departures"]: + line = departure["line"] + delay = departure.get("delay", 0) + departures.append( + { + ATTR_DEPARTURE: departure_time + + timedelta(minutes=departure["timeOffset"]) + + timedelta(seconds=delay), + ATTR_LINE: line["name"], + ATTR_ORIGIN: line["origin"], + ATTR_DIRECTION: line["direction"], + ATTR_TYPE: line["type"]["shortInfo"], + ATTR_ID: line["id"], + ATTR_DELAY: delay, + } + ) + self.attr[ATTR_NEXT] = departures + + @property + def unique_id(self): + """Return a unique ID to use for this sensor.""" + station_id = self.config_entry.data[CONF_STATION]["id"] + station_type = self.config_entry.data[CONF_STATION]["type"] + + return f"{self.config_entry.entry_id}-{station_id}-{station_type}" + + @property + def device_info(self): + """Return the device info for this sensor.""" + return { + "identifiers": { + ( + DOMAIN, + self.config_entry.entry_id, + self.config_entry.data[CONF_STATION]["id"], + self.config_entry.data[CONF_STATION]["type"], + ) + }, + "name": self._name, + "manufacturer": MANUFACTURER, + } + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return ICON + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self.attr diff --git a/homeassistant/components/hvv_departures/strings.json b/homeassistant/components/hvv_departures/strings.json new file mode 100644 index 00000000000..dfd6484f7d8 --- /dev/null +++ b/homeassistant/components/hvv_departures/strings.json @@ -0,0 +1,48 @@ +{ + "title": "HVV Departures", + "config": { + "step": { + "user": { + "title": "Connect to the HVV API", + "data": { + "host": "Host", + "username": "Username", + "password": "Password" + } + }, + "station": { + "title": "Enter Station/Address", + "data": { + "station": "Station/Address" + } + }, + "station_select": { + "title": "Select Station/Address", + "data": { + "station": "Station/Address" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "no_results": "No results. Try with a different station/address" + }, + "abort": { + "already_configured": "Device is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Options", + "description": "Change options for this departure sensor", + "data": { + "filter": "Select lines", + "offset": "Offset (minutes)", + "real_time": "Use real time data" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hvv_departures/translations/en.json b/homeassistant/components/hvv_departures/translations/en.json new file mode 100644 index 00000000000..ede3ece2f4a --- /dev/null +++ b/homeassistant/components/hvv_departures/translations/en.json @@ -0,0 +1,48 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "no_results": "No results. Try with a different station/address" + }, + "step": { + "station": { + "data": { + "station": "Station/Address" + }, + "title": "Enter Station/Address" + }, + "station_select": { + "data": { + "station": "Station/Address" + }, + "title": "Select Station/Address" + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + }, + "title": "Connect to the HVV API" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "filter": "Select lines", + "offset": "Offset (minutes)", + "real_time": "Use real time data" + }, + "description": "Change options for this departure sensor", + "title": "Options" + } + } + }, + "title": "HVV Departures" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ee4c0ad048d..80e0d496abf 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -70,6 +70,7 @@ FLOWS = [ "huawei_lte", "hue", "hunterdouglas_powerview", + "hvv_departures", "iaqualink", "icloud", "ifttt", diff --git a/requirements_all.txt b/requirements_all.txt index cee4fc107e0..67cb729e96a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1359,6 +1359,9 @@ pygatt[GATTTOOL]==4.0.5 # homeassistant.components.gtfs pygtfs==0.1.5 +# homeassistant.components.hvv_departures +pygti==0.6.0 + # homeassistant.components.version pyhaversion==3.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0bf678b5a17..a91efa9b8db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -587,6 +587,9 @@ pyfttt==0.3 # homeassistant.components.skybeacon pygatt[GATTTOOL]==4.0.5 +# homeassistant.components.hvv_departures +pygti==0.6.0 + # homeassistant.components.version pyhaversion==3.3.0 diff --git a/tests/components/hvv_departures/__init__.py b/tests/components/hvv_departures/__init__.py new file mode 100644 index 00000000000..bc238f43f5e --- /dev/null +++ b/tests/components/hvv_departures/__init__.py @@ -0,0 +1 @@ +"""Tests for the HVV Departures integration.""" diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py new file mode 100644 index 00000000000..3f9098abfc8 --- /dev/null +++ b/tests/components/hvv_departures/test_config_flow.py @@ -0,0 +1,344 @@ +"""Test the HVV Departures config flow.""" +import json + +from pygti.exceptions import CannotConnect, InvalidAuth + +from homeassistant import data_entry_flow +from homeassistant.components.hvv_departures.const import ( + CONF_FILTER, + CONF_REAL_TIME, + CONF_STATION, + DOMAIN, +) +from homeassistant.config_entries import CONN_CLASS_CLOUD_POLL, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_OFFSET, CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture + +FIXTURE_INIT = json.loads(load_fixture("hvv_departures/init.json")) +FIXTURE_CHECK_NAME = json.loads(load_fixture("hvv_departures/check_name.json")) +FIXTURE_STATION_INFORMATION = json.loads( + load_fixture("hvv_departures/station_information.json") +) +FIXTURE_CONFIG_ENTRY = json.loads(load_fixture("hvv_departures/config_entry.json")) +FIXTURE_OPTIONS = json.loads(load_fixture("hvv_departures/options.json")) +FIXTURE_DEPARTURE_LIST = json.loads(load_fixture("hvv_departures/departure_list.json")) + + +async def test_user_flow(hass): + """Test that config flow works.""" + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", + return_value=FIXTURE_INIT, + ), patch("pygti.gti.GTI.checkName", return_value=FIXTURE_CHECK_NAME,), patch( + "pygti.gti.GTI.stationInformation", return_value=FIXTURE_STATION_INFORMATION, + ), patch( + "homeassistant.components.hvv_departures.async_setup", return_value=True + ), patch( + "homeassistant.components.hvv_departures.async_setup_entry", return_value=True, + ): + + # step: user + + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_user["step_id"] == "station" + + # step: station + result_station = await hass.config_entries.flow.async_configure( + result_user["flow_id"], {CONF_STATION: "Wartenau"}, + ) + + assert result_station["step_id"] == "station_select" + + # step: station_select + result_station_select = await hass.config_entries.flow.async_configure( + result_user["flow_id"], {CONF_STATION: "Wartenau"}, + ) + + assert result_station_select["type"] == "create_entry" + assert result_station_select["title"] == "Wartenau" + assert result_station_select["data"] == { + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_STATION: { + "name": "Wartenau", + "city": "Hamburg", + "combinedName": "Wartenau", + "id": "Master:10901", + "type": "STATION", + "coordinate": {"x": 10.035515, "y": 53.56478}, + "serviceTypes": ["bus", "u"], + "hasStationInformation": True, + }, + } + + +async def test_user_flow_no_results(hass): + """Test that config flow works when there are no results.""" + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", + return_value=FIXTURE_INIT, + ), patch( + "pygti.gti.GTI.checkName", return_value={"returnCode": "OK", "results": []}, + ), patch( + "homeassistant.components.hvv_departures.async_setup", return_value=True + ), patch( + "homeassistant.components.hvv_departures.async_setup_entry", return_value=True, + ): + + # step: user + + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_user["step_id"] == "station" + + # step: station + result_station = await hass.config_entries.flow.async_configure( + result_user["flow_id"], {CONF_STATION: "non_existing_station"}, + ) + + assert result_station["step_id"] == "station" + assert result_station["errors"]["base"] == "no_results" + + +async def test_user_flow_invalid_auth(hass): + """Test that config flow handles invalid auth.""" + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", + side_effect=InvalidAuth( + "ERROR_TEXT", + "Bei der Verarbeitung der Anfrage ist ein technisches Problem aufgetreten.", + "Authentication failed!", + ), + ): + + # step: user + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_user["type"] == "form" + assert result_user["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_cannot_connect(hass): + """Test that config flow handles connection errors.""" + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", + side_effect=CannotConnect(), + ): + + # step: user + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_user["type"] == "form" + assert result_user["errors"] == {"base": "cannot_connect"} + + +async def test_user_flow_station(hass): + """Test that config flow handles empty data on step station.""" + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", return_value=True, + ), patch( + "pygti.gti.GTI.checkName", return_value={"returnCode": "OK", "results": []}, + ): + + # step: user + + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result_user["step_id"] == "station" + + # step: station + result_station = await hass.config_entries.flow.async_configure( + result_user["flow_id"], None, + ) + assert result_station["type"] == "form" + assert result_station["step_id"] == "station" + + +async def test_user_flow_station_select(hass): + """Test that config flow handles empty data on step station_select.""" + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", return_value=True, + ), patch( + "pygti.gti.GTI.checkName", return_value=FIXTURE_CHECK_NAME, + ): + result_user = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "api-test.geofox.de", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + result_station = await hass.config_entries.flow.async_configure( + result_user["flow_id"], {CONF_STATION: "Wartenau"}, + ) + + # step: station_select + result_station_select = await hass.config_entries.flow.async_configure( + result_station["flow_id"], None, + ) + + assert result_station_select["type"] == "form" + assert result_station_select["step_id"] == "station_select" + + +async def test_options_flow(hass): + """Test that options flow works.""" + + config_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="Wartenau", + data=FIXTURE_CONFIG_ENTRY, + source="user", + connection_class=CONN_CLASS_CLOUD_POLL, + system_options={"disable_new_entities": False}, + options=FIXTURE_OPTIONS, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", return_value=True, + ), patch( + "pygti.gti.GTI.departureList", return_value=FIXTURE_DEPARTURE_LIST, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_FILTER: ["0"], CONF_OFFSET: 15, CONF_REAL_TIME: False}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_FILTER: [ + { + "serviceID": "HHA-U:U1_HHA-U", + "stationIDs": ["Master:10902"], + "label": "Fuhlsbüttel Nord / Ochsenzoll / Norderstedt Mitte / Kellinghusenstraße / Ohlsdorf / Garstedt", + "serviceName": "U1", + } + ], + CONF_OFFSET: 15, + CONF_REAL_TIME: False, + } + + +async def test_options_flow_invalid_auth(hass): + """Test that options flow works.""" + + config_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="Wartenau", + data=FIXTURE_CONFIG_ENTRY, + source="user", + connection_class=CONN_CLASS_CLOUD_POLL, + system_options={"disable_new_entities": False}, + options=FIXTURE_OPTIONS, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.hvv_departures.hub.GTI.init", + side_effect=InvalidAuth( + "ERROR_TEXT", + "Bei der Verarbeitung der Anfrage ist ein technisches Problem aufgetreten.", + "Authentication failed!", + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_options_flow_cannot_connect(hass): + """Test that options flow works.""" + + config_entry = MockConfigEntry( + version=1, + domain=DOMAIN, + title="Wartenau", + data=FIXTURE_CONFIG_ENTRY, + source="user", + connection_class=CONN_CLASS_CLOUD_POLL, + system_options={"disable_new_entities": False}, + options=FIXTURE_OPTIONS, + unique_id="1234", + ) + config_entry.add_to_hass(hass) + + with patch( + "pygti.gti.GTI.departureList", side_effect=CannotConnect(), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/fixtures/hvv_departures/check_name.json b/tests/fixtures/hvv_departures/check_name.json new file mode 100644 index 00000000000..7f1bf50d39b --- /dev/null +++ b/tests/fixtures/hvv_departures/check_name.json @@ -0,0 +1,15 @@ +{ + "returnCode": "OK", + "results": [ + { + "name": "Wartenau", + "city": "Hamburg", + "combinedName": "Wartenau", + "id": "Master:10901", + "type": "STATION", + "coordinate": {"x": 10.035515, "y": 53.56478}, + "serviceTypes": ["bus", "u"], + "hasStationInformation": true + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/hvv_departures/config_entry.json b/tests/fixtures/hvv_departures/config_entry.json new file mode 100644 index 00000000000..f878280953d --- /dev/null +++ b/tests/fixtures/hvv_departures/config_entry.json @@ -0,0 +1,16 @@ +{ + "host": "api-test.geofox.de", + "username": "test-username", + "password": "test-password", + "station": { + "city": "Schmalfeld", + "combinedName": "Schmalfeld, Holstenstra\u00dfe", + "coordinate": {"x": 9.986115, "y": 53.874122}, + "hasStationInformation": false, + "id": "Master:75279", + "name": "Holstenstra\u00dfe", + "serviceTypes": ["bus"], + "type": "STATION" + }, + "stationInformation": {"returnCode": "OK"} +} \ No newline at end of file diff --git a/tests/fixtures/hvv_departures/departure_list.json b/tests/fixtures/hvv_departures/departure_list.json new file mode 100644 index 00000000000..95099a0ab17 --- /dev/null +++ b/tests/fixtures/hvv_departures/departure_list.json @@ -0,0 +1,162 @@ +{ + "returnCode": "OK", + "time": {"date": "26.01.2020", "time": "22:52"}, + "departures": [ + { + "line": { + "name": "U1", + "direction": "Großhansdorf", + "origin": "Norderstedt Mitte", + "type": { + "simpleType": "TRAIN", + "shortInfo": "U", + "longInfo": "U-Bahn", + "model": "DT4" + }, + "id": "HHA-U:U1_HHA-U" + }, + "timeOffset": 0, + "delay": 0, + "serviceId": 1482563187, + "station": {"combinedName": "Wartenau", "id": "Master:10901"}, + "attributes": [{"isPlanned": true, "types": ["REALTIME", "ACCURATE"]}] + }, + { + "line": { + "name": "25", + "direction": "Bf. Altona", + "origin": "U Burgstraße", + "type": { + "simpleType": "BUS", + "shortInfo": "Bus", + "longInfo": "Niederflur Metrobus", + "model": "Gelenkbus" + }, + "id": "HHA-B:25_HHA-B" + }, + "timeOffset": 1, + "delay": 0, + "serviceId": 74567, + "station": {"combinedName": "U Wartenau", "id": "Master:60015"}, + "attributes": [{"isPlanned": true, "types": ["REALTIME", "ACCURATE"]}] + }, + { + "line": { + "name": "25", + "direction": "U Burgstraße", + "origin": "Bf. Altona", + "type": { + "simpleType": "BUS", + "shortInfo": "Bus", + "longInfo": "Niederflur Metrobus", + "model": "Gelenkbus" + }, + "id": "HHA-B:25_HHA-B" + }, + "timeOffset": 5, + "delay": 0, + "serviceId": 74328, + "station": {"combinedName": "U Wartenau", "id": "Master:60015"}, + "attributes": [{"isPlanned": true, "types": ["REALTIME", "ACCURATE"]}] + }, + { + "line": { + "name": "U1", + "direction": "Norderstedt Mitte", + "origin": "Großhansdorf", + "type": { + "simpleType": "TRAIN", + "shortInfo": "U", + "longInfo": "U-Bahn", + "model": "DT4" + }, + "id": "HHA-U:U1_HHA-U" + }, + "timeOffset": 8, + "delay": 0, + "station": {"combinedName": "Wartenau", "id": "Master:10901"}, + "attributes": [{"isPlanned": true, "types": ["REALTIME", "ACCURATE"]}] + }, + { + "line": { + "name": "U1", + "direction": "Ohlstedt", + "origin": "Norderstedt Mitte", + "type": { + "simpleType": "TRAIN", + "shortInfo": "U", + "longInfo": "U-Bahn", + "model": "DT4" + }, + "id": "HHA-U:U1_HHA-U" + }, + "timeOffset": 10, + "delay": 0, + "station": {"combinedName": "Wartenau", "id": "Master:10901"}, + "attributes": [{"isPlanned": true, "types": ["REALTIME", "ACCURATE"]}] + } + ], + "filter": [ + { + "serviceID": "HHA-U:U1_HHA-U", + "stationIDs": ["Master:10902"], + "label": "Fuhlsbüttel Nord / Ochsenzoll / Norderstedt Mitte / Kellinghusenstraße / Ohlsdorf / Garstedt", + "serviceName": "U1" + }, + { + "serviceID": "HHA-U:U1_HHA-U", + "stationIDs": ["Master:60904"], + "label": "Volksdorf / Farmsen / Großhansdorf / Ohlstedt", + "serviceName": "U1" + }, + { + "serviceID": "HHA-B:25_HHA-B", + "stationIDs": ["Master:10047"], + "label": "Sachsenstraße / U Burgstraße", + "serviceName": "25" + }, + { + "serviceID": "HHA-B:25_HHA-B", + "stationIDs": ["Master:60029"], + "label": "Winterhuder Marktplatz / Bf. Altona", + "serviceName": "25" + }, + { + "serviceID": "HHA-B:36_HHA-B", + "stationIDs": ["Master:10049"], + "label": "S Blankenese / Rathausmarkt", + "serviceName": "36" + }, + { + "serviceID": "HHA-B:36_HHA-B", + "stationIDs": ["Master:60013"], + "label": "Berner Heerweg", + "serviceName": "36" + }, + { + "serviceID": "HHA-B:606_HHA-B", + "stationIDs": ["Master:10047"], + "label": "S Landwehr (Ramazan-Avci-Platz) - Rathausmarkt", + "serviceName": "606" + }, + { + "serviceID": "HHA-B:606_HHA-B", + "stationIDs": ["Master:60029"], + "label": "Uferstraße - Winterhuder Marktplatz / Uferstraße - S Hamburg Airport / Uferstraße - U Langenhorn Markt (Krohnstieg)", + "serviceName": "606" + }, + { + "serviceID": "HHA-B:608_HHA-B", + "stationIDs": ["Master:10048"], + "label": "Rathausmarkt / S Reeperbahn", + "serviceName": "608" + }, + { + "serviceID": "HHA-B:608_HHA-B", + "stationIDs": ["Master:60012"], + "label": "Bf. Rahlstedt (Amtsstraße) / Großlohe", + "serviceName": "608" + } + ], + "serviceTypes": ["UBAHN", "BUS", "METROBUS", "SCHNELLBUS", "NACHTBUS"] +} \ No newline at end of file diff --git a/tests/fixtures/hvv_departures/init.json b/tests/fixtures/hvv_departures/init.json new file mode 100644 index 00000000000..a20a96363c7 --- /dev/null +++ b/tests/fixtures/hvv_departures/init.json @@ -0,0 +1,10 @@ +{ + "returnCode": "OK", + "beginOfService": "04.06.2020", + "endOfService": "13.12.2020", + "id": "1.80.0", + "dataId": "32.55.01", + "buildDate": "04.06.2020", + "buildTime": "14:29:59", + "buildText": "Regelfahrplan 2020" +} \ No newline at end of file diff --git a/tests/fixtures/hvv_departures/options.json b/tests/fixtures/hvv_departures/options.json new file mode 100644 index 00000000000..f2e288d760a --- /dev/null +++ b/tests/fixtures/hvv_departures/options.json @@ -0,0 +1,12 @@ +{ + "filter": [ + { + "label": "S Landwehr (Ramazan-Avci-Platz) - Rathausmarkt", + "serviceID": "HHA-B:606_HHA-B", + "serviceName": "606", + "stationIDs": ["Master:10047"] + } + ], + "offset": 10, + "realtime": true +} \ No newline at end of file diff --git a/tests/fixtures/hvv_departures/station_information.json b/tests/fixtures/hvv_departures/station_information.json new file mode 100644 index 00000000000..52a2cd8da25 --- /dev/null +++ b/tests/fixtures/hvv_departures/station_information.json @@ -0,0 +1,32 @@ +{ + "returnCode": "OK", + "partialStations": [ + { + "stationOutline": "http://www.geofox.de/images/mobi/stationDescriptions/U_Wartenau.ZM3.jpg", + "elevators": [ + { + "label": "A", + "cabinWidth": 124, + "cabinLength": 147, + "doorWidth": 110, + "description": "Zugang Landwehr <-> Schalterhalle", + "elevatorType": "Durchlader", + "buttonType": "BRAILLE", + "state": "READY" + }, + { + "lines": ["U1"], + "label": "B", + "cabinWidth": 123, + "cabinLength": 145, + "doorWidth": 90, + "description": "Schalterhalle <-> U1", + "elevatorType": "Durchlader", + "buttonType": "COMBI", + "state": "READY" + } + ] + } + ], + "lastUpdate": {"date": "26.01.2020", "time": "22:49"} +} \ No newline at end of file