Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9c4cb5e
Amélioration v1, tests à approfondir et code à relire
pa-martin May 14, 2026
9819add
Fix de l'affichage des trains sur la barre
pa-martin May 15, 2026
91b3f4b
Amélioration des couleurs de la barre de progression et des libéllés
pa-martin May 15, 2026
ce4c0e9
Gestion des icônes génériques HA (ex : mdi:home)
pa-martin May 15, 2026
5728ffb
Fix du chargement du composant Front
pa-martin May 17, 2026
424e569
Merge pull request #23 from pa-martin/fix-module-load
Master13011 May 17, 2026
4a6f81e
fix: linter
Master13011 May 17, 2026
58e10ad
Merge pull request #24 from Master13011/Lint
Master13011 May 17, 2026
777e957
Merge pull request #22 from pa-martin/main
Master13011 May 17, 2026
e2155e7
Merge branch 'main' into fetch-evolves
May 17, 2026
229e949
Ajout des fichiers à comparer pour merge :D
pa-martin May 17, 2026
8332cac
feat(card): Ajout d'un support pour avoir plusieurs trajets sur une m…
May 21, 2026
4968c57
Merge pull request #25 from yad/feature/multipleDevices
Master13011 May 22, 2026
ba3e3bb
Merge branch 'Master13011:main' into fetch-evolves
pa-martin May 23, 2026
87157e9
Homogénéisation des devs de PB35 avec ceux de la branche main.
pa-martin May 23, 2026
290e24c
Reprise des devs python de PB35
pa-martin May 24, 2026
fb4219c
cleanup
pa-martin May 24, 2026
4b16f22
cleanup 2
pa-martin May 24, 2026
b9c95e7
rollback tests
pa-martin May 24, 2026
e7825cd
Linter
pa-martin May 24, 2026
ba4e7a7
Gestion des erreurs + rétrocompatibilité
pa-martin May 25, 2026
ee7c4c5
Ajout des arrêts passés + disparition des trains déjà partis
pa-martin May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 173 additions & 115 deletions README.md

Large diffs are not rendered by default.

27 changes: 22 additions & 5 deletions custom_components/sncf_trains/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from pathlib import Path
from types import MappingProxyType
from logging import getLogger
from typing import Any

from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, CoreState, EVENT_HOMEASSISTANT_STARTED
from homeassistant.helpers.entity_registry import Platform
from homeassistant.components.frontend import add_extra_js_url
from homeassistant.components.http import StaticPathConfig
Expand All @@ -30,14 +32,29 @@

CARD_URL = "/sncf_trains/sncf-train-card.js"
CARD_FILE = Path(__file__).parent / "www" / "sncf-train-card.js"
LOGGER = getLogger(__name__)


async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up SNCF Trains component — register the Lovelace card."""
await hass.http.async_register_static_paths(
[StaticPathConfig(CARD_URL, str(CARD_FILE), cache_headers=False)]
)
add_extra_js_url(hass, CARD_URL)
async def _setup_frontend(_event: Any = None) -> None:
"""Inner function to register frontend modules."""
await hass.http.async_register_static_paths(
[StaticPathConfig(CARD_URL, str(CARD_FILE), cache_headers=False)]
)
add_extra_js_url(hass, CARD_URL)

if hass.state == CoreState.running:
LOGGER.debug(
"Home Assistant already running, registering frontend modules immediately."
)
await _setup_frontend()
else:
LOGGER.debug(
"Home Assistant not running yet, scheduling frontend module registration."
)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _setup_frontend)

return True


Expand Down
16 changes: 10 additions & 6 deletions custom_components/sncf_trains/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 20
self, stop_id: str, max_results: int = 10
) -> Optional[List[dict]]:
if stop_id.startswith("stop_area:"):
url = f"{API_BASE}/v1/coverage/sncf/stop_areas/{stop_id}/departures"
Expand All @@ -47,10 +47,14 @@ 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)")
raise RuntimeError(
"SNCF API rate-limited (429)"
) # sera géré comme non-critique
resp.raise_for_status()
data = await resp.json()
return data.get("departures", [])
Expand All @@ -60,8 +64,8 @@ async def fetch_departures(
return None

async def fetch_journeys(
self, from_id: str, to_id: str, datetime_str: str, count: int = 20
) -> Optional[Dict[str, Any]]: # 👈 On change le type de retour
self, from_id: str, to_id: str, datetime_str: str, count: int = 5
) -> Optional[Dict[str, Any]]:
url = f"{API_BASE}/v1/coverage/sncf/journeys"
params_raw: dict[str, object] = {
"from": from_id,
Expand All @@ -87,7 +91,7 @@ async def fetch_journeys(
raise RuntimeError("Quota exceeded: 429 Too Many Requests.")
resp.raise_for_status()
data = await resp.json()
return data # 👈 ON RETOURNE TOUT LE JSON !
return data
except (ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Network error fetching journeys from SNCF API: %s", err)
return None
Expand All @@ -112,4 +116,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: 1 addition & 10 deletions custom_components/sncf_trains/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,11 @@
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 @@ -252,16 +250,13 @@ 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 @@ -283,15 +278,11 @@ 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 @@ -302,4 +293,4 @@ async def async_step_reconfigure(
),
)

async_step_user = async_step_departure_city
async_step_user = async_step_departure_city
2 changes: 0 additions & 2 deletions custom_components/sncf_trains/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
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 @@ -24,4 +23,3 @@
CONF_TIME_START = "time_start"
CONF_TO = "to"
CONF_TRAIN_COUNT = "train_count"
CONF_SHOW_ROUTE_DETAILS = "show_route_details" # NOUVEAU
84 changes: 53 additions & 31 deletions custom_components/sncf_trains/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Data Update Coordinator for SNCF integration."""
"""Data Update Coordinator."""

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 du coordinateur."""
"""Initialisation."""
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 client API au démarrage."""
"""Paramétrage du coordinateur."""
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:
_LOGGER.error("Erreur d'initialisation API SNCF: %s", 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)
raise UpdateFailed(err) from err

def _build_datetime_param(self, time_start: str, time_end: str) -> str:
"""Construit le paramètre datetime pour l'API en ignorant le passé."""
def _build_datetime_param(self, time_start, time_end) -> str:
"""Construit le paramètre datetime pour l'API."""
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: str, time_end: str) -> timedelta:
"""Calcule l'intervalle approprié (Actif vs Éco)."""
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."""
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,19 +102,35 @@ def _adjust_update_interval(self, time_start: str, time_end: str) -> timedelta:
if in_fast_mode
else self.outside_interval_minutes
)
return timedelta(minutes=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

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

if not self.entry.subentries:
_LOGGER.warning("Pas de subentries configurés")
return {}

update_intervals = []
trains = {}
max_retries = 3
retry_delay = 2

max_retries = 3 # nombre de tentatives
retry_delay = 2 # secondes entre les tentatives
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]
Expand All @@ -123,31 +139,34 @@ async def _async_update_data(self) -> dict[str, Any]:

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

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

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

# Extraction séparée
journeys_list = journeys_data.get("journeys", [])
disruptions_list = journeys_data.get("disruptions", [])
journeys_list = journeys.get("journeys", [])
disruptions_list = journeys.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
# On injecte les perturbations dans chaque trajet
j["_disruptions"] = disruptions_list
valid_journeys.append(j)

Expand All @@ -157,6 +176,9 @@ async def _async_update_data(self) -> dict[str, Any]:
new_interval = min(update_intervals)
if self.update_interval != new_interval:
self.update_interval = new_interval
_LOGGER.debug("Nouvel intervalle de mise à jour: %s min", new_interval.total_seconds() / 60)
_LOGGER.debug(
"Coordinator update interval set to %s minutes",
self.update_interval.total_seconds() / 60,
)

return trains
return trains
10 changes: 3 additions & 7 deletions custom_components/sncf_trains/manifest.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
{
"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 @@ -21,4 +17,4 @@
"requirements": [],
"single_config_entry": true,
"version": "1.0.0"
}
}
Loading
Loading