diff --git a/.strict-typing b/.strict-typing index 695c7faa99d43..9877e8fa428d6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index 48c5d6a029fce..d650dec544556 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -345,6 +345,8 @@ CLAUDE.md @home-assistant/core /tests/components/cync/ @Kinachi249 /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike +/homeassistant/components/data_grandlyon/ @Crocmagnon +/tests/components/data_grandlyon/ @Crocmagnon /homeassistant/components/date/ @home-assistant/core /tests/components/date/ @home-assistant/core /homeassistant/components/datetime/ @home-assistant/core diff --git a/homeassistant/components/data_grandlyon/__init__.py b/homeassistant/components/data_grandlyon/__init__.py new file mode 100644 index 0000000000000..9c26f533fede9 --- /dev/null +++ b/homeassistant/components/data_grandlyon/__init__.py @@ -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), + ) + + 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) diff --git a/homeassistant/components/data_grandlyon/config_flow.py b/homeassistant/components/data_grandlyon/config_flow.py new file mode 100644 index 0000000000000..9e97b49199aad --- /dev/null +++ b/homeassistant/components/data_grandlyon/config_flow.py @@ -0,0 +1,369 @@ +"""Config flow for the Data Grand Lyon integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +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_STATION_ID, + CONF_STOP_ID, + DOMAIN, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Inclusive(CONF_USERNAME, "credentials"): str, + vol.Inclusive(CONF_PASSWORD, "credentials"): str, + } +) + +STEP_REAUTH_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, + } +) + +STEP_VELOV_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATION_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, + SUBENTRY_TYPE_VELOV: VelovSubentryFlowHandler, + } + + 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() + + 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 + + if error := await self._test_connection(data): + errors["base"] = error + else: + return self.async_create_entry(title="Data Grand Lyon", data=data) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the main config entry.""" + errors: dict[str, str] = {} + + if user_input is not None: + 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 + + if error := await self._test_connection(data): + errors["base"] = error + else: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data=data, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + self._get_reconfigure_entry().data, + ), + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle initiation of re-authentication.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication with new credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + data = { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + if error := await self._test_connection(data): + errors["base"] = error + else: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data=data, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_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. + """ + 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 + if data.get(CONF_USERNAME): + await client.get_tcl_passages( + ligne="__test__", stop_id=0, passage_type=TclPassageType.ESTIMATED + ) + else: + await client.get_velov_station(0) + 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/editing 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): + return self.async_abort(reason="auth_required") + + 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, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of an existing stop.""" + subentry = self._get_reconfigure_subentry() + + if user_input is not None: + entry = self._get_entry() + line = user_input[CONF_LINE] + stop_id = user_input[CONF_STOP_ID] + unique_id = f"{line}_{stop_id}" + + for existing_subentry in entry.subentries.values(): + if ( + existing_subentry.subentry_id != subentry.subentry_id + and existing_subentry.unique_id == unique_id + ): + return self.async_abort(reason="already_configured") + + name = user_input.get(CONF_NAME) or f"{line} - Stop {stop_id}" + self._async_update( + entry, + subentry, + data={CONF_LINE: line, CONF_STOP_ID: stop_id}, + title=name, + unique_id=unique_id, + ) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_STOP_DATA_SCHEMA, + { + CONF_LINE: subentry.data[CONF_LINE], + CONF_STOP_ID: subentry.data[CONF_STOP_ID], + CONF_NAME: subentry.title, + }, + ), + ) + + +class VelovSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding/editing a Vélo'v station.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the user step to add a new Vélo'v station.""" + errors: dict[str, str] = {} + + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + unique_id = str(station_id) + + entry = self._get_entry() + for subentry in entry.subentries.values(): + if subentry.unique_id == unique_id: + return self.async_abort(reason="already_configured") + + try: + title = await self._fetch_station_title(station_id) + except ClientError, TimeoutError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error fetching Vélo'v station") + errors["base"] = "unknown" + else: + if title is None: + errors[CONF_STATION_ID] = "station_not_found" + else: + return self.async_create_entry( + title=title, + data={CONF_STATION_ID: station_id}, + unique_id=unique_id, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_VELOV_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of an existing Vélo'v station.""" + subentry = self._get_reconfigure_subentry() + errors: dict[str, str] = {} + + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + unique_id = str(station_id) + + entry = self._get_entry() + for existing_subentry in entry.subentries.values(): + if ( + existing_subentry.subentry_id != subentry.subentry_id + and existing_subentry.unique_id == unique_id + ): + return self.async_abort(reason="already_configured") + + try: + title = await self._fetch_station_title(station_id) + except ClientError, TimeoutError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error fetching Vélo'v station") + errors["base"] = "unknown" + else: + if title is None: + errors[CONF_STATION_ID] = "station_not_found" + else: + self._async_update( + entry, + subentry, + data={CONF_STATION_ID: station_id}, + title=title, + unique_id=unique_id, + ) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_VELOV_DATA_SCHEMA, + {CONF_STATION_ID: subentry.data[CONF_STATION_ID]}, + ), + errors=errors, + ) + + async def _fetch_station_title(self, station_id: int) -> str | None: + """Fetch the station name from the API, returning None if not found.""" + session = async_get_clientsession(self.hass) + client = DataGrandLyonClient(session=session) + station = await client.get_velov_station(station_id) + if station is None: + return None + return station.name diff --git a/homeassistant/components/data_grandlyon/const.py b/homeassistant/components/data_grandlyon/const.py new file mode 100644 index 0000000000000..b3803c33f1cd9 --- /dev/null +++ b/homeassistant/components/data_grandlyon/const.py @@ -0,0 +1,13 @@ +"""Constants for the Data Grand Lyon integration.""" + +import logging + +DOMAIN = "data_grandlyon" +LOGGER = logging.getLogger(__package__) + +SUBENTRY_TYPE_STOP = "stop" +SUBENTRY_TYPE_VELOV = "velov" + +CONF_LINE = "line" +CONF_STOP_ID = "stop_id" +CONF_STATION_ID = "station_id" diff --git a/homeassistant/components/data_grandlyon/coordinator.py b/homeassistant/components/data_grandlyon/coordinator.py new file mode 100644 index 0000000000000..76081f06d47fc --- /dev/null +++ b/homeassistant/components/data_grandlyon/coordinator.py @@ -0,0 +1,111 @@ +"""DataUpdateCoordinator for the Data Grand Lyon integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import timedelta + +from data_grand_lyon_ha import DataGrandLyonClient, TclPassage, VelovStation + +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_STATION_ID, + CONF_STOP_ID, + DOMAIN, + LOGGER, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV, +) + +type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator] + + +@dataclass +class DataGrandLyonData: + """Aggregated data from the Data Grand Lyon coordinator.""" + + stops: dict[str, list[TclPassage]] = field(default_factory=dict) + velov: dict[str, VelovStation | None] = field(default_factory=dict) + + +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), + ) + + async def _async_update_data(self) -> DataGrandLyonData: + """Fetch data for all monitored stops and stations.""" + stop_subentries = list( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP) + ) + velov_subentries = list( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV) + ) + + stop_tasks = [ + self.client.get_tcl_passages( + ligne=subentry.data[CONF_LINE], + stop_id=subentry.data[CONF_STOP_ID], + ) + for subentry in stop_subentries + ] + velov_tasks = [ + self.client.get_velov_station(subentry.data[CONF_STATION_ID]) + for subentry in velov_subentries + ] + + stop_results: list[list[TclPassage] | BaseException] = await asyncio.gather( + *stop_tasks, return_exceptions=True + ) + velov_results: list[VelovStation | None | BaseException] = await asyncio.gather( + *velov_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 + + velov: dict[str, VelovStation | None] = {} + for i, subentry in enumerate(velov_subentries): + velov_result = velov_results[i] + if isinstance(velov_result, BaseException): + LOGGER.warning( + "Error fetching Vélo'v station %s: %s", + subentry.subentry_id, + velov_result, + ) + continue + velov[subentry.subentry_id] = velov_result + + if (stop_subentries or velov_subentries) and not stops and not velov: + raise UpdateFailed("Error fetching DataGrandLyon data: all requests failed") + return DataGrandLyonData(stops=stops, velov=velov) diff --git a/homeassistant/components/data_grandlyon/manifest.json b/homeassistant/components/data_grandlyon/manifest.json new file mode 100644 index 0000000000000..68af506c49814 --- /dev/null +++ b/homeassistant/components/data_grandlyon/manifest.json @@ -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", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "silver", + "requirements": ["data-grand-lyon-ha==0.5.0"] +} diff --git a/homeassistant/components/data_grandlyon/quality_scale.yaml b/homeassistant/components/data_grandlyon/quality_scale.yaml new file mode 100644 index 0000000000000..6397d0823428c --- /dev/null +++ b/homeassistant/components/data_grandlyon/quality_scale.yaml @@ -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: done + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Devices can't be discovered. + discovery: + status: exempt + comment: Devices can't be discovered. + 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: done + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/data_grandlyon/sensor.py b/homeassistant/components/data_grandlyon/sensor.py new file mode 100644 index 0000000000000..82d0843f1b8c0 --- /dev/null +++ b/homeassistant/components/data_grandlyon/sensor.py @@ -0,0 +1,225 @@ +"""Sensor platform for the Data Grand Lyon integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +from data_grand_lyon_ha import TclPassage, TclPassageType, VelovStation + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV +from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator + +PARALLEL_UPDATES = 0 + +_TZ_PARIS = ZoneInfo("Europe/Paris") + + +@dataclass(frozen=True, kw_only=True) +class DataGrandLyonStopSensorEntityDescription(SensorEntityDescription): + """Describes a Data Grand Lyon stop passage sensor entity.""" + + passage_index: int + + +@dataclass(frozen=True, kw_only=True) +class DataGrandLyonVelovSensorEntityDescription(SensorEntityDescription): + """Describes a Data Grand Lyon Vélo'v sensor entity.""" + + value_fn: Callable[[VelovStation], int | None] + + +STOP_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonStopSensorEntityDescription, ...] = ( + DataGrandLyonStopSensorEntityDescription( + key="next_passage_1", + translation_key="next_passage_1", + device_class=SensorDeviceClass.TIMESTAMP, + passage_index=0, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_passage_2", + translation_key="next_passage_2", + device_class=SensorDeviceClass.TIMESTAMP, + passage_index=1, + ), + DataGrandLyonStopSensorEntityDescription( + key="next_passage_3", + translation_key="next_passage_3", + device_class=SensorDeviceClass.TIMESTAMP, + passage_index=2, + ), +) + +VELOV_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonVelovSensorEntityDescription, ...] = ( + DataGrandLyonVelovSensorEntityDescription( + key="available_bikes", + translation_key="available_bikes", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda s: s.available_bikes, + ), + DataGrandLyonVelovSensorEntityDescription( + key="available_electrical_bikes", + translation_key="available_electrical_bikes", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda s: s.total_stands.electrical_bikes, + ), + DataGrandLyonVelovSensorEntityDescription( + key="available_mechanical_bikes", + translation_key="available_mechanical_bikes", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda s: s.total_stands.mechanical_bikes, + ), + DataGrandLyonVelovSensorEntityDescription( + key="available_bike_stands", + translation_key="available_bike_stands", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda s: s.available_bike_stands, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DataGrandLyonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Data Grand Lyon sensor entities.""" + coordinator = entry.runtime_data + + for subentry_id, subentry in entry.subentries.items(): + if subentry.subentry_type == SUBENTRY_TYPE_STOP: + async_add_entities( + ( + DataGrandLyonStopSensor(coordinator, subentry, description) + for description in STOP_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry_id, + ) + elif subentry.subentry_type == SUBENTRY_TYPE_VELOV: + async_add_entities( + ( + DataGrandLyonVelovSensor(coordinator, subentry, description) + for description in VELOV_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry_id, + ) + + +class DataGrandLyonStopSensor( + CoordinatorEntity[DataGrandLyonCoordinator], SensorEntity +): + """Sensor for Data Grand Lyon stop passages.""" + + _attr_has_entity_name = True + entity_description: DataGrandLyonStopSensorEntityDescription + + def __init__( + self, + coordinator: DataGrandLyonCoordinator, + subentry: ConfigSubentry, + description: DataGrandLyonStopSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._subentry_id = subentry.subentry_id + + self._attr_unique_id = f"{self._subentry_id}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._subentry_id)}, + name=subentry.title, + manufacturer="TCL", + model="Stop", + entry_type=DeviceEntryType.SERVICE, + ) + + def _get_passage(self) -> TclPassage | None: + """Return the passage for this sensor's index, or None.""" + passages = self.coordinator.data.stops.get(self._subentry_id, []) + index = self.entity_description.passage_index + if index >= len(passages): + return None + return passages[index] + + @property + def native_value(self) -> datetime | None: + """Return the passage time.""" + passage = self._get_passage() + if passage is None: + return None + dt = passage.heure_passage + if dt.tzinfo is None: + return dt.replace(tzinfo=_TZ_PARIS) + return dt + + @property + def icon(self) -> str: + """Return icon based on passage type.""" + passage = self._get_passage() + if passage is not None and passage.type == TclPassageType.ESTIMATED: + return "mdi:clock-check-outline" + return "mdi:clock-outline" + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return line and direction as extra attributes.""" + passage = self._get_passage() + if passage is None: + return None + return { + "line": passage.ligne, + "direction": passage.direction, + "type": passage.type.name.lower(), + } + + +class DataGrandLyonVelovSensor( + CoordinatorEntity[DataGrandLyonCoordinator], SensorEntity +): + """Sensor for Vélo'v station data.""" + + _attr_has_entity_name = True + entity_description: DataGrandLyonVelovSensorEntityDescription + + def __init__( + self, + coordinator: DataGrandLyonCoordinator, + subentry: ConfigSubentry, + description: DataGrandLyonVelovSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._subentry_id = subentry.subentry_id + + self._attr_unique_id = f"{self._subentry_id}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._subentry_id)}, + name=subentry.title, + manufacturer="JCDecaux", + model="Vélo'v", + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + station = self.coordinator.data.velov.get(self._subentry_id) + if station is None: + return None + return self.entity_description.value_fn(station) diff --git a/homeassistant/components/data_grandlyon/strings.json b/homeassistant/components/data_grandlyon/strings.json new file mode 100644 index 0000000000000..dcb95b90235a1 --- /dev/null +++ b/homeassistant/components/data_grandlyon/strings.json @@ -0,0 +1,143 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your password on data.grandlyon.com.", + "username": "Your username on data.grandlyon.com." + } + }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your password on data.grandlyon.com.", + "username": "Your username on data.grandlyon.com." + } + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your password on data.grandlyon.com.", + "username": "Your username on data.grandlyon.com." + } + } + } + }, + "config_subentries": { + "stop": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "auth_required": "Authentication is required to add a stop.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "entry_type": "Transit stop", + "initiate_flow": { + "user": "Add transit stop" + }, + "step": { + "reconfigure": { + "data": { + "line": "Line", + "name": "[%key:common::config_flow::data::name%]", + "stop_id": "Stop ID" + } + }, + "user": { + "data": { + "line": "Line", + "name": "[%key:common::config_flow::data::name%]", + "stop_id": "Stop ID" + } + } + } + }, + "velov": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "entry_type": "Vélo'v station", + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "station_not_found": "Station not found. Please check the station ID.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "initiate_flow": { + "user": "Add Vélo'v station" + }, + "step": { + "reconfigure": { + "data": { + "station_id": "Station ID" + } + }, + "user": { + "data": { + "station_id": "Station ID" + } + } + } + } + }, + "entity": { + "sensor": { + "available_bike_stands": { "name": "Available bike stands" }, + "available_bikes": { "name": "Available bikes" }, + "available_electrical_bikes": { "name": "Available electrical bikes" }, + "available_mechanical_bikes": { "name": "Available mechanical bikes" }, + "next_passage_1": { + "name": "Next passage 1", + "state_attributes": { + "type": { + "state": { + "estimated": "Estimated", + "theoretical": "Theoretical" + } + } + } + }, + "next_passage_2": { + "name": "Next passage 2", + "state_attributes": { + "type": { + "state": { + "estimated": "Estimated", + "theoretical": "Theoretical" + } + } + } + }, + "next_passage_3": { + "name": "Next passage 3", + "state_attributes": { + "type": { + "state": { + "estimated": "Estimated", + "theoretical": "Theoretical" + } + } + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 532c4fe74707b..b2c38e64fb8fb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -137,6 +137,7 @@ "crownstone", "cync", "daikin", + "data_grandlyon", "datadog", "deako", "deconz", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 11e5784078baa..3543cbb14b7f6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1249,6 +1249,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "data_grandlyon": { + "name": "Data Grand Lyon", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "datadog": { "name": "Datadog", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 9ec8a76c2188d..68408f6cfda60 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1294,6 +1294,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.data_grandlyon.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.date.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8787939f63b99..20f86f14eaf60 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,6 +777,9 @@ crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 +# homeassistant.components.data_grandlyon +data-grand-lyon-ha==0.5.0 + # homeassistant.components.datadog datadog==0.52.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8b33b0fae61a..2505a7cce59bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -689,6 +689,9 @@ crownstone-sse==2.0.5 # homeassistant.components.crownstone crownstone-uart==2.1.0 +# homeassistant.components.data_grandlyon +data-grand-lyon-ha==0.5.0 + # homeassistant.components.datadog datadog==0.52.0 diff --git a/tests/components/data_grandlyon/__init__.py b/tests/components/data_grandlyon/__init__.py new file mode 100644 index 0000000000000..305f1de429fb8 --- /dev/null +++ b/tests/components/data_grandlyon/__init__.py @@ -0,0 +1 @@ +"""Tests for the Data Grand Lyon integration.""" diff --git a/tests/components/data_grandlyon/conftest.py b/tests/components/data_grandlyon/conftest.py new file mode 100644 index 0000000000000..2138c558db3f2 --- /dev/null +++ b/tests/components/data_grandlyon/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the Data Grand Lyon tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.data_grandlyon.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/data_grandlyon/test_config_flow.py b/tests/components/data_grandlyon/test_config_flow.py new file mode 100644 index 0000000000000..128ea5202d674 --- /dev/null +++ b/tests/components/data_grandlyon/test_config_flow.py @@ -0,0 +1,1101 @@ +"""Test the Data Grand Lyon config flow.""" + +from datetime import datetime +from unittest.mock import AsyncMock, patch + +from aiohttp import ClientConnectionError, ClientResponseError +from data_grand_lyon_ha import ( + VelovAvailabilityLevel, + VelovBikeStandAvailability, + VelovStation, + VelovStationStatus, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.data_grandlyon.const import ( + CONF_LINE, + CONF_STATION_ID, + CONF_STOP_ID, + DOMAIN, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV, +) +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_VELOV_STATION = VelovStation( + number=1002, + name="Gare Part-Dieu", + address="Place Charles Béraudier", + commune="Lyon", + status=VelovStationStatus.OPEN, + availability=VelovAvailabilityLevel.GREEN, + lat=45.76, + lng=4.86, + bike_stands=20, + available_bikes=12, + available_bike_stands=8, + banking=True, + last_update=datetime(2026, 4, 10, 12, 0), + total_stands=VelovBikeStandAvailability( + bikes=12, + electrical_bikes=5, + electrical_internal_battery_bikes=3, + electrical_removable_battery_bikes=2, + mechanical_bikes=7, + stands=8, + capacity=20, + ), +) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + +@pytest.fixture +def mock_config_entry_no_auth() -> MockConfigEntry: + """Create a mock config entry without credentials.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={}, + ) + + +@pytest.fixture +def mock_config_entry_with_stop_subentry() -> MockConfigEntry: + """Create a mock config entry with a stop subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={CONF_LINE: "C3", CONF_STOP_ID: 123}, + subentry_id="stop_1", + subentry_type=SUBENTRY_TYPE_STOP, + title="C3 - Stop 123", + unique_id="C3_123", + ) + ], + ) + + +@pytest.fixture +def mock_config_entry_with_velov_subentry() -> MockConfigEntry: + """Create a mock config entry with a Vélo'v subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={CONF_STATION_ID: 1002}, + subentry_id="velov_1", + subentry_type=SUBENTRY_TYPE_VELOV, + title="Gare Part-Dieu", + unique_id="1002", + ) + ], + ) + + +@pytest.fixture +def mock_config_entry_with_two_velov_subentries() -> MockConfigEntry: + """Create a mock config entry with two Vélo'v subentries.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={CONF_STATION_ID: 1002}, + subentry_id="velov_1", + subentry_type=SUBENTRY_TYPE_VELOV, + title="Gare Part-Dieu", + unique_id="1002", + ), + config_entries.ConfigSubentryData( + data={CONF_STATION_ID: 2001}, + subentry_id="velov_2", + subentry_type=SUBENTRY_TYPE_VELOV, + title="Bellecour", + unique_id="2001", + ), + ], + ) + + +# Main config flow tests + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form and can create an entry with credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Data Grand Lyon" + assert result["data"] == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_credentials( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we can create an entry without credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Data Grand Lyon" + assert result["data"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we show an error when the API is unreachable.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=ClientConnectionError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_cannot_connect_no_credentials( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we show an error when the API is unreachable without credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + side_effect=ClientConnectionError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we abort if already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + # Second flow shows the form but aborts on submit + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring the main entry.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-pass", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-pass", + } + + +async def test_reconfigure_remove_credentials( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring to remove credentials.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == {} + + +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfigure shows error when API is unreachable.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=ClientConnectionError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +# Stop subentry tests + + +async def test_stop_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test adding a stop subentry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "C3", CONF_STOP_ID: 456}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "C3 - Stop 456" + assert result["data"] == {CONF_LINE: "C3", CONF_STOP_ID: 456} + + +async def test_stop_subentry_flow_with_custom_name( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test adding a stop subentry with a custom name.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "T1", CONF_STOP_ID: 789, CONF_NAME: "My Stop"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "My Stop" + assert result["data"] == {CONF_LINE: "T1", CONF_STOP_ID: 789} + + +async def test_stop_subentry_aborts_without_auth( + hass: HomeAssistant, + mock_config_entry_no_auth: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test stop subentry aborts when no credentials are configured.""" + mock_config_entry_no_auth.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_no_auth.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry_no_auth.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "auth_required" + + +async def test_stop_subentry_reconfigure( + hass: HomeAssistant, + mock_config_entry_with_stop_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring a stop subentry.""" + mock_config_entry_with_stop_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop_subentry.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_with_stop_subentry.start_subentry_reconfigure_flow( + hass, "stop_1" + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "T4", CONF_STOP_ID: 999, CONF_NAME: "Renamed Stop"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry_with_stop_subentry.subentries["stop_1"] + assert subentry.data == {CONF_LINE: "T4", CONF_STOP_ID: 999} + assert subentry.title == "Renamed Stop" + + +async def test_stop_subentry_reconfigure_default_name( + hass: HomeAssistant, + mock_config_entry_with_stop_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring a stop subentry without providing a name.""" + mock_config_entry_with_stop_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop_subentry.entry_id) + await hass.async_block_till_done() + + result = await mock_config_entry_with_stop_subentry.start_subentry_reconfigure_flow( + hass, "stop_1" + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "T4", CONF_STOP_ID: 999}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry_with_stop_subentry.subentries["stop_1"] + assert subentry.title == "T4 - Stop 999" + + +# Vélo'v subentry tests + + +async def test_velov_subentry_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test adding a Vélo'v subentry.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_VELOV), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=MOCK_VELOV_STATION, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gare Part-Dieu" + assert result["data"] == {CONF_STATION_ID: 1002} + + +async def test_velov_subentry_station_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v subentry with an invalid station ID.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_VELOV), + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 9999}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_STATION_ID: "station_not_found"} + + # Recover with a valid station + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=MOCK_VELOV_STATION, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Gare Part-Dieu" + + +async def test_velov_subentry_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v subentry when the API is unreachable.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_VELOV), + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + side_effect=ClientConnectionError(), + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=MOCK_VELOV_STATION, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_velov_subentry_reconfigure( + hass: HomeAssistant, + mock_config_entry_with_velov_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring a Vélo'v subentry.""" + mock_config_entry_with_velov_subentry.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_velov_subentry.entry_id + ) + await hass.async_block_till_done() + + new_station = VelovStation( + number=2001, + name="Bellecour", + address="Place Bellecour", + commune="Lyon", + status=VelovStationStatus.OPEN, + availability=VelovAvailabilityLevel.GREEN, + lat=45.75, + lng=4.83, + bike_stands=30, + available_bikes=20, + available_bike_stands=10, + banking=True, + last_update=datetime(2026, 4, 10, 12, 0), + total_stands=VelovBikeStandAvailability( + bikes=20, + electrical_bikes=10, + electrical_internal_battery_bikes=5, + electrical_removable_battery_bikes=5, + mechanical_bikes=10, + stands=10, + capacity=30, + ), + ) + + result = ( + await mock_config_entry_with_velov_subentry.start_subentry_reconfigure_flow( + hass, "velov_1" + ) + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=new_station, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 2001}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + subentry = mock_config_entry_with_velov_subentry.subentries["velov_1"] + assert subentry.data == {CONF_STATION_ID: 2001} + assert subentry.title == "Bellecour" + + +async def test_velov_subentry_reconfigure_not_found( + hass: HomeAssistant, + mock_config_entry_with_velov_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring a Vélo'v subentry with invalid station.""" + mock_config_entry_with_velov_subentry.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_velov_subentry.entry_id + ) + await hass.async_block_till_done() + + result = ( + await mock_config_entry_with_velov_subentry.start_subentry_reconfigure_flow( + hass, "velov_1" + ) + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=None, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 9999}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_STATION_ID: "station_not_found"} + + +async def test_velov_subentry_reconfigure_cannot_connect( + hass: HomeAssistant, + mock_config_entry_with_velov_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reconfiguring a Vélo'v subentry when API fails.""" + mock_config_entry_with_velov_subentry.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_velov_subentry.entry_id + ) + await hass.async_block_till_done() + + result = ( + await mock_config_entry_with_velov_subentry.start_subentry_reconfigure_flow( + hass, "velov_1" + ) + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + side_effect=ClientConnectionError(), + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_stop_subentry_already_configured( + hass: HomeAssistant, + mock_config_entry_with_stop_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test stop subentry aborts if same line+stop already exists.""" + mock_config_entry_with_stop_subentry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop_subentry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry_with_stop_subentry.entry_id, SUBENTRY_TYPE_STOP), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "C3", CONF_STOP_ID: 123}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_velov_subentry_already_configured( + hass: HomeAssistant, + mock_config_entry_with_velov_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v subentry aborts if same station already exists.""" + mock_config_entry_with_velov_subentry.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_velov_subentry.entry_id + ) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry_with_velov_subentry.entry_id, SUBENTRY_TYPE_VELOV), + context={"source": config_entries.SOURCE_USER}, + ) + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_velov_subentry_reconfigure_already_configured( + hass: HomeAssistant, + mock_config_entry_with_two_velov_subentries: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v reconfigure aborts if station_id collides with another subentry.""" + mock_config_entry_with_two_velov_subentries.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_two_velov_subentries.entry_id + ) + await hass.async_block_till_done() + + # Reconfigure velov_2 (station 2001) to use station 1002 which is already velov_1 + result = await mock_config_entry_with_two_velov_subentries.start_subentry_reconfigure_flow( + hass, "velov_2" + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_velov_subentry_reconfigure_same_station( + hass: HomeAssistant, + mock_config_entry_with_velov_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v reconfigure allows keeping the same station_id.""" + mock_config_entry_with_velov_subentry.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_velov_subentry.entry_id + ) + await hass.async_block_till_done() + + result = ( + await mock_config_entry_with_velov_subentry.start_subentry_reconfigure_flow( + hass, "velov_1" + ) + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + return_value=MOCK_VELOV_STATION, + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + +# Reauth tests + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauth flow with valid credentials.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "new-user", CONF_PASSWORD: "new-pass"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: "new-user", + CONF_PASSWORD: "new-pass", + } + + +async def test_reauth_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test the reauth flow when connection fails.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=ClientConnectionError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Recover + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +# Error type differentiation tests + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we show invalid_auth on 401 response.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=ClientResponseError(None, None, status=401), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "wrong"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_unknown_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we show unknown on unexpected exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=RuntimeError("unexpected"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_form_http_error_non_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we show cannot_connect on non-auth HTTP errors (e.g. 500).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_tcl_passages", + side_effect=ClientResponseError(None, None, status=500), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_stop_subentry_reconfigure_collision( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test stop reconfigure aborts if new line+stop collides with another subentry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + config_entries.ConfigSubentryData( + data={CONF_LINE: "C3", CONF_STOP_ID: 123}, + subentry_id="stop_1", + subentry_type=SUBENTRY_TYPE_STOP, + title="C3 - Stop 123", + unique_id="C3_123", + ), + config_entries.ConfigSubentryData( + data={CONF_LINE: "T1", CONF_STOP_ID: 456}, + subentry_id="stop_2", + subentry_type=SUBENTRY_TYPE_STOP, + title="T1 - Stop 456", + unique_id="T1_456", + ), + ], + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Reconfigure stop_2 to use C3/123 which is already stop_1 + result = await entry.start_subentry_reconfigure_flow(hass, "stop_2") + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_LINE: "C3", CONF_STOP_ID: 123}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_velov_subentry_unknown_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v subentry shows unknown on unexpected exceptions.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, SUBENTRY_TYPE_VELOV), + context={"source": config_entries.SOURCE_USER}, + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + side_effect=RuntimeError("unexpected"), + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 9999}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_velov_subentry_reconfigure_unknown_error( + hass: HomeAssistant, + mock_config_entry_with_velov_subentry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test Vélo'v reconfigure shows unknown on unexpected exceptions.""" + mock_config_entry_with_velov_subentry.add_to_hass(hass) + await hass.config_entries.async_setup( + mock_config_entry_with_velov_subentry.entry_id + ) + await hass.async_block_till_done() + + result = ( + await mock_config_entry_with_velov_subentry.start_subentry_reconfigure_flow( + hass, "velov_1" + ) + ) + + with patch( + "homeassistant.components.data_grandlyon.config_flow.DataGrandLyonClient.get_velov_station", + side_effect=RuntimeError("unexpected"), + ): + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_STATION_ID: 1002}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/data_grandlyon/test_sensor.py b/tests/components/data_grandlyon/test_sensor.py new file mode 100644 index 0000000000000..ec43d59edbeee --- /dev/null +++ b/tests/components/data_grandlyon/test_sensor.py @@ -0,0 +1,438 @@ +"""Tests for the Data Grand Lyon sensor platform.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo + +from data_grand_lyon_ha import ( + TclPassage, + TclPassageType, + VelovAvailabilityLevel, + VelovBikeStandAvailability, + VelovStation, + VelovStationStatus, +) +import pytest + +from homeassistant.components.data_grandlyon.const import ( + CONF_LINE, + CONF_STATION_ID, + CONF_STOP_ID, + DOMAIN, + SUBENTRY_TYPE_STOP, + SUBENTRY_TYPE_VELOV, +) +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TZ_PARIS = ZoneInfo("Europe/Paris") + +MOCK_PASSAGES = [ + TclPassage( + id=100, + ligne="C3", + direction="Gare Part-Dieu", + delai_passage="3 min", + type=TclPassageType.ESTIMATED, + heure_passage=datetime(2026, 4, 10, 14, 3), + id_tarret_destination=0, + course_theorique="A", + ), + TclPassage( + id=100, + ligne="C3", + direction="Gare St-Paul", + delai_passage="8 min", + type=TclPassageType.THEORETICAL, + heure_passage=datetime(2026, 4, 10, 14, 8), + id_tarret_destination=0, + course_theorique="B", + ), +] + +MOCK_VELOV_STATION = VelovStation( + number=1002, + name="Gare Part-Dieu", + address="Place Charles Béraudier", + commune="Lyon", + status=VelovStationStatus.OPEN, + availability=VelovAvailabilityLevel.GREEN, + lat=45.76, + lng=4.86, + bike_stands=20, + available_bikes=12, + available_bike_stands=8, + banking=True, + last_update=datetime(2026, 4, 10, 12, 0, tzinfo=UTC), + total_stands=VelovBikeStandAvailability( + bikes=12, + electrical_bikes=5, + electrical_internal_battery_bikes=3, + electrical_removable_battery_bikes=2, + mechanical_bikes=7, + stands=8, + capacity=20, + ), +) + + +@pytest.fixture +def mock_config_entry_with_stop() -> MockConfigEntry: + """Create a config entry with a stop subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + ConfigSubentryData( + data={CONF_LINE: "C3", CONF_STOP_ID: 100}, + subentry_id="stop_1", + subentry_type=SUBENTRY_TYPE_STOP, + title="C3 - Stop 100", + unique_id="C3_100", + ) + ], + ) + + +@pytest.fixture +def mock_config_entry_with_velov() -> MockConfigEntry: + """Create a config entry with a Vélo'v subentry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + ConfigSubentryData( + data={CONF_STATION_ID: 1002}, + subentry_id="velov_1", + subentry_type=SUBENTRY_TYPE_VELOV, + title="Gare Part-Dieu", + unique_id="1002", + ) + ], + ) + + +@pytest.fixture +def mock_tcl_client() -> Generator[AsyncMock]: + """Mock DataGrandLyonClient for coordinator.""" + with patch( + "homeassistant.components.data_grandlyon.DataGrandLyonClient", autospec=True + ) as mock_cls: + client = mock_cls.return_value + client.get_tcl_passages.return_value = MOCK_PASSAGES + client.get_velov_station.return_value = MOCK_VELOV_STATION + yield client + + +# Stop sensor tests + + +async def test_stop_sensor_native_value_timezone( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that naive datetimes are localized to Europe/Paris.""" + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.c3_stop_100_next_passage_1") + assert state is not None + # Naive 14:03 localized to Europe/Paris (CEST = UTC+2) → stored as UTC 12:03 + assert state.state == datetime(2026, 4, 10, 12, 3, tzinfo=UTC).isoformat() + + +async def test_stop_sensor_icon_estimated( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that estimated passages get the check-outline icon.""" + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + # First passage is ESTIMATED + state = hass.states.get("sensor.c3_stop_100_next_passage_1") + assert state is not None + assert state.attributes["icon"] == "mdi:clock-check-outline" + + +async def test_stop_sensor_icon_theoretical( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that theoretical passages get the plain clock icon.""" + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + # Second passage is THEORETICAL + state = hass.states.get("sensor.c3_stop_100_next_passage_2") + assert state is not None + assert state.attributes["icon"] == "mdi:clock-outline" + + +async def test_stop_sensor_extra_attributes( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that line, direction, and type are exposed as attributes.""" + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.c3_stop_100_next_passage_1") + assert state is not None + assert state.attributes["line"] == "C3" + assert state.attributes["direction"] == "Gare Part-Dieu" + assert state.attributes["type"] == "estimated" + + state2 = hass.states.get("sensor.c3_stop_100_next_passage_2") + assert state2 is not None + assert state2.attributes["type"] == "theoretical" + + +async def test_stop_sensor_no_data( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that sensors with no passage data return unknown.""" + mock_tcl_client.get_tcl_passages.return_value = [] + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.c3_stop_100_next_passage_1") + assert state is not None + assert state.state == "unknown" + + +async def test_stop_sensor_third_passage_missing( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that the third passage sensor is unknown when only 2 passages exist.""" + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + # Only 2 mock passages, third should be unknown + state = hass.states.get("sensor.c3_stop_100_next_passage_3") + assert state is not None + assert state.state == "unknown" + + +# Vélo'v sensor tests + + +async def test_velov_sensor_available_bikes( + hass: HomeAssistant, + mock_config_entry_with_velov: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test Vélo'v available bikes sensor.""" + mock_config_entry_with_velov.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_velov.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gare_part_dieu_available_bikes") + assert state is not None + assert state.state == "12" + + +async def test_velov_sensor_electrical_bikes( + hass: HomeAssistant, + mock_config_entry_with_velov: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test Vélo'v electrical bikes sensor.""" + mock_config_entry_with_velov.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_velov.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gare_part_dieu_available_electrical_bikes") + assert state is not None + assert state.state == "5" + + +async def test_velov_sensor_mechanical_bikes( + hass: HomeAssistant, + mock_config_entry_with_velov: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test Vélo'v mechanical bikes sensor.""" + mock_config_entry_with_velov.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_velov.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gare_part_dieu_available_mechanical_bikes") + assert state is not None + assert state.state == "7" + + +async def test_velov_sensor_available_stands( + hass: HomeAssistant, + mock_config_entry_with_velov: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test Vélo'v available stands sensor.""" + mock_config_entry_with_velov.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_velov.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gare_part_dieu_available_bike_stands") + assert state is not None + assert state.state == "8" + + +async def test_stop_sensor_aware_datetime_passthrough( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that already timezone-aware datetimes are passed through unchanged.""" + aware_passage = TclPassage( + id=100, + ligne="C3", + direction="Gare Part-Dieu", + delai_passage="3 min", + type=TclPassageType.ESTIMATED, + heure_passage=datetime(2026, 4, 10, 14, 3, tzinfo=TZ_PARIS), + id_tarret_destination=0, + course_theorique="A", + ) + mock_tcl_client.get_tcl_passages.return_value = [aware_passage] + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.c3_stop_100_next_passage_1") + assert state is not None + # Already aware at CEST (UTC+2), stored as UTC 12:03 + assert state.state == datetime(2026, 4, 10, 12, 3, tzinfo=UTC).isoformat() + + +async def test_velov_sensor_station_missing_from_data( + hass: HomeAssistant, + mock_config_entry_with_velov: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test Vélo'v sensor returns unknown when station is not in coordinator data.""" + mock_tcl_client.get_velov_station.return_value = None + mock_config_entry_with_velov.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_velov.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gare_part_dieu_available_bikes") + assert state is not None + assert state.state == "unknown" + + +# Coordinator error handling tests + + +async def test_coordinator_stop_fetch_error( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator handles stop fetch errors gracefully.""" + mock_tcl_client.get_tcl_passages.side_effect = ConnectionError("API down") + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + # Single subentry fails → UpdateFailed → entry not loaded, sensors unavailable + state = hass.states.get("sensor.c3_stop_100_next_passage_1") + assert state is None or state.state == "unavailable" + + +async def test_coordinator_velov_fetch_error( + hass: HomeAssistant, + mock_config_entry_with_velov: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator handles Vélo'v fetch errors gracefully.""" + mock_tcl_client.get_velov_station.side_effect = ConnectionError("API down") + mock_config_entry_with_velov.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_velov.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.gare_part_dieu_available_bikes") + assert state is None or state.state == "unavailable" + + +async def test_coordinator_partial_failure( + hass: HomeAssistant, + mock_tcl_client: AsyncMock, +) -> None: + """Test coordinator succeeds when one subentry fails but another succeeds.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Data Grand Lyon", + data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + subentries_data=[ + ConfigSubentryData( + data={CONF_LINE: "C3", CONF_STOP_ID: 100}, + subentry_id="stop_1", + subentry_type=SUBENTRY_TYPE_STOP, + title="C3 - Stop 100", + unique_id="C3_100", + ), + ConfigSubentryData( + data={CONF_STATION_ID: 1002}, + subentry_id="velov_1", + subentry_type=SUBENTRY_TYPE_VELOV, + title="Gare Part-Dieu", + unique_id="1002", + ), + ], + ) + # Stop fetch fails, but vélo'v succeeds + mock_tcl_client.get_tcl_passages.side_effect = ConnectionError("API down") + mock_tcl_client.get_velov_station.return_value = MOCK_VELOV_STATION + + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Vélo'v sensors should work + state = hass.states.get("sensor.gare_part_dieu_available_bikes") + assert state is not None + assert state.state == "12" + + +# Init update listener test + + +async def test_update_entry_reloads( + hass: HomeAssistant, + mock_config_entry_with_stop: MockConfigEntry, + mock_tcl_client: AsyncMock, +) -> None: + """Test that the update listener triggers a reload.""" + mock_config_entry_with_stop.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_with_stop.entry_id) + await hass.async_block_till_done() + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as mock_reload: + hass.config_entries.async_update_entry( + mock_config_entry_with_stop, title="Updated Data Grand Lyon" + ) + await hass.async_block_till_done() + + mock_reload.assert_called_once_with(mock_config_entry_with_stop.entry_id)