Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 @@ -154,6 +154,7 @@ homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.data_grandlyon.*
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.

50 changes: 50 additions & 0 deletions homeassistant/components/data_grandlyon/__init__.py
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),
Comment thread
Crocmagnon marked this conversation as resolved.
Outdated
)

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)
146 changes: 146 additions & 0 deletions homeassistant/components/data_grandlyon/config_flow.py
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,
Comment thread
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()
Comment thread
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
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store required credentials directly from user_input instead of conditionally adding them, since the current truthy checks can drop empty strings and create entries missing required keys.

Suggested change
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 uses AI. Check for mistakes.
Comment thread
Crocmagnon marked this conversation as resolved.
Outdated

if error := await self._test_connection(data):
errors["base"] = error
else:
return self.async_create_entry(title="Data Grand Lyon", data=data)
Copy link

Copilot AI Apr 18, 2026

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).

Suggested change
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 uses AI. Check for mistakes.

return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

async def _test_connection(self, data: 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.
"""
if not data.get(CONF_USERNAME):
return "invalid_auth"
Copy link

Copilot AI Apr 18, 2026

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.

Suggested change
return "invalid_auth"
return None

Copilot uses AI. Check for mistakes.

session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=data.get(CONF_USERNAME),
password=data.get(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 not entry.data.get(CONF_USERNAME):
Copy link

Copilot AI Apr 18, 2026

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.

Suggested change
if not entry.data.get(CONF_USERNAME):
if not entry.data.get(CONF_USERNAME) or not entry.data.get(CONF_PASSWORD):

Copilot uses AI. Check for mistakes.
return self.async_abort(reason="auth_required")
Comment thread
Crocmagnon marked this conversation as resolved.
Outdated

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 = user_input.get(CONF_NAME) or 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_grandlyon/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_grandlyon"
LOGGER = logging.getLogger(__package__)

SUBENTRY_TYPE_STOP = "stop"

CONF_LINE = "line"
CONF_STOP_ID = "stop_id"
80 changes: 80 additions & 0 deletions homeassistant/components/data_grandlyon/coordinator.py
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]]
Comment thread
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),
Comment thread
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)
11 changes: 11 additions & 0 deletions homeassistant/components/data_grandlyon/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "data_grandlyon",
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.

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?

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.

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 tcl but it felt too restrictive vis-à-vis bike sharing and maybe later more data.

Comment thread
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",
Comment thread
Crocmagnon marked this conversation as resolved.
Outdated
"integration_type": "service",
"iot_class": "cloud_polling",
Comment thread
Crocmagnon marked this conversation as resolved.
Outdated
"quality_scale": "bronze",
"requirements": ["data-grand-lyon-ha==0.5.0"]
}
72 changes: 72 additions & 0 deletions homeassistant/components/data_grandlyon/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
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
Loading
Loading