Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ab0f34c
Add Data Grand Lyon integration
Crocmagnon Apr 10, 2026
6430203
Reduce Data Grand Lyon integration scope per review feedback
Crocmagnon Apr 18, 2026
e924efe
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon Apr 18, 2026
c7473a0
Merge remote-tracking branch 'upstream/dev' into devel/Crocmagnon/tcl
Crocmagnon Apr 18, 2026
3c1bafe
Fix config flow schema and tests: require credentials
Crocmagnon Apr 18, 2026
aaae33d
data_grandlyon: cleanup unnecessary code
Crocmagnon Apr 22, 2026
a3f064b
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon Apr 22, 2026
3893d37
data_grandlyon: cleanup
Crocmagnon Apr 22, 2026
1c85384
data_grandlyon: remove obsolete test
Crocmagnon Apr 22, 2026
6963458
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon Apr 22, 2026
5733e44
data_grandlyon: remove line attribute from sensor
Crocmagnon Apr 22, 2026
711c18d
data_grandlyon: fix failing test
Crocmagnon Apr 22, 2026
b88a666
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon Apr 23, 2026
a524bea
data_grandlyon: remove config name
Crocmagnon Apr 23, 2026
3d97fc9
data_grandlyon: preserve customizations
Crocmagnon Apr 23, 2026
db478cd
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon Apr 23, 2026
e61cdb0
data_grandlyon: fix docstring
Crocmagnon Apr 23, 2026
c77df7b
data_grandlyon: fix config flow already_configured
Crocmagnon Apr 23, 2026
8124deb
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon May 4, 2026
4d3dae5
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon May 4, 2026
f995f16
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon May 5, 2026
2f16773
data grandlyon: split line and type in separate entities
Crocmagnon May 5, 2026
ae07cd9
data grandlyon(tests): increase fixture reuse
Crocmagnon May 5, 2026
2bc6088
data grandlyon(tests): remove unnecessary block till done
Crocmagnon May 5, 2026
1361b26
data grandlyon(tests): assert unique id in config flow
Crocmagnon May 5, 2026
6cf8045
data grandlyon(tests): cleanup unused fixtures and cleanup 'already c…
Crocmagnon May 5, 2026
0076da6
data grandlyon(tests): refactor config_flow tests
Crocmagnon May 5, 2026
a93643a
data grandlyon(tests): refactor sensor tests with snapshots
Crocmagnon May 5, 2026
f45358b
data grandlyon(tests): refactor subentry added
Crocmagnon May 5, 2026
31f8075
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon May 5, 2026
8e68635
data grandlyon: remove unused import
Crocmagnon May 5, 2026
153452d
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon May 5, 2026
c179787
data grand lyon: rename integration
Crocmagnon May 5, 2026
8f46365
rename 'passage' to 'departure'
Crocmagnon May 5, 2026
cdcf799
data grand lyon: set icon for 'direction' sensors
Crocmagnon May 5, 2026
5f7ce6e
data grand lyon: fix test name
Crocmagnon May 5, 2026
5d03547
Merge branch 'dev' into devel/Crocmagnon/tcl
Crocmagnon May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.data_grand_lyon.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions homeassistant/components/data_grand_lyon/__init__.py
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)
132 changes: 132 additions & 0 deletions homeassistant/components/data_grand_lyon/config_flow.py
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,
}
Comment thread
Crocmagnon marked this conversation as resolved.
)

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,
}
Comment thread
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]})
Comment thread
Crocmagnon marked this conversation as resolved.

if error := await self._test_connection(user_input):
errors["base"] = error
Comment thread
Crocmagnon marked this conversation as resolved.
else:
return self.async_create_entry(title="Data Grand Lyon", data=user_input)
Comment thread
Crocmagnon marked this conversation as resolved.

return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
Comment thread
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:
Comment thread
Crocmagnon marked this conversation as resolved.
Comment thread
Crocmagnon marked this conversation as resolved.
Comment thread
Crocmagnon marked this conversation as resolved.
return "cannot_connect"
Comment thread
Crocmagnon marked this conversation as resolved.
except Exception:
_LOGGER.exception("Unexpected error testing Data Grand Lyon connection")
return "unknown"
Comment thread
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]
Comment thread
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,
)
11 changes: 11 additions & 0 deletions homeassistant/components/data_grand_lyon/const.py
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"
70 changes: 70 additions & 0 deletions homeassistant/components/data_grand_lyon/coordinator.py
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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):
Comment thread
Crocmagnon marked this conversation as resolved.
LOGGER.warning(
"Error fetching departures for stop %s: %s",
subentry.subentry_id,
result,
)
Comment thread
Crocmagnon marked this conversation as resolved.
Comment thread
Crocmagnon marked this conversation as resolved.
continue
Comment thread
Crocmagnon marked this conversation as resolved.
Comment thread
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")
Comment thread
Crocmagnon marked this conversation as resolved.
Comment thread
Crocmagnon marked this conversation as resolved.
return stops
15 changes: 15 additions & 0 deletions homeassistant/components/data_grand_lyon/icons.json
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"
}
}
}
}
11 changes: 11 additions & 0 deletions homeassistant/components/data_grand_lyon/manifest.json
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"]
Comment thread
Crocmagnon marked this conversation as resolved.
}
Comment thread
Crocmagnon marked this conversation as resolved.
72 changes: 72 additions & 0 deletions homeassistant/components/data_grand_lyon/quality_scale.yaml
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
Comment thread
Crocmagnon marked this conversation as resolved.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
Comment thread
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
Loading
Loading