-
-
Notifications
You must be signed in to change notification settings - Fork 37.4k
Add Data Grand Lyon integration #167946
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Add Data Grand Lyon integration #167946
Changes from 4 commits
ab0f34c
6430203
e924efe
c7473a0
3c1bafe
aaae33d
a3f064b
3893d37
1c85384
6963458
5733e44
711c18d
b88a666
a524bea
3d97fc9
db478cd
e61cdb0
c77df7b
8124deb
4d3dae5
f995f16
2f16773
ae07cd9
2bc6088
1361b26
6cf8045
0076da6
a93643a
f45358b
31f8075
8e68635
153452d
c179787
8f46365
cdcf799
5f7ce6e
5d03547
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| """The Data Grand Lyon integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from data_grand_lyon_ha import DataGrandLyonClient | ||
|
|
||
| from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator | ||
|
|
||
| PLATFORMS: list[Platform] = [Platform.SENSOR] | ||
|
|
||
|
|
||
| async def async_setup_entry( | ||
| hass: HomeAssistant, entry: DataGrandLyonConfigEntry | ||
| ) -> bool: | ||
| """Set up Data Grand Lyon from a config entry.""" | ||
| session = async_get_clientsession(hass) | ||
| client = DataGrandLyonClient( | ||
| session=session, | ||
| username=entry.data.get(CONF_USERNAME), | ||
| password=entry.data.get(CONF_PASSWORD), | ||
| ) | ||
|
|
||
| coordinator = DataGrandLyonCoordinator(hass, entry, client) | ||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| entry.runtime_data = coordinator | ||
|
|
||
| entry.async_on_unload(entry.add_update_listener(async_update_entry)) | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_update_entry( | ||
| hass: HomeAssistant, entry: DataGrandLyonConfigEntry | ||
| ) -> None: | ||
| """Handle config entry update (e.g., subentry changes).""" | ||
| await hass.config_entries.async_reload(entry.entry_id) | ||
|
|
||
|
|
||
| async def async_unload_entry( | ||
| hass: HomeAssistant, entry: DataGrandLyonConfigEntry | ||
| ) -> bool: | ||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,146 @@ | ||||||||||||||||||||
| """Config flow for the Data Grand Lyon integration.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||
|
|
||||||||||||||||||||
| import logging | ||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from aiohttp import ClientError, ClientResponseError | ||||||||||||||||||||
| from data_grand_lyon_ha import DataGrandLyonClient, TclPassageType | ||||||||||||||||||||
| import voluptuous as vol | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from homeassistant.config_entries import ( | ||||||||||||||||||||
| ConfigEntry, | ||||||||||||||||||||
| ConfigFlow, | ||||||||||||||||||||
| ConfigFlowResult, | ||||||||||||||||||||
| ConfigSubentryFlow, | ||||||||||||||||||||
| SubentryFlowResult, | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME | ||||||||||||||||||||
| from homeassistant.core import callback | ||||||||||||||||||||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||||||||||||||||||||
|
|
||||||||||||||||||||
| from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, SUBENTRY_TYPE_STOP | ||||||||||||||||||||
|
|
||||||||||||||||||||
| _LOGGER = logging.getLogger(__name__) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| STEP_USER_DATA_SCHEMA = vol.Schema( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| vol.Required(CONF_USERNAME): str, | ||||||||||||||||||||
| vol.Required(CONF_PASSWORD): str, | ||||||||||||||||||||
| } | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| STEP_STOP_DATA_SCHEMA = vol.Schema( | ||||||||||||||||||||
| { | ||||||||||||||||||||
| vol.Required(CONF_LINE): str, | ||||||||||||||||||||
| vol.Required(CONF_STOP_ID): vol.Coerce(int), | ||||||||||||||||||||
| vol.Optional(CONF_NAME): str, | ||||||||||||||||||||
|
Crocmagnon marked this conversation as resolved.
Outdated
|
||||||||||||||||||||
| } | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN): | ||||||||||||||||||||
| """Handle a config flow for Data Grand Lyon.""" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| VERSION = 1 | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @classmethod | ||||||||||||||||||||
| @callback | ||||||||||||||||||||
| def async_get_supported_subentry_types( | ||||||||||||||||||||
| cls, config_entry: ConfigEntry | ||||||||||||||||||||
| ) -> dict[str, type[ConfigSubentryFlow]]: | ||||||||||||||||||||
| """Return subentry types supported by this integration.""" | ||||||||||||||||||||
| return { | ||||||||||||||||||||
| SUBENTRY_TYPE_STOP: StopSubentryFlowHandler, | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| async def async_step_user( | ||||||||||||||||||||
| self, user_input: dict[str, Any] | None = None | ||||||||||||||||||||
| ) -> ConfigFlowResult: | ||||||||||||||||||||
| """Handle the initial step.""" | ||||||||||||||||||||
| errors: dict[str, str] = {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if user_input is not None: | ||||||||||||||||||||
| self._async_abort_entries_match() | ||||||||||||||||||||
|
Crocmagnon marked this conversation as resolved.
Outdated
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| data: dict[str, Any] = {} | ||||||||||||||||||||
| if username := user_input.get(CONF_USERNAME): | ||||||||||||||||||||
| data[CONF_USERNAME] = username | ||||||||||||||||||||
| if password := user_input.get(CONF_PASSWORD): | ||||||||||||||||||||
| data[CONF_PASSWORD] = password | ||||||||||||||||||||
|
||||||||||||||||||||
| data: dict[str, Any] = {} | |
| if username := user_input.get(CONF_USERNAME): | |
| data[CONF_USERNAME] = username | |
| if password := user_input.get(CONF_PASSWORD): | |
| data[CONF_PASSWORD] = password | |
| data: dict[str, Any] = { | |
| CONF_USERNAME: user_input[CONF_USERNAME], | |
| CONF_PASSWORD: user_input[CONF_PASSWORD], | |
| } |
Copilot
AI
Apr 18, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid running the connection test when both username and password are omitted (currently an empty submission will still reach _test_connection and be treated as an auth error).
| if error := await self._test_connection(data): | |
| errors["base"] = error | |
| else: | |
| return self.async_create_entry(title="Data Grand Lyon", data=data) | |
| if data: | |
| if error := await self._test_connection(data): | |
| errors["base"] = error | |
| else: | |
| return self.async_create_entry(title="Data Grand Lyon", data=data) |
Copilot
AI
Apr 18, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t return invalid_auth just because username is missing; if anonymous mode is supported, treat missing credentials as a successful no-op test (or skip this method entirely) so the flow can create the entry.
| return "invalid_auth" | |
| return None |
Copilot
AI
Apr 18, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Require both username and password (or otherwise verify auth is configured) before allowing the stop subentry flow to proceed, since checking only CONF_USERNAME can allow creating subentries with incomplete credentials.
| if not entry.data.get(CONF_USERNAME): | |
| if not entry.data.get(CONF_USERNAME) or not entry.data.get(CONF_PASSWORD): |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| """Constants for the Data Grand Lyon integration.""" | ||
|
|
||
| import logging | ||
|
|
||
| DOMAIN = "data_grandlyon" | ||
| LOGGER = logging.getLogger(__package__) | ||
|
|
||
| SUBENTRY_TYPE_STOP = "stop" | ||
|
|
||
| CONF_LINE = "line" | ||
| CONF_STOP_ID = "stop_id" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| """DataUpdateCoordinator for the Data Grand Lyon integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| from dataclasses import dataclass | ||
| from datetime import timedelta | ||
|
|
||
| from data_grand_lyon_ha import DataGrandLyonClient, TclPassage | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, LOGGER, SUBENTRY_TYPE_STOP | ||
|
|
||
| type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator] | ||
|
|
||
|
|
||
| @dataclass | ||
| class DataGrandLyonData: | ||
| """Aggregated data from the Data Grand Lyon coordinator.""" | ||
|
|
||
| stops: dict[str, list[TclPassage]] | ||
|
Crocmagnon marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonData]): | ||
| """Coordinator for the Data Grand Lyon integration.""" | ||
|
|
||
| config_entry: DataGrandLyonConfigEntry | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| entry: DataGrandLyonConfigEntry, | ||
| client: DataGrandLyonClient, | ||
| ) -> None: | ||
| """Initialize the coordinator.""" | ||
| self.client = client | ||
| super().__init__( | ||
| hass, | ||
| LOGGER, | ||
| config_entry=entry, | ||
| name=DOMAIN, | ||
| update_interval=timedelta(minutes=1), | ||
|
Crocmagnon marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
|
||
| async def _async_update_data(self) -> DataGrandLyonData: | ||
| """Fetch data for all monitored stops.""" | ||
| stop_subentries = list( | ||
| self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP) | ||
| ) | ||
|
|
||
| stop_tasks = [ | ||
| self.client.get_tcl_passages( | ||
| ligne=subentry.data[CONF_LINE], | ||
| stop_id=subentry.data[CONF_STOP_ID], | ||
| ) | ||
| for subentry in stop_subentries | ||
| ] | ||
|
|
||
| stop_results: list[list[TclPassage] | BaseException] = await asyncio.gather( | ||
| *stop_tasks, return_exceptions=True | ||
| ) | ||
|
|
||
| stops: dict[str, list[TclPassage]] = {} | ||
| for i, subentry in enumerate(stop_subentries): | ||
| result = stop_results[i] | ||
| if isinstance(result, BaseException): | ||
| LOGGER.warning( | ||
| "Error fetching passages for stop %s: %s", | ||
| subentry.subentry_id, | ||
| result, | ||
| ) | ||
| continue | ||
| stops[subentry.subentry_id] = result | ||
|
|
||
| if stop_subentries and not stops: | ||
| raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed") | ||
| return DataGrandLyonData(stops=stops) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "domain": "data_grandlyon", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I also got wondering, why do we call it
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this specific case (bus/metro/tram departures), the users would interact with TCL means "Transports en commun lyonnais" which is French for "Public transportation in Lyon". "Grand Lyon" is the name of the metropolitan area around the city of Lyon, France. I guess users would search for "Lyon", "Vélo'v" or "TCL". At least that's what I did when I searched for this integration. I initially called the domain
Crocmagnon marked this conversation as resolved.
Outdated
|
||
| "name": "Data Grand Lyon", | ||
| "codeowners": ["@Crocmagnon"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/data_grandlyon", | ||
|
Crocmagnon marked this conversation as resolved.
Outdated
|
||
| "integration_type": "service", | ||
| "iot_class": "cloud_polling", | ||
|
Crocmagnon marked this conversation as resolved.
Outdated
|
||
| "quality_scale": "bronze", | ||
| "requirements": ["data-grand-lyon-ha==0.5.0"] | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| rules: | ||
| # Bronze | ||
| action-setup: | ||
| status: exempt | ||
| comment: This integration does not register custom actions. | ||
| appropriate-polling: done | ||
| brands: done | ||
| common-modules: done | ||
| config-flow-test-coverage: done | ||
| config-flow: done | ||
| dependency-transparency: done | ||
| docs-actions: | ||
| status: exempt | ||
| comment: This integration does not register custom actions. | ||
| docs-high-level-description: done | ||
| docs-installation-instructions: done | ||
| docs-removal-instructions: done | ||
| entity-event-setup: | ||
| status: exempt | ||
| comment: Entities use the coordinator pattern and do not subscribe to events. | ||
| entity-unique-id: done | ||
| has-entity-name: done | ||
| runtime-data: done | ||
| test-before-configure: done | ||
| test-before-setup: done | ||
| unique-config-entry: done | ||
|
|
||
| # Silver | ||
| action-exceptions: | ||
| status: exempt | ||
| comment: This integration does not register custom actions. | ||
| config-entry-unloading: done | ||
| docs-configuration-parameters: done | ||
| docs-installation-parameters: done | ||
| entity-unavailable: done | ||
| integration-owner: done | ||
| log-when-unavailable: done | ||
| parallel-updates: done | ||
| reauthentication-flow: todo | ||
| test-coverage: done | ||
|
|
||
| # Gold | ||
| devices: done | ||
| diagnostics: todo | ||
| discovery-update-info: | ||
| status: exempt | ||
| comment: This is a service integration; there are no discoverable devices. | ||
| discovery: | ||
| status: exempt | ||
| comment: This is a service integration; there are no discoverable devices. | ||
| docs-data-update: done | ||
| docs-examples: todo | ||
| docs-known-limitations: done | ||
| docs-supported-devices: done | ||
| docs-supported-functions: done | ||
| docs-troubleshooting: done | ||
| docs-use-cases: done | ||
| dynamic-devices: done | ||
| entity-category: done | ||
| entity-device-class: done | ||
| entity-disabled-by-default: done | ||
| entity-translations: todo | ||
| exception-translations: todo | ||
| icon-translations: todo | ||
| reconfiguration-flow: todo | ||
| repair-issues: todo | ||
| stale-devices: done | ||
|
|
||
| # Platinum | ||
| async-dependency: done | ||
| inject-websession: done | ||
| strict-typing: done |
Uh oh!
There was an error while loading. Please reload this page.