From 0331ebdd47d05cf226e1c1c751f0feba4e1e0b37 Mon Sep 17 00:00:00 2001
From: Tom Schneider <tom.schneider-github@sutomaji.net>
Date: Mon, 15 Jun 2020 00:15:20 +0200
Subject: [PATCH] Add HVV integration (Hamburg public transportation) (#31564)

Co-authored-by: springstan <46536646+springstan@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
---
 .coveragerc                                   |   2 +
 CODEOWNERS                                    |   1 +
 .../components/hvv_departures/__init__.py     |  52 +++
 .../components/hvv_departures/config_flow.py  | 218 +++++++++++
 .../components/hvv_departures/const.py        |  10 +
 .../components/hvv_departures/hub.py          |  20 +
 .../components/hvv_departures/manifest.json   |  12 +
 .../components/hvv_departures/sensor.py       | 201 ++++++++++
 .../components/hvv_departures/strings.json    |  48 +++
 .../hvv_departures/translations/en.json       |  48 +++
 homeassistant/generated/config_flows.py       |   1 +
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 tests/components/hvv_departures/__init__.py   |   1 +
 .../hvv_departures/test_config_flow.py        | 344 ++++++++++++++++++
 tests/fixtures/hvv_departures/check_name.json |  15 +
 .../fixtures/hvv_departures/config_entry.json |  16 +
 .../hvv_departures/departure_list.json        | 162 +++++++++
 tests/fixtures/hvv_departures/init.json       |  10 +
 tests/fixtures/hvv_departures/options.json    |  12 +
 .../hvv_departures/station_information.json   |  32 ++
 21 files changed, 1211 insertions(+)
 create mode 100644 homeassistant/components/hvv_departures/__init__.py
 create mode 100644 homeassistant/components/hvv_departures/config_flow.py
 create mode 100644 homeassistant/components/hvv_departures/const.py
 create mode 100644 homeassistant/components/hvv_departures/hub.py
 create mode 100644 homeassistant/components/hvv_departures/manifest.json
 create mode 100644 homeassistant/components/hvv_departures/sensor.py
 create mode 100644 homeassistant/components/hvv_departures/strings.json
 create mode 100644 homeassistant/components/hvv_departures/translations/en.json
 create mode 100644 tests/components/hvv_departures/__init__.py
 create mode 100644 tests/components/hvv_departures/test_config_flow.py
 create mode 100644 tests/fixtures/hvv_departures/check_name.json
 create mode 100644 tests/fixtures/hvv_departures/config_entry.json
 create mode 100644 tests/fixtures/hvv_departures/departure_list.json
 create mode 100644 tests/fixtures/hvv_departures/init.json
 create mode 100644 tests/fixtures/hvv_departures/options.json
 create mode 100644 tests/fixtures/hvv_departures/station_information.json

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
-- 
GitLab