-
-
Notifications
You must be signed in to change notification settings - Fork 37.3k
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 5 commits
ab0f34c
6430203
e924efe
c7473a0
3c1bafe
aaae33d
a3f064b
3893d37
1c85384
6963458
5733e44
711c18d
b88a666
a524bea
3d97fc9
db478cd
e61cdb0
c77df7b
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,143 @@ | ||||||||||||||||||||
| """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, | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+27
to
+31
|
||||||||||||||||||||
| ) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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, | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Crocmagnon marked this conversation as resolved.
|
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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.
Fix the exception handler syntax by using a tuple in the except clause so the module parses on current Python versions.
| except ClientError, TimeoutError: | |
| except (ClientError, TimeoutError): |
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" |
|
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. Why do you run a single coordinator? You could split it in theory
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. I'm not very familiar with HA core, this is my first experience. How do you suggest to split the coordinator? One for each sub-entry type?
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. Okay I just read your comment about filtering, do you get all data from the service?
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. The service provides a single endpoint for next departures with no filtering that I'm aware of. For a first implementation, I wanted to keep things simple and provide a single method with no data cache on the library client side. I'm planning on adding that in a future iteration so as to avoid unnecessary API calls. |
| 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): | ||||||||||||||||||
|
||||||||||||||||||
| if isinstance(result, BaseException): | |
| if isinstance(result, asyncio.CancelledError): | |
| raise result | |
| if isinstance(result, Exception): |
Copilot
AI
Apr 11, 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.
These warnings drop the traceback even though you have the actual exception object. Logging with exc_info=... (or using LOGGER.exception where appropriate) would preserve stack traces, which materially improves field diagnostics when API/library failures occur.
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 swallowing asyncio.CancelledError by treating only Exception results from asyncio.gather(..., return_exceptions=True) as failures (or explicitly re-raising cancellations).
Copilot
AI
Apr 23, 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 treat BaseException results from asyncio.gather(..., return_exceptions=True) as normal failures; only handle Exception and let cancellation/system-exiting exceptions propagate.
Catching BaseException here will also swallow asyncio.CancelledError (and similar) and can interfere with task cancellation during shutdown or reload. Checking for Exception (or explicitly re-raising CancelledError) avoids this.
Copilot
AI
Apr 11, 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.
The UpdateFailed message is user/diagnostic-facing and currently uses an internal-looking name (DataGrandLyon) and doesn’t provide actionable context. Consider using the proper integration name (Data Grand Lyon) and adding minimal detail (e.g., number of stop/station requests and that all failed) to improve troubleshooting without being overly verbose.
| raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed") | |
| raise UpdateFailed( | |
| f"Error fetching Data Grand Lyon data: all {len(stop_subentries)} " | |
| f"stop request(s) and {len(velov_subentries)} Vélo'v station " | |
| "request(s) failed" | |
| ) |
Copilot
AI
Apr 23, 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.
Make the UpdateFailed message consistent and user-friendly (e.g., include the proper integration name with spaces) since it can surface in logs/UI when the entry fails to refresh.
| raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed") | |
| raise UpdateFailed("Error fetching Data Grand Lyon data: all requests failed") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "domain": "data_grandlyon", | ||
| "name": "Data Grand Lyon", | ||
| "codeowners": ["@Crocmagnon"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/data_grandlyon", | ||
|
Comment on lines
+2
to
+6
|
||
| "integration_type": "service", | ||
| "iot_class": "cloud_polling", | ||
| "quality_scale": "bronze", | ||
| "requirements": ["data-grand-lyon-ha==0.5.0"] | ||
|
Comment on lines
+1
to
+10
|
||
| } | ||
| 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 | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| config-flow-test-coverage: done | ||
| config-flow: done | ||
| dependency-transparency: done | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| 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.