-
-
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
Add Data Grand Lyon integration #167946
Changes from all 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,48 @@ | ||
| """The Data Grand Lyon integration.""" | ||
|
|
||
| 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[CONF_USERNAME], | ||
| password=entry.data[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,132 @@ | ||
| """Config flow for the Data Grand Lyon integration.""" | ||
|
|
||
| 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_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), | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| 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({CONF_USERNAME: user_input[CONF_USERNAME]}) | ||
|
Crocmagnon marked this conversation as resolved.
|
||
|
|
||
| if error := await self._test_connection(user_input): | ||
| errors["base"] = error | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| else: | ||
| return self.async_create_entry(title="Data Grand Lyon", data=user_input) | ||
|
Crocmagnon marked this conversation as resolved.
|
||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=STEP_USER_DATA_SCHEMA, | ||
| errors=errors, | ||
| ) | ||
|
Crocmagnon marked this conversation as resolved.
|
||
|
|
||
| async def _test_connection(self, user_input: dict[str, Any]) -> str | None: | ||
| """Test connectivity by making a dummy API call. | ||
|
|
||
| Returns None on success, or an error key for the errors dict. | ||
| """ | ||
| session = async_get_clientsession(self.hass) | ||
| client = DataGrandLyonClient( | ||
| session=session, | ||
| username=user_input[CONF_USERNAME], | ||
| password=user_input[CONF_PASSWORD], | ||
| ) | ||
| try: | ||
| # the upstream library filters in memory so these placeholder values | ||
| # won't trigger an exception ; the returned list will be empty | ||
| await client.get_tcl_passages( | ||
| ligne="__test__", stop_id=0, passage_type=TclPassageType.ESTIMATED | ||
| ) | ||
| except ClientResponseError as err: | ||
| if err.status in (401, 403): | ||
| return "invalid_auth" | ||
| return "cannot_connect" | ||
| except ClientError, TimeoutError: | ||
|
Crocmagnon marked this conversation as resolved.
Crocmagnon marked this conversation as resolved.
Crocmagnon marked this conversation as resolved.
|
||
| return "cannot_connect" | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| except Exception: | ||
| _LOGGER.exception("Unexpected error testing Data Grand Lyon connection") | ||
| return "unknown" | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| return None | ||
|
|
||
|
|
||
| class StopSubentryFlowHandler(ConfigSubentryFlow): | ||
| """Handle a subentry flow for adding a Data Grand Lyon stop.""" | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> SubentryFlowResult: | ||
| """Handle the user step to add a new stop.""" | ||
| entry = self._get_entry() | ||
|
|
||
| if user_input is not None: | ||
| line = user_input[CONF_LINE] | ||
| stop_id = user_input[CONF_STOP_ID] | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| unique_id = f"{line}_{stop_id}" | ||
|
|
||
| for subentry in entry.subentries.values(): | ||
| if subentry.unique_id == unique_id: | ||
| return self.async_abort(reason="already_configured") | ||
|
|
||
| name = f"{line} - Stop {stop_id}" | ||
| return self.async_create_entry( | ||
| title=name, | ||
| data={CONF_LINE: line, CONF_STOP_ID: stop_id}, | ||
| unique_id=unique_id, | ||
| ) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=STEP_STOP_DATA_SCHEMA, | ||
| ) | ||
| 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_grand_lyon" | ||
| 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
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. 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?
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. 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,70 @@ | ||
| """DataUpdateCoordinator for the Data Grand Lyon integration.""" | ||
|
|
||
| import asyncio | ||
| 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] | ||
|
|
||
|
|
||
| class DataGrandLyonCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]): | ||
| """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=5), | ||
| ) | ||
|
|
||
| async def _async_update_data(self) -> dict[str, list[TclPassage]]: | ||
| """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): | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| LOGGER.warning( | ||
| "Error fetching departures for stop %s: %s", | ||
| subentry.subentry_id, | ||
| result, | ||
| ) | ||
|
Crocmagnon marked this conversation as resolved.
Crocmagnon marked this conversation as resolved.
|
||
| continue | ||
|
Crocmagnon marked this conversation as resolved.
Crocmagnon marked this conversation as resolved.
|
||
| stops[subentry.subentry_id] = result | ||
|
|
||
| if stop_subentries and not stops: | ||
| raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed") | ||
|
Crocmagnon marked this conversation as resolved.
Crocmagnon marked this conversation as resolved.
|
||
| return stops | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "entity": { | ||
| "sensor": { | ||
| "next_departure_1_direction": { | ||
| "default": "mdi:directions" | ||
| }, | ||
| "next_departure_2_direction": { | ||
| "default": "mdi:directions" | ||
| }, | ||
| "next_departure_3_direction": { | ||
| "default": "mdi:directions" | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "domain": "data_grand_lyon", | ||
| "name": "Data Grand Lyon", | ||
| "codeowners": ["@Crocmagnon"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/data_grand_lyon", | ||
| "integration_type": "service", | ||
| "iot_class": "cloud_polling", | ||
| "quality_scale": "bronze", | ||
| "requirements": ["data-grand-lyon-ha==0.5.0"] | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| } | ||
|
Crocmagnon marked this conversation as resolved.
|
||
| 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.