Skip to content

Commit 11f3728

Browse files
Crocmagnonclaude
andcommitted
Add TCL (Transports en commun à Lyon) integration
New integration for Lyon public transport and Vélo'v bike-sharing data. Supports config subentries for transit stops (with estimated/theoretical passage merging) and Vélo'v stations (with electrical/mechanical bike breakdown). Includes reconfiguration flow for credentials. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ce98758 commit 11f3728

File tree

16 files changed

+1757
-0
lines changed

16 files changed

+1757
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""The TCL integration."""
2+
3+
from __future__ import annotations
4+
5+
from transports_commun_lyon import TCLClient
6+
7+
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
10+
11+
from .coordinator import TCLConfigEntry, TCLCoordinator
12+
13+
PLATFORMS: list[Platform] = [Platform.SENSOR]
14+
15+
16+
async def async_setup_entry(hass: HomeAssistant, entry: TCLConfigEntry) -> bool:
17+
"""Set up TCL from a config entry."""
18+
session = async_get_clientsession(hass)
19+
client = TCLClient(
20+
session=session,
21+
username=entry.data.get(CONF_USERNAME),
22+
password=entry.data.get(CONF_PASSWORD),
23+
)
24+
25+
coordinator = TCLCoordinator(hass, entry, client)
26+
await coordinator.async_config_entry_first_refresh()
27+
28+
entry.runtime_data = coordinator
29+
30+
entry.async_on_unload(entry.add_update_listener(async_update_entry))
31+
32+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
33+
34+
return True
35+
36+
37+
async def async_update_entry(hass: HomeAssistant, entry: TCLConfigEntry) -> None:
38+
"""Handle config entry update (e.g., subentry changes)."""
39+
await hass.config_entries.async_reload(entry.entry_id)
40+
41+
42+
async def async_unload_entry(hass: HomeAssistant, entry: TCLConfigEntry) -> bool:
43+
"""Unload a config entry."""
44+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""Config flow for the TCL integration."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
from typing import Any
7+
8+
from transports_commun_lyon import PassageType, TCLClient
9+
import voluptuous as vol
10+
11+
from homeassistant.config_entries import (
12+
ConfigEntry,
13+
ConfigFlow,
14+
ConfigFlowResult,
15+
ConfigSubentryFlow,
16+
SubentryFlowResult,
17+
)
18+
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
19+
from homeassistant.core import callback
20+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
21+
22+
from .const import (
23+
CONF_LINE,
24+
CONF_STATION_ID,
25+
CONF_STOP_ID,
26+
DOMAIN,
27+
SUBENTRY_TYPE_STOP,
28+
SUBENTRY_TYPE_VELOV,
29+
)
30+
31+
STEP_USER_DATA_SCHEMA = vol.Schema(
32+
{
33+
vol.Optional(CONF_USERNAME): str,
34+
vol.Optional(CONF_PASSWORD): str,
35+
}
36+
)
37+
38+
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
39+
{
40+
vol.Required(CONF_USERNAME): str,
41+
vol.Required(CONF_PASSWORD): str,
42+
}
43+
)
44+
45+
STEP_STOP_DATA_SCHEMA = vol.Schema(
46+
{
47+
vol.Required(CONF_LINE): str,
48+
vol.Required(CONF_STOP_ID): vol.Coerce(int),
49+
vol.Optional(CONF_NAME): str,
50+
}
51+
)
52+
53+
STEP_VELOV_DATA_SCHEMA = vol.Schema(
54+
{
55+
vol.Required(CONF_STATION_ID): vol.Coerce(int),
56+
}
57+
)
58+
59+
60+
class TCLConfigFlow(ConfigFlow, domain=DOMAIN):
61+
"""Handle a config flow for TCL."""
62+
63+
VERSION = 1
64+
65+
@classmethod
66+
@callback
67+
def async_get_supported_subentry_types(
68+
cls, config_entry: ConfigEntry
69+
) -> dict[str, type[ConfigSubentryFlow]]:
70+
"""Return subentry types supported by this integration."""
71+
return {
72+
SUBENTRY_TYPE_STOP: StopSubentryFlowHandler,
73+
SUBENTRY_TYPE_VELOV: VelovSubentryFlowHandler,
74+
}
75+
76+
async def async_step_user(
77+
self, user_input: dict[str, Any] | None = None
78+
) -> ConfigFlowResult:
79+
"""Handle the initial step."""
80+
errors: dict[str, str] = {}
81+
82+
if user_input is not None:
83+
self._async_abort_entries_match(
84+
{CONF_USERNAME: user_input.get(CONF_USERNAME)}
85+
)
86+
87+
data: dict[str, Any] = {}
88+
if username := user_input.get(CONF_USERNAME):
89+
data[CONF_USERNAME] = username
90+
if password := user_input.get(CONF_PASSWORD):
91+
data[CONF_PASSWORD] = password
92+
93+
if not await self._test_connection(data):
94+
errors["base"] = "cannot_connect"
95+
else:
96+
return self.async_create_entry(title="TCL", data=data)
97+
98+
return self.async_show_form(
99+
step_id="user",
100+
data_schema=STEP_USER_DATA_SCHEMA,
101+
errors=errors,
102+
)
103+
104+
async def async_step_reconfigure(
105+
self, user_input: dict[str, Any] | None = None
106+
) -> ConfigFlowResult:
107+
"""Handle reconfiguration of the main config entry."""
108+
errors: dict[str, str] = {}
109+
110+
if user_input is not None:
111+
data = {
112+
CONF_USERNAME: user_input[CONF_USERNAME],
113+
CONF_PASSWORD: user_input[CONF_PASSWORD],
114+
}
115+
116+
if not await self._test_connection(data):
117+
errors["base"] = "cannot_connect"
118+
else:
119+
return self.async_update_reload_and_abort(
120+
self._get_reconfigure_entry(),
121+
data=data,
122+
)
123+
124+
return self.async_show_form(
125+
step_id="reconfigure",
126+
data_schema=self.add_suggested_values_to_schema(
127+
STEP_USER_DATA_SCHEMA,
128+
self._get_reconfigure_entry().data,
129+
),
130+
errors=errors,
131+
)
132+
133+
async def async_step_reauth(
134+
self, entry_data: Mapping[str, Any]
135+
) -> ConfigFlowResult:
136+
"""Handle initiation of re-authentication."""
137+
return await self.async_step_reauth_confirm()
138+
139+
async def async_step_reauth_confirm(
140+
self, user_input: dict[str, Any] | None = None
141+
) -> ConfigFlowResult:
142+
"""Handle re-authentication with new credentials."""
143+
errors: dict[str, str] = {}
144+
145+
if user_input is not None:
146+
data = {
147+
CONF_USERNAME: user_input[CONF_USERNAME],
148+
CONF_PASSWORD: user_input[CONF_PASSWORD],
149+
}
150+
if not await self._test_connection(data):
151+
errors["base"] = "cannot_connect"
152+
else:
153+
return self.async_update_reload_and_abort(
154+
self._get_reauth_entry(),
155+
data=data,
156+
)
157+
158+
return self.async_show_form(
159+
step_id="reauth_confirm",
160+
data_schema=STEP_REAUTH_DATA_SCHEMA,
161+
errors=errors,
162+
)
163+
164+
async def _test_connection(self, data: dict[str, Any]) -> bool:
165+
"""Test connectivity by making a dummy API call."""
166+
session = async_get_clientsession(self.hass)
167+
client = TCLClient(
168+
session=session,
169+
username=data.get(CONF_USERNAME),
170+
password=data.get(CONF_PASSWORD),
171+
)
172+
try:
173+
if data.get(CONF_USERNAME):
174+
await client.get_passages(
175+
ligne="__test__", stop_id=0, passage_type=PassageType.ESTIMATED
176+
)
177+
else:
178+
await client.get_velov_station(0)
179+
except Exception: # noqa: BLE001
180+
return False
181+
return True
182+
183+
184+
class StopSubentryFlowHandler(ConfigSubentryFlow):
185+
"""Handle a subentry flow for adding/editing a TCL stop."""
186+
187+
async def async_step_user(
188+
self, user_input: dict[str, Any] | None = None
189+
) -> SubentryFlowResult:
190+
"""Handle the user step to add a new stop."""
191+
entry = self._get_entry()
192+
if not entry.data.get(CONF_USERNAME):
193+
return self.async_abort(reason="auth_required")
194+
195+
if user_input is not None:
196+
line = user_input[CONF_LINE]
197+
stop_id = user_input[CONF_STOP_ID]
198+
unique_id = f"{line}_{stop_id}"
199+
200+
for subentry in entry.subentries.values():
201+
if subentry.unique_id == unique_id:
202+
return self.async_abort(reason="already_configured")
203+
204+
name = user_input.get(CONF_NAME) or f"{line} - Stop {stop_id}"
205+
return self.async_create_entry(
206+
title=name,
207+
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
208+
unique_id=unique_id,
209+
)
210+
211+
return self.async_show_form(
212+
step_id="user",
213+
data_schema=STEP_STOP_DATA_SCHEMA,
214+
)
215+
216+
async def async_step_reconfigure(
217+
self, user_input: dict[str, Any] | None = None
218+
) -> SubentryFlowResult:
219+
"""Handle reconfiguration of an existing stop."""
220+
subentry = self._get_reconfigure_subentry()
221+
222+
if user_input is not None:
223+
entry = self._get_entry()
224+
line = user_input[CONF_LINE]
225+
stop_id = user_input[CONF_STOP_ID]
226+
name = user_input.get(CONF_NAME) or f"{line} - Stop {stop_id}"
227+
self._async_update(
228+
entry,
229+
subentry,
230+
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
231+
title=name,
232+
)
233+
return self.async_abort(reason="reconfigure_successful")
234+
235+
return self.async_show_form(
236+
step_id="reconfigure",
237+
data_schema=self.add_suggested_values_to_schema(
238+
STEP_STOP_DATA_SCHEMA,
239+
{
240+
CONF_LINE: subentry.data[CONF_LINE],
241+
CONF_STOP_ID: subentry.data[CONF_STOP_ID],
242+
CONF_NAME: subentry.title,
243+
},
244+
),
245+
)
246+
247+
248+
class VelovSubentryFlowHandler(ConfigSubentryFlow):
249+
"""Handle a subentry flow for adding/editing a Vélo'v station."""
250+
251+
async def async_step_user(
252+
self, user_input: dict[str, Any] | None = None
253+
) -> SubentryFlowResult:
254+
"""Handle the user step to add a new Vélo'v station."""
255+
errors: dict[str, str] = {}
256+
257+
if user_input is not None:
258+
station_id = user_input[CONF_STATION_ID]
259+
unique_id = str(station_id)
260+
261+
entry = self._get_entry()
262+
for subentry in entry.subentries.values():
263+
if subentry.unique_id == unique_id:
264+
return self.async_abort(reason="already_configured")
265+
266+
try:
267+
title = await self._fetch_station_title(station_id)
268+
except Exception: # noqa: BLE001
269+
errors["base"] = "cannot_connect"
270+
else:
271+
if title is None:
272+
errors[CONF_STATION_ID] = "station_not_found"
273+
else:
274+
return self.async_create_entry(
275+
title=title,
276+
data={CONF_STATION_ID: station_id},
277+
unique_id=unique_id,
278+
)
279+
280+
return self.async_show_form(
281+
step_id="user",
282+
data_schema=STEP_VELOV_DATA_SCHEMA,
283+
errors=errors,
284+
)
285+
286+
async def async_step_reconfigure(
287+
self, user_input: dict[str, Any] | None = None
288+
) -> SubentryFlowResult:
289+
"""Handle reconfiguration of an existing Vélo'v station."""
290+
subentry = self._get_reconfigure_subentry()
291+
errors: dict[str, str] = {}
292+
293+
if user_input is not None:
294+
station_id = user_input[CONF_STATION_ID]
295+
try:
296+
title = await self._fetch_station_title(station_id)
297+
except Exception: # noqa: BLE001
298+
errors["base"] = "cannot_connect"
299+
else:
300+
if title is None:
301+
errors[CONF_STATION_ID] = "station_not_found"
302+
else:
303+
entry = self._get_entry()
304+
self._async_update(
305+
entry,
306+
subentry,
307+
data={CONF_STATION_ID: station_id},
308+
title=title,
309+
)
310+
return self.async_abort(reason="reconfigure_successful")
311+
312+
return self.async_show_form(
313+
step_id="reconfigure",
314+
data_schema=self.add_suggested_values_to_schema(
315+
STEP_VELOV_DATA_SCHEMA,
316+
{CONF_STATION_ID: subentry.data[CONF_STATION_ID]},
317+
),
318+
errors=errors,
319+
)
320+
321+
async def _fetch_station_title(self, station_id: int) -> str | None:
322+
"""Fetch the station name from the API, returning None if not found."""
323+
session = async_get_clientsession(self.hass)
324+
client = TCLClient(session=session)
325+
station = await client.get_velov_station(station_id)
326+
if station is None:
327+
return None
328+
return station.name
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Constants for the TCL integration."""
2+
3+
import logging
4+
5+
DOMAIN = "tcl"
6+
LOGGER = logging.getLogger(__package__)
7+
8+
SUBENTRY_TYPE_STOP = "stop"
9+
SUBENTRY_TYPE_VELOV = "velov"
10+
11+
CONF_LINE = "line"
12+
CONF_STOP_ID = "stop_id"
13+
CONF_STATION_ID = "station_id"
14+
15+
MAX_PASSAGES_PER_LINE = 3

0 commit comments

Comments
 (0)