-
-
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
Merged
Merged
Add Data Grand Lyon integration #167946
Changes from 32 commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
ab0f34c
Add Data Grand Lyon integration
Crocmagnon 6430203
Reduce Data Grand Lyon integration scope per review feedback
Crocmagnon e924efe
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon c7473a0
Merge remote-tracking branch 'upstream/dev' into devel/Crocmagnon/tcl
Crocmagnon 3c1bafe
Fix config flow schema and tests: require credentials
Crocmagnon aaae33d
data_grandlyon: cleanup unnecessary code
Crocmagnon a3f064b
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon 3893d37
data_grandlyon: cleanup
Crocmagnon 1c85384
data_grandlyon: remove obsolete test
Crocmagnon 6963458
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon 5733e44
data_grandlyon: remove line attribute from sensor
Crocmagnon 711c18d
data_grandlyon: fix failing test
Crocmagnon b88a666
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon a524bea
data_grandlyon: remove config name
Crocmagnon 3d97fc9
data_grandlyon: preserve customizations
Crocmagnon db478cd
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon e61cdb0
data_grandlyon: fix docstring
Crocmagnon c77df7b
data_grandlyon: fix config flow already_configured
Crocmagnon 8124deb
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon 4d3dae5
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon f995f16
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon 2f16773
data grandlyon: split line and type in separate entities
Crocmagnon ae07cd9
data grandlyon(tests): increase fixture reuse
Crocmagnon 2bc6088
data grandlyon(tests): remove unnecessary block till done
Crocmagnon 1361b26
data grandlyon(tests): assert unique id in config flow
Crocmagnon 6cf8045
data grandlyon(tests): cleanup unused fixtures and cleanup 'already c…
Crocmagnon 0076da6
data grandlyon(tests): refactor config_flow tests
Crocmagnon a93643a
data grandlyon(tests): refactor sensor tests with snapshots
Crocmagnon f45358b
data grandlyon(tests): refactor subentry added
Crocmagnon 31f8075
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon 8e68635
data grandlyon: remove unused import
Crocmagnon 153452d
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon c179787
data grand lyon: rename integration
Crocmagnon 8f46365
rename 'passage' to 'departure'
Crocmagnon cdcf799
data grand lyon: set icon for 'direction' sensors
Crocmagnon 5f7ce6e
data grand lyon: fix test name
Crocmagnon 5d03547
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| } | ||
|
|
||
| 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]}) | ||
|
|
||
| if error := await self._test_connection(user_input): | ||
| errors["base"] = error | ||
| else: | ||
| return self.async_create_entry(title="Data Grand Lyon", data=user_input) | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=STEP_USER_DATA_SCHEMA, | ||
| errors=errors, | ||
| ) | ||
|
|
||
| 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: | ||
| return "cannot_connect" | ||
| except Exception: | ||
| _LOGGER.exception("Unexpected error testing Data Grand Lyon connection") | ||
| return "unknown" | ||
| 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] | ||
| 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, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
| 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 stops |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "domain": "data_grandlyon", | ||
|
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"] | ||
| } | ||
72 changes: 72 additions & 0 deletions
72
homeassistant/components/data_grandlyon/quality_scale.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
I also got wondering, why do we call it
data_...? It seems to be derived from the domain, but what is the name users generally interact with?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.
For this specific case (bus/metro/tram departures), the users would interact with
TCL. We could later add alerts about the TCL network as well, there's an API endpoint for that.I also at least plan on adding bike sharing data (a.k.a
Vélo'v). The data comes from the same source but it's a different "business domain".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
tclbut it felt too restrictive vis-à-vis bike sharing and maybe later more data.