Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9a38082
Update coordinator.py
ProBreizh35 Apr 20, 2026
412c451
Update api.py
ProBreizh35 Apr 20, 2026
667ab4e
Update README.md
ProBreizh35 Apr 20, 2026
db947cd
update 1
ProBreizh35 Apr 20, 2026
8575c75
alpha
ProBreizh35 Apr 20, 2026
a52ce59
alpha01
ProBreizh35 Apr 20, 2026
ebd5279
alpha02
ProBreizh35 Apr 20, 2026
ef4bfcc
Create README.md
ProBreizh35 Apr 20, 2026
7846bc1
Update README.md
ProBreizh35 Apr 20, 2026
199d04f
Update README.md
ProBreizh35 Apr 21, 2026
2847ca7
Rajout des fichiers manquans
ProBreizh35 Apr 21, 2026
8560ce9
Merge branch 'main' of https://github.com/ProBreizh35/SNCF-API-HA
ProBreizh35 Apr 21, 2026
c8cfe51
Update sensor.py for Lint
ProBreizh35 Apr 21, 2026
88e2329
Version alpha0.2
ProBreizh35 Apr 23, 2026
378656d
dernière modifications
ProBreizh35 May 6, 2026
e2155e7
Merge branch 'main' into fetch-evolves
May 17, 2026
229e949
Ajout des fichiers à comparer pour merge :D
pa-martin May 17, 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
6 changes: 3 additions & 3 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 Down Expand Up @@ -65,7 +65,7 @@ async def fetch_departures(

async def fetch_journeys(
self, from_id: str, to_id: str, datetime_str: str, count: int = 5
) -> Optional[List[dict]]:
) -> Optional[Dict[str, Any]]:
url = f"{API_BASE}/v1/coverage/sncf/journeys"
params_raw: dict[str, object] = {
"from": from_id,
Expand All @@ -91,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.get("journeys", [])
return data
except (ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Network error fetching journeys from SNCF API: %s", err)
return None
Expand Down
21 changes: 14 additions & 7 deletions custom_components/sncf_trains/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,15 @@ async def _async_update_data(self) -> dict[str, Any]:
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
for attempt in range(1, max_retries + 1):
try:
journeys = await self.api_client.fetch_journeys(
departure, arrival, datetime_str, count=10
departure, arrival, datetime_str, count=train_count
)
if journeys is not None:
break # succès, on sort du retry
Expand All @@ -155,15 +156,21 @@ async def _async_update_data(self) -> dict[str, Any]:
)
await asyncio.sleep(retry_delay)

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

trains[subentry_id] = [
j
for j in journeys
if isinstance(j, dict) and len(j.get("sections", [])) == 1
]
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:
# 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)
Expand Down
140 changes: 114 additions & 26 deletions custom_components/sncf_trains/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,68 +104,156 @@ def __init__(self, coordinator, train_id: str, journey_id: int) -> None:
self.tid = train_id
self.jid = journey_id
entry = self.coordinator.entry.subentries[train_id]
journey = coordinator.data[train_id][journey_id]
section = journey.get("sections", [{}])[0]
departure_time = parse_datetime(section.get("base_departure_date_time", ""))
dep_name = entry.data[CONF_DEPARTURE_NAME]
arr_name = entry.data[CONF_ARRIVAL_NAME]
self.journey = coordinator.data[train_id][journey_id]
self.sections = self.journey.get("sections", [{}])[0]
departure_time = parse_datetime(self.sections.get("base_departure_date_time", ""))

self.departure = entry.data[CONF_FROM]
self.arrival = entry.data[CONF_TO]

self._attr_name = f"Train {journey_id + 1}"
self._attr_unique_id = f"{entry.subentry_id}_{journey_id}"
self._attr_extra_state_attributes = self._extra_attributes(journey)
self._attr_extra_state_attributes = self._extra_attributes(self.journey)
self._attr_device_info = {
"identifiers": {(DOMAIN, entry.subentry_id)},
"name": f"SNCF {dep_name} → {arr_name}",
"name": f"SNCF {entry.data[CONF_DEPARTURE_NAME]} → {entry.data[CONF_ARRIVAL_NAME]}",
"manufacturer": "Master13011",
"model": "API",
"entry_type": DeviceEntryType.SERVICE,
}
self._attr_native_value = departure_time
# On appelle la fonction de mise à jour dès la création pour mutualiser le code
self._update_state()

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
journey = self.coordinator.data[self.tid][self.jid]
section = journey.get("sections", [{}])[0]
self._attr_native_value = parse_datetime(
section.get("base_departure_date_time", "")
)
self._attr_extra_state_attributes = self._extra_attributes(journey)
"""Appelé à chaque mise à jour de l'API."""
self._update_state()
self.async_write_ha_state()

def _update_state(self) -> None:
"""Handle updated data from the coordinator."""
self.journey = self.coordinator.data[self.tid][self.jid]
if self.jid < len(self.journey):
self.sections = self.journey.get("sections", [{}])[0]
self._attr_native_value = parse_datetime(self.sections.get("base_departure_date_time", ""))
self._attr_extra_state_attributes = self._extra_attributes(self.journey)
self._attr_available = True # Le capteur est actif
else:
# Si l'API est KO ou renvoie moins de trains que prévu
self._attr_native_value = None
self._attr_extra_state_attributes = {}
self._attr_available = False # Le capteur passe en "Indisponible"

def _extra_attributes(self, journey: dict[str, Any]) -> dict[str, Any]:
"""Extra attributes."""
section = journey.get("sections", [{}])[0]
arr_dt = parse_datetime(journey.get("arrival_date_time", ""))
base_arr_dt = parse_datetime(section.get("base_arrival_date_time"))
arr_dt = parse_datetime(self.sections.get("arrival_date_time", ""))
base_arr_dt = parse_datetime(self.sections.get("base_arrival_date_time", ""))
delay = (
int((arr_dt - base_arr_dt).total_seconds() / 60)
if arr_dt and base_arr_dt
else 0
)

delay_cause = self._get_delay()

route_details, stops_schedule = self._get_route()

return {
"departure_time": format_time(journey.get("departure_date_time", "")),
"arrival_time": format_time(journey.get("arrival_date_time", "")),
"base_departure_time": format_time(section.get("base_departure_date_time")),
"base_arrival_time": format_time(section.get("base_arrival_date_time")),
"base_departure_time": format_time(self.sections.get("base_departure_date_time")),
"base_arrival_time": format_time(self.sections.get("base_arrival_date_time")),
"delay_minutes": delay,
"delay_cause": delay_cause,
"duration_minutes": get_duration(journey),
"has_delay": delay > 0,
"route_details": route_details,
"stops_schedule": stops_schedule,
"departure_stop_id": self.departure,
"arrival_stop_id": self.arrival,
"direction": section.get("display_informations", {}).get("direction", ""),
"physical_mode": section.get("display_informations", {}).get(
"physical_mode", ""
),
"commercial_mode": section.get("display_informations", {}).get(
"commercial_mode", ""
),
"direction": self.sections.get("display_informations", {}).get("direction", ""),
"physical_mode": self.sections.get("display_informations", {}).get("physical_mode", ""),
"commercial_mode": self.sections.get("display_informations", {}).get("commercial_mode", ""),
"train_num": get_train_num(journey),
}

def _get_route(self) -> Any:
impacted_stops = self.sections.get("impacted_stops", [])
stops_list = []
stops_schedule = []

if impacted_stops:
for stop in impacted_stops:
stop_name = stop.get("stop_point", {}).get("name", "")
b_raw = stop.get("base_departure_time") or stop.get("base_arrival_time")
a_raw = stop.get("amended_departure_time") or stop.get("amended_arrival_time")

b_time = f"{b_raw[:2]}:{b_raw[2:4]}" if b_raw and len(b_raw) >= 4 else ""
a_time = f"{a_raw[:2]}:{a_raw[2:4]}" if a_raw and len(a_raw) >= 4 else ""

stop_effect = stop.get("stop_time_effect", "unchanged")
prefix = ""
if stop_effect == "deleted":
prefix = "[SUPPRIMÉ] "
elif stop_effect == "added":
prefix = "[NOUVEAU] "

stops_list.append(f"{prefix}{stop_name} ({a_time if a_time else b_time})")
stops_schedule.append({
"name": stop_name,
"base_time": b_time,
"amended_time": a_time if a_time != b_time else None,
"effect": stop_effect
})
else:
stops_data = self.sections.get("stop_date_times", [])
for stop in stops_data:
stop_name = stop.get("stop_point", {}).get("name", "")
raw_time = stop.get("departure_date_time", stop.get("arrival_date_time", ""))
formatted_time = format_time(raw_time) if raw_time else ""
stop_effect = stop.get("stop_time_effect", "unchanged")

if stop_name and formatted_time:
prefix = ""
if stop_effect == "deleted":
prefix = "[SUPPRIMÉ] "
elif stop_effect == "added":
prefix = "[NOUVEAU] "

stops_list.append(f"{prefix}{stop_name} ({formatted_time})")
just_time = formatted_time.split(" - ")[-1] if " - " in formatted_time else formatted_time
stops_schedule.append({
"name": stop_name,
"time": just_time,
"base_time": just_time,
"amended_time": None,
"effect": stop_effect
})
route_details = " ➔ ".join(stops_list)
return route_details, stops_schedule

def _get_delay(self) -> Any:
delay_cause = self.sections.get("cause", "")

if not delay_cause:
messages = self.journey.get("messages", [])
if messages:
delay_cause = messages[0].get("text", "")

if not delay_cause:
disruptions = self.journey.get("_disruptions", [])
links = self.sections.get("display_informations", {}).get("links", [])
disruption_ids = [link.get("id") for link in links if link.get("type") == "disruption"]

for disruption in disruptions:
if disruption.get("id") in disruption_ids:
disruption_msgs = disruption.get("messages", [])
if disruption_msgs:
delay_cause = disruption_msgs[0].get("text", "")
break
return delay_cause


class SncfAllTrainsLineSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity):
"""Sensor that aggregates all trains on a single line per attribute."""
Expand Down
10 changes: 5 additions & 5 deletions custom_components/sncf_trains/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
}
},
"error": {
"invalid_api_key": "API Key not found. Please retry."
"invalid_api_key": "API Key not found or invalid. Please retry."
},
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
Expand Down Expand Up @@ -47,13 +47,13 @@
"arrival_city": {
"title": "Select arrival city",
"data": {
"departure_city": "City name"
"arrival_city": "City name"
}
},
"arrival_station": {
"title": "Select arrival station",
"data": {
"departure_station": "Station name"
"arrival_station": "Station name"
}
},
"time_range": {
Expand All @@ -74,10 +74,10 @@
}
},
"error": {
"no_stations": "No station for this city"
"no_stations": "No station found for this city"
},
"abort": {
"already_configured_as_entry": "Already train is configured",
"already_configured_as_entry": "This journey is already configured",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
}
Expand Down
Loading
Loading