Skip to content

Commit b503f79

Browse files
heindrichpaulNoRi2909frenckCopilotjoostlek
authored
Add config flow to NS (home-assistant#151567)
Signed-off-by: Heindrich Paul <[email protected]> Co-authored-by: Norbert Rittel <[email protected]> Co-authored-by: Franck Nijhof <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Joostlek <[email protected]>
1 parent 410c3df commit b503f79

File tree

16 files changed

+1133
-51
lines changed

16 files changed

+1133
-51
lines changed

CODEOWNERS

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,56 @@
1-
"""The nederlandse_spoorwegen component."""
1+
"""The Nederlandse Spoorwegen integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from ns_api import NSAPI, RequestParametersError
8+
import requests
9+
10+
from homeassistant.config_entries import ConfigEntry
11+
from homeassistant.const import CONF_API_KEY, Platform
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.exceptions import ConfigEntryNotReady
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
18+
type NSConfigEntry = ConfigEntry[NSAPI]
19+
20+
PLATFORMS = [Platform.SENSOR]
21+
22+
23+
async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool:
24+
"""Set up Nederlandse Spoorwegen from a config entry."""
25+
api_key = entry.data[CONF_API_KEY]
26+
27+
client = NSAPI(api_key)
28+
29+
try:
30+
await hass.async_add_executor_job(client.get_stations)
31+
except (
32+
requests.exceptions.ConnectionError,
33+
requests.exceptions.HTTPError,
34+
) as error:
35+
_LOGGER.error("Could not connect to the internet: %s", error)
36+
raise ConfigEntryNotReady from error
37+
except RequestParametersError as error:
38+
_LOGGER.error("Could not fetch stations, please check configuration: %s", error)
39+
raise ConfigEntryNotReady from error
40+
41+
entry.runtime_data = client
42+
43+
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
44+
45+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
46+
return True
47+
48+
49+
async def async_reload_entry(hass: HomeAssistant, entry: NSConfigEntry) -> None:
50+
"""Reload NS integration when options are updated."""
51+
await hass.config_entries.async_reload(entry.entry_id)
52+
53+
54+
async def async_unload_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool:
55+
"""Unload a config entry."""
56+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Config flow for Nederlandse Spoorwegen integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from ns_api import NSAPI, Station
9+
from requests.exceptions import (
10+
ConnectionError as RequestsConnectionError,
11+
HTTPError,
12+
Timeout,
13+
)
14+
import voluptuous as vol
15+
16+
from homeassistant.config_entries import (
17+
ConfigEntry,
18+
ConfigFlow,
19+
ConfigFlowResult,
20+
ConfigSubentryData,
21+
ConfigSubentryFlow,
22+
SubentryFlowResult,
23+
)
24+
from homeassistant.const import CONF_API_KEY
25+
from homeassistant.core import callback
26+
from homeassistant.helpers.selector import (
27+
SelectOptionDict,
28+
SelectSelector,
29+
SelectSelectorConfig,
30+
TimeSelector,
31+
)
32+
33+
from .const import (
34+
CONF_FROM,
35+
CONF_NAME,
36+
CONF_ROUTES,
37+
CONF_TIME,
38+
CONF_TO,
39+
CONF_VIA,
40+
DOMAIN,
41+
)
42+
43+
_LOGGER = logging.getLogger(__name__)
44+
45+
46+
class NSConfigFlow(ConfigFlow, domain=DOMAIN):
47+
"""Handle a config flow for Nederlandse Spoorwegen."""
48+
49+
VERSION = 1
50+
MINOR_VERSION = 1
51+
52+
async def async_step_user(
53+
self, user_input: dict[str, Any] | None = None
54+
) -> ConfigFlowResult:
55+
"""Handle the initial step of the config flow (API key)."""
56+
errors: dict[str, str] = {}
57+
if user_input is not None:
58+
self._async_abort_entries_match(user_input)
59+
client = NSAPI(user_input[CONF_API_KEY])
60+
try:
61+
await self.hass.async_add_executor_job(client.get_stations)
62+
except HTTPError:
63+
errors["base"] = "invalid_auth"
64+
except (RequestsConnectionError, Timeout):
65+
errors["base"] = "cannot_connect"
66+
except Exception:
67+
_LOGGER.exception("Unexpected exception validating API key")
68+
errors["base"] = "unknown"
69+
if not errors:
70+
return self.async_create_entry(
71+
title="Nederlandse Spoorwegen",
72+
data={CONF_API_KEY: user_input[CONF_API_KEY]},
73+
)
74+
return self.async_show_form(
75+
step_id="user",
76+
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
77+
errors=errors,
78+
)
79+
80+
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
81+
"""Handle import from YAML configuration."""
82+
self._async_abort_entries_match({CONF_API_KEY: import_data[CONF_API_KEY]})
83+
84+
client = NSAPI(import_data[CONF_API_KEY])
85+
try:
86+
stations = await self.hass.async_add_executor_job(client.get_stations)
87+
except HTTPError:
88+
return self.async_abort(reason="invalid_auth")
89+
except (RequestsConnectionError, Timeout):
90+
return self.async_abort(reason="cannot_connect")
91+
except Exception:
92+
_LOGGER.exception("Unexpected exception validating API key")
93+
return self.async_abort(reason="unknown")
94+
95+
station_codes = {station.code for station in stations}
96+
97+
subentries: list[ConfigSubentryData] = []
98+
for route in import_data.get(CONF_ROUTES, []):
99+
# Convert station codes to uppercase for consistency with UI routes
100+
for key in (CONF_FROM, CONF_TO, CONF_VIA):
101+
if key in route:
102+
route[key] = route[key].upper()
103+
if route[key] not in station_codes:
104+
return self.async_abort(reason="invalid_station")
105+
106+
subentries.append(
107+
ConfigSubentryData(
108+
title=route[CONF_NAME],
109+
subentry_type="route",
110+
data=route,
111+
unique_id=None,
112+
)
113+
)
114+
115+
return self.async_create_entry(
116+
title="Nederlandse Spoorwegen",
117+
data={CONF_API_KEY: import_data[CONF_API_KEY]},
118+
subentries=subentries,
119+
)
120+
121+
@classmethod
122+
@callback
123+
def async_get_supported_subentry_types(
124+
cls, config_entry: ConfigEntry
125+
) -> dict[str, type[ConfigSubentryFlow]]:
126+
"""Return subentries supported by this integration."""
127+
return {"route": RouteSubentryFlowHandler}
128+
129+
130+
class RouteSubentryFlowHandler(ConfigSubentryFlow):
131+
"""Handle subentry flow for adding and modifying routes."""
132+
133+
def __init__(self) -> None:
134+
"""Initialize route subentry flow."""
135+
self.stations: dict[str, Station] = {}
136+
137+
async def async_step_user(
138+
self, user_input: dict[str, Any] | None = None
139+
) -> SubentryFlowResult:
140+
"""Add a new route subentry."""
141+
if user_input is not None:
142+
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
143+
client = NSAPI(self._get_entry().data[CONF_API_KEY])
144+
if not self.stations:
145+
try:
146+
self.stations = {
147+
station.code: station
148+
for station in await self.hass.async_add_executor_job(
149+
client.get_stations
150+
)
151+
}
152+
except (RequestsConnectionError, Timeout, HTTPError, ValueError):
153+
return self.async_abort(reason="cannot_connect")
154+
155+
options = [
156+
SelectOptionDict(label=station.names["long"], value=code)
157+
for code, station in self.stations.items()
158+
]
159+
return self.async_show_form(
160+
step_id="user",
161+
data_schema=vol.Schema(
162+
{
163+
vol.Required(CONF_NAME): str,
164+
vol.Required(CONF_FROM): SelectSelector(
165+
SelectSelectorConfig(options=options, sort=True),
166+
),
167+
vol.Required(CONF_TO): SelectSelector(
168+
SelectSelectorConfig(options=options, sort=True),
169+
),
170+
vol.Optional(CONF_VIA): SelectSelector(
171+
SelectSelectorConfig(options=options, sort=True),
172+
),
173+
vol.Optional(CONF_TIME): TimeSelector(),
174+
}
175+
),
176+
)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Constants for the Nederlandse Spoorwegen integration."""
2+
3+
DOMAIN = "nederlandse_spoorwegen"
4+
5+
CONF_ROUTES = "routes"
6+
CONF_FROM = "from"
7+
CONF_TO = "to"
8+
CONF_VIA = "via"
9+
CONF_TIME = "time"
10+
CONF_NAME = "name"
11+
12+
# Attribute and schema keys
13+
ATTR_ROUTE = "route"
14+
ATTR_TRIPS = "trips"
15+
ATTR_FIRST_TRIP = "first_trip"
16+
ATTR_NEXT_TRIP = "next_trip"
17+
ATTR_ROUTES = "routes"

homeassistant/components/nederlandse_spoorwegen/manifest.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
22
"domain": "nederlandse_spoorwegen",
33
"name": "Nederlandse Spoorwegen (NS)",
4-
"codeowners": ["@YarmoM"],
4+
"codeowners": ["@YarmoM", "@heindrichpaul"],
5+
"config_flow": true,
56
"documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen",
7+
"integration_type": "service",
68
"iot_class": "cloud_polling",
79
"quality_scale": "legacy",
810
"requirements": ["nsapi==3.1.2"]

0 commit comments

Comments
 (0)