Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
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)
143 changes: 143 additions & 0 deletions homeassistant/components/data_grandlyon/config_flow.py
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
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.

Allow credentials to be optional here if the integration supports an unauthenticated setup (tests submit an empty dict); otherwise the flow can never be completed without entering both fields.

Copilot uses AI. Check for mistakes.
)

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

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.
"""
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:
Comment thread
Crocmagnon marked this conversation as resolved.
Comment thread
Crocmagnon marked this conversation as resolved.
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.

Fix the exception handler syntax by using a tuple in the except clause so the module parses on current Python versions.

Suggested change
except ClientError, TimeoutError:
except (ClientError, TimeoutError):

Copilot uses AI. Check for mistakes.
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 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]
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 = 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
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
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
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,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):
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.

Handle asyncio.CancelledError separately (or only treat Exception as a per-stop failure) so cancellation isn’t swallowed/logged as a normal fetch error.

Suggested change
if isinstance(result, BaseException):
if isinstance(result, asyncio.CancelledError):
raise result
if isinstance(result, Exception):

Copilot uses AI. Check for mistakes.
LOGGER.warning(
"Error fetching passages for stop %s: %s",
subentry.subentry_id,
result,
)
Comment thread
Crocmagnon marked this conversation as resolved.
Comment on lines +62 to +66
Copy link

Copilot AI Apr 11, 2026

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 uses AI. Check for mistakes.
continue
Comment on lines +54 to +67
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 swallowing asyncio.CancelledError by treating only Exception results from asyncio.gather(..., return_exceptions=True) as failures (or explicitly re-raising cancellations).

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +67
Copy link

Copilot AI Apr 23, 2026

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 uses AI. Check for mistakes.
stops[subentry.subentry_id] = result

if stop_subentries and not stops:
raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed")
Copy link

Copilot AI Apr 11, 2026

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.

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

Copilot AI Apr 23, 2026

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.

Suggested change
raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed")
raise UpdateFailed("Error fetching Data Grand Lyon data: all requests failed")

Copilot uses AI. Check for mistakes.
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",
"name": "Data Grand Lyon",
"codeowners": ["@Crocmagnon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/data_grandlyon",
Comment on lines +2 to +6
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.

Align the PR description with the code in this PR: the current implementation only adds transit stop subentries/sensors (no bike sharing subentries or passage merging logic are present).

Copilot uses AI. Check for mistakes.
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["data-grand-lyon-ha==0.5.0"]
Comment on lines +1 to +10
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.

Align the PR description with the implementation, since this integration currently only adds transit stop subentries/sensors and does not include the described bike-station support.

Copilot uses AI. Check for mistakes.
}
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
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