Skip to content
Open
244 changes: 115 additions & 129 deletions README.md

Large diffs are not rendered by default.

18 changes: 7 additions & 11 deletions custom_components/sncf_trains/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import base64
import logging
from aiohttp import ClientSession, ClientTimeout, ClientError
from typing import List, Optional, Mapping
from typing import List, Optional, Mapping, Dict, Any
from homeassistant.exceptions import ConfigEntryAuthFailed
import asyncio

Expand All @@ -22,7 +22,7 @@ def __init__(self, session: ClientSession, api_key: str, timeout: int = 10):
self._timeout = timeout

async def fetch_departures(
self, stop_id: str, max_results: int = 10
self, stop_id: str, max_results: int = 20
) -> Optional[List[dict]]:
if stop_id.startswith("stop_area:"):
url = f"{API_BASE}/v1/coverage/sncf/stop_areas/{stop_id}/departures"
Expand All @@ -47,14 +47,10 @@ async def fetch_departures(
timeout=ClientTimeout(total=self._timeout),
) as resp:
if resp.status == 401:
# vrai problème d'auth
raise ConfigEntryAuthFailed("Unauthorized: check your API key.")
if resp.status == 429:
# rate-limit => pas une auth failure
_LOGGER.warning("API rate limit (429) on %s with %s", url, params)
raise RuntimeError(
"SNCF API rate-limited (429)"
) # sera géré comme non-critique
raise RuntimeError("SNCF API rate-limited (429)")
resp.raise_for_status()
data = await resp.json()
return data.get("departures", [])
Expand All @@ -64,8 +60,8 @@ async def fetch_departures(
return None

async def fetch_journeys(
self, from_id: str, to_id: str, datetime_str: str, count: int = 5
) -> Optional[List[dict]]:
self, from_id: str, to_id: str, datetime_str: str, count: int = 20
) -> Optional[Dict[str, Any]]: # 👈 On change le type de retour
url = f"{API_BASE}/v1/coverage/sncf/journeys"
params_raw: dict[str, object] = {
"from": from_id,
Expand All @@ -91,7 +87,7 @@ async def fetch_journeys(
raise RuntimeError("Quota exceeded: 429 Too Many Requests.")
resp.raise_for_status()
data = await resp.json()
return data.get("journeys", [])
return data # 👈 ON RETOURNE TOUT LE JSON !
except (ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Network error fetching journeys from SNCF API: %s", err)
return None
Expand All @@ -116,4 +112,4 @@ async def search_stations(self, query: str) -> Optional[List[dict]]:
return data.get("places", [])
except (ClientError, asyncio.TimeoutError) as err:
_LOGGER.error("Network error searching stations from SNCF API: %s", err)
return None
return None
11 changes: 10 additions & 1 deletion custom_components/sncf_trains/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
CONF_TRAIN_COUNT,
CONF_UPDATE_INTERVAL,
CONF_OUTSIDE_INTERVAL,
CONF_SHOW_ROUTE_DETAILS, # NOUVEAU
DEFAULT_OUTSIDE_INTERVAL,
DEFAULT_TIME_END,
DEFAULT_TIME_START,
DEFAULT_TRAIN_COUNT,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_SHOW_ROUTE_DETAILS, # NOUVEAU
DOMAIN,
)

Expand Down Expand Up @@ -250,13 +252,16 @@ async def async_step_time_range(
},
unique_id=unique_id,
)

# NOUVEAU: On ajoute l'option booléenne
return self.async_show_form(
step_id="time_range",
data_schema=vol.Schema(
{
vol.Required(CONF_TIME_START, default=DEFAULT_TIME_START): str,
vol.Required(CONF_TIME_END, default=DEFAULT_TIME_END): str,
vol.Required(CONF_TRAIN_COUNT, default=DEFAULT_TRAIN_COUNT): int,
vol.Optional(CONF_SHOW_ROUTE_DETAILS, default=DEFAULT_SHOW_ROUTE_DETAILS): bool,
}
),
)
Expand All @@ -278,11 +283,15 @@ async def async_step_reconfigure(
title=f"Trajet: {data[CONF_DEPARTURE_NAME]} → {data[CONF_ARRIVAL_NAME]} ({data[CONF_TIME_START]} - {data[CONF_TIME_END]})",
)

# NOUVEAU: On récupère l'ancienne valeur si elle existe
current_show_route = config_subentry.data.get(CONF_SHOW_ROUTE_DETAILS, DEFAULT_SHOW_ROUTE_DETAILS)

DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_TIME_START, default=DEFAULT_TIME_START): str,
vol.Required(CONF_TIME_END, default=DEFAULT_TIME_END): str,
vol.Required(CONF_TRAIN_COUNT, default=DEFAULT_TRAIN_COUNT): int,
vol.Optional(CONF_SHOW_ROUTE_DETAILS, default=current_show_route): bool,
}
)

Expand All @@ -293,4 +302,4 @@ async def async_step_reconfigure(
),
)

async_step_user = async_step_departure_city
async_step_user = async_step_departure_city
2 changes: 2 additions & 0 deletions custom_components/sncf_trains/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
DEFAULT_TRAIN_COUNT = 5
DEFAULT_TIME_START = "07:00"
DEFAULT_TIME_END = "10:00"
DEFAULT_SHOW_ROUTE_DETAILS = False

ATTRIBUTION = "Data provided by api.sncf.com"

Expand All @@ -23,3 +24,4 @@
CONF_TIME_START = "time_start"
CONF_TO = "to"
CONF_TRAIN_COUNT = "train_count"
CONF_SHOW_ROUTE_DETAILS = "show_route_details" # NOUVEAU
97 changes: 41 additions & 56 deletions custom_components/sncf_trains/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Data Update Coordinator."""
"""Data Update Coordinator for SNCF integration."""

import logging
from datetime import timedelta
from typing import Any
import asyncio
from aiohttp import ClientError

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
Expand All @@ -32,7 +32,7 @@ class SncfUpdateCoordinator(DataUpdateCoordinator):
"""Coordonnateur pour récupérer les données des trajets SNCF."""

def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
"""Initialisation."""
"""Initialisation du coordinateur."""
self.entry = entry
self.api_client = None
self.update_interval_minutes = entry.options.get(
Expand All @@ -50,34 +50,34 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry):
)

async def _async_setup(self) -> None:
"""Paramétrage du coordinateur."""
"""Paramétrage du client API au démarrage."""
api_key = self.entry.data[CONF_API_KEY]

try:
session = async_get_clientsession(self.hass)
self.api_client = SncfApiClient(session, api_key)

except Exception as err:
if "401" in str(err) or "403" in str(err):
raise ConfigEntryAuthFailed("Clé API invalide ou expirée") from err
_LOGGER.error("Erreur lors de la récupération des trajets SNCF: %s", err)
_LOGGER.error("Erreur d'initialisation API SNCF: %s", err)
raise UpdateFailed(err) from err

def _build_datetime_param(self, time_start, time_end) -> str:
"""Construit le paramètre datetime pour l'API."""
def _build_datetime_param(self, time_start: str, time_end: str) -> str:
"""Construit le paramètre datetime pour l'API en ignorant le passé."""
now = dt_util.now()
h_start, m_start = map(int, time_start.split(":"))
h_end, m_end = map(int, time_end.split(":"))

dt_start = now.replace(hour=h_start, minute=m_start, second=0, microsecond=0)
dt_end = now.replace(hour=h_end, minute=m_end, second=0, microsecond=0)

if now > dt_end:
dt_start += timedelta(days=1)
elif now > dt_start:
dt_start = now

return dt_start.strftime("%Y%m%dT%H%M%S")

def _adjust_update_interval(self, time_start, time_end) -> timedelta | None:
"""Ajuste la fréquence selon la plage horaire, avec préfenêtre 1h et gestion minuit."""
def _adjust_update_interval(self, time_start: str, time_end: str) -> timedelta:
"""Calcule l'intervalle approprié (Actif vs Éco)."""
now = dt_util.now()
h_start, m_start = map(int, time_start.split(":"))
h_end, m_end = map(int, time_end.split(":"))
Expand All @@ -102,76 +102,61 @@ def _adjust_update_interval(self, time_start, time_end) -> timedelta | None:
if in_fast_mode
else self.outside_interval_minutes
)
new_interval = timedelta(minutes=interval_minutes)

if self.update_interval != new_interval:
_LOGGER.debug(
"Update interval: %s → %s minutes",
(
None
if self.update_interval is None
else self.update_interval.total_seconds() / 60
),
interval_minutes,
)
return new_interval

return new_interval
return timedelta(minutes=interval_minutes)

async def _async_update_data(self) -> dict[str, Any]:
"""Récupère les données de l'API SNCF."""

"""Récupère les données depuis l'API SNCF."""
if not self.entry.subentries:
_LOGGER.warning("Pas de subentries configurés")
return {}

update_intervals = []
trains = {}
max_retries = 3 # nombre de tentatives
retry_delay = 2 # secondes entre les tentatives
max_retries = 3
retry_delay = 2

for subentry_id, entry in self.entry.subentries.items():
_LOGGER.debug(entry.title)
departure = entry.data[CONF_FROM]
arrival = entry.data[CONF_TO]
time_start = entry.data[CONF_TIME_START]
time_end = entry.data[CONF_TIME_END]
train_count = entry.data.get("train_count", 10)

update_intervals.append(self._adjust_update_interval(time_start, time_end))
datetime_str = self._build_datetime_param(time_start, time_end)
journeys = None

journeys_data = None
for attempt in range(1, max_retries + 1):
try:
journeys = await self.api_client.fetch_journeys(
departure, arrival, datetime_str, count=10
journeys_data = await self.api_client.fetch_journeys(
departure, arrival, datetime_str, count=train_count
)
if journeys is not None:
break # succès, on sort du retry
if journeys_data is not None:
break
except (ClientError, asyncio.TimeoutError, RuntimeError) as err:
_LOGGER.warning(
"Erreur réseau lors de la récupération des trajets (tentative %d/%d) : %s",
attempt,
max_retries,
err,
)
_LOGGER.warning("Tentative %d/%d échouée: %s", attempt, max_retries, err)
await asyncio.sleep(retry_delay)

if journeys is None or not isinstance(journeys, list):
_LOGGER.error("Aucune donnée reçue de l'API SNCF pour le trajet ")
# Vérification du dictionnaire
if journeys_data is None or not isinstance(journeys_data, dict):
continue

trains[subentry_id] = [
j
for j in journeys
if isinstance(j, dict) and len(j.get("sections", [])) == 1
]
# Extraction séparée
journeys_list = journeys_data.get("journeys", [])
disruptions_list = journeys_data.get("disruptions", [])

valid_journeys = []
for j in journeys_list:
if isinstance(j, dict) and len(j.get("sections", [])) == 1:
# 👈 LA MAGIE : On injecte les perturbations dans chaque trajet
j["_disruptions"] = disruptions_list
valid_journeys.append(j)

trains[subentry_id] = valid_journeys

if update_intervals:
new_interval = min(update_intervals)
if self.update_interval != new_interval:
self.update_interval = new_interval
_LOGGER.debug(
"Coordinator update interval set to %s minutes",
self.update_interval.total_seconds() / 60,
)
_LOGGER.debug("Nouvel intervalle de mise à jour: %s min", new_interval.total_seconds() / 60)

return trains
return trains
10 changes: 7 additions & 3 deletions custom_components/sncf_trains/manifest.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
{
"domain": "sncf_trains",
"name": "SNCF Trains",
"after_dependencies": ["http"],
"after_dependencies": [
"http"
],
"codeowners": [
"@Master13011"
],
"config_flow": true,
"dependencies": ["frontend"],
"dependencies": [
"frontend"
],
"documentation": "https://github.com/Master13011/SNCF-API-HA",
"integration_type": "service",
"iot_class": "cloud_polling",
Expand All @@ -17,4 +21,4 @@
"requirements": [],
"single_config_entry": true,
"version": "1.0.0"
}
}
Loading