diff --git a/README.md b/README.md index 6691f9f..07b3550 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,196 @@ -# 🚄 IntĂ©gration SNCF Trains pour Home Assistant - ![Home Assistant](https://img.shields.io/badge/Home--Assistant-2024.5+-blue?logo=home-assistant) ![Custom Component](https://img.shields.io/badge/Custom%20Component-oui-orange) ![Licence MIT](https://img.shields.io/badge/Licence-MIT-green) -Suivez les horaires des trains SNCF entre deux gares dans Home Assistant, grĂące Ă  l’API officielle [SNCF](https://www.digital.sncf.com/startup/api). -DĂ©part / arrivĂ©e, retards, durĂ©e, mode (TER
), tout est intĂ©grĂ© dans une interface configurable et traduite. +# 🚆 SNCF Trains pour Home Assistant + +Suivez facilement les horaires des trains SNCF entre deux gares directement dans votre tableau de bord Home Assistant, grĂące Ă  l’API officielle de la [SNCF](https://www.digital.sncf.com/startup/api). -> ⚠ Ne prend pas en compte les trains supprimĂ©s. +DĂ©parts, arrivĂ©es, retards, durĂ©e du trajet et type de train (TER, TGV, etc.) : toutes les informations essentielles sont regroupĂ©es dans une interface personnalisable et entiĂšrement traduite en français. + +> [!CAUTION] +> +> ### ⚠ DÉVELOPPEMENT ACTIF / ACTIVE DEVELOPMENT +> +> **Ce projet est actuellement en phase d'amĂ©lioration intensive.** +> Les fonctionnalitĂ©s Ă©voluent rapidement. Assurez-vous d'utiliser la derniĂšre version des fichiers de l'intĂ©gration pour garantir une compatibilitĂ© totale avec votre tableau de bord. --- -## 📩 Installation +## đŸ§Ș NouveautĂ© en phase de test : Les Trains SupprimĂ©s -### 1. Via HACS (recommandĂ©) +> **Nous avons rĂ©cemment introduit la dĂ©tection et l'affichage des trains annulĂ©s/supprimĂ©s !** > Cette fonctionnalitĂ© est actuellement en **phase de test**. +> +> 🙏 **Un immense merci** Ă  tous les utilisateurs qui prennent le temps de nous faire leurs retours (qu'il s'agisse de petits bugs ou de succĂšs sur vos trajets quotidiens). C'est grĂące Ă  votre aide que nous pouvons stabiliser et amĂ©liorer ce projet pour tout le monde ! -> NĂ©cessite HACS installĂ© dans Home Assistant +--- -1. Aller dans **HACS** -2. Chercher **SNCF Trains** -3. Installer puis redĂ©marrer Home Assistant +## 🚀 DerniĂšres mises Ă  jour (Avril 2026) -### 2. Manuel (sans HACS) +Le systĂšme a Ă©tĂ© lourdement mis Ă  jour pour vous offrir une prĂ©cision et un confort d'utilisation optimaux : -1. TĂ©lĂ©charger le contenu du dĂ©pĂŽt -2. Copier le dossier `sncf_trains` dans `config/custom_components/` -3. RedĂ©marrer Home Assistant +- **🕒 Correction de l'affichage de l'heure :** RĂ©solution dĂ©finitive du problĂšme qui affichait des trains "il y a 8 heures". Le systĂšme gĂšre dĂ©sormais parfaitement les fuseaux horaires locaux. +- **📡 Radar de Ligne (V3.3) :** IntĂ©gration d'un visuel dĂ©taillĂ© affichant les arrĂȘts intermĂ©diaires et dĂ©tectant les modifications de parcours. _(Note : Cette option peut ĂȘtre dĂ©sactivĂ©e dans les paramĂštres pour garder un design simple)._ +- **🎭 Moteur d'Animation Dynamique :** L'emoji du train avance dĂ©sormais de maniĂšre synchronisĂ©e avec la durĂ©e rĂ©elle de votre trajet. +- **🔍 Analyse Intelligente des Perturbations :** \* Affichage clair de la **cause officielle** du retard (ex: Panne de signalisation, DĂ©faut d'alimentation...). + - Code couleur intuitif : **Orange** pour les retards, **Rouge** pour les suppressions. --- -## ⚙ Configuration +## 📾 Aperçu Visuel + +| Design ÉpurĂ© (Classique) | Nouveau Design (Radar de Ligne) | +| :-----------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------: | +| Avant | Radar de Ligne V3 | -1. Aller dans **ParamĂštres → Appareils & services → Ajouter une intĂ©gration** -2. Rechercher **SNCF Trains** -3. Suivre les Ă©tapes : - - ClĂ© API SNCF -4. Ajouter un trajet : - - Ville et gare de dĂ©part - - Ville et gare d'arrivĂ©e - - Plage horaire Ă  surveiller +### ⚠ Zoom sur les Retards et Perturbations -Plusieurs trajets peuvent ĂȘtre configurĂ©s sĂ©parĂ©ment. +GrĂące Ă  la nouvelle analyse des donnĂ©es de la SNCF, la carte est capable d'afficher le suivi en temps rĂ©el des incidents avec la cause exacte et l'impact sur chaque arrĂȘt : + +DĂ©tail d'un retard avec sa cause officielle --- -## đŸ§© Options dynamiques +## 📩 Installation -### IntĂ©gration principale (Configurer) +### 1. Via HACS (MĂ©thode recommandĂ©e) -- ⏱ Intervalle de mise Ă  jour **pendant** la plage horaire -- 🕰 Intervalle **hors** plage horaire +_NĂ©cessite [HACS](https://hacs.xyz/) installĂ© sur votre Home Assistant._ -### Par trajet (Reconfigurer un trajet) +1. Ouvrez **HACS** dans votre menu de gauche. +2. Recherchez **SNCF Trains**. +3. Cliquez sur **Installer**, puis redĂ©marrez Home Assistant. -- 🚆 Nombre de trains affichĂ©s -- 🕗 Heures de dĂ©but et fin de surveillance +### 2. MĂ©thode Manuelle -✅ Aucun redĂ©marrage requis. Les modifications sont appliquĂ©es dynamiquement. +1. TĂ©lĂ©chargez le contenu de ce dĂ©pĂŽt. +2. Copiez le dossier `sncf_trains` dans le rĂ©pertoire `config/custom_components/` de votre Home Assistant. +3. RedĂ©marrez Home Assistant. --- -## 🔐 ClĂ© API SNCF +## ⚙ Configuration initiale -Obtenez votre clĂ© ici : [https://www.digital.sncf.com/startup/api](https://www.digital.sncf.com/startup/api) +1. Dans Home Assistant, allez dans **ParamĂštres** → **Appareils et services** → **Ajouter une intĂ©gration**. +2. Recherchez **SNCF Trains**. +3. Renseignez votre **ClĂ© API SNCF** _(voir section suivante)_. +4. Configurez votre premier trajet en indiquant : + - La gare de dĂ©part. + - La gare d'arrivĂ©e. + - La plage horaire que vous souhaitez surveiller. -1. CrĂ©ez un compte ou connectez-vous -2. GĂ©nĂ©rez une clĂ© API gratuite -3. Utilisez-la lors de la configuration (limite de 5 000 requĂȘtes par jour) - -> Pour changer de clĂ©, cliquer sur **Reconfigurer** dans l'intĂ©gration. +_Astuce : Vous pouvez configurer autant de trajets diffĂ©rents que vous le souhaitez !_ --- -## ⚙ Variables de l'intĂ©gration +## 🔐 Obtenir sa ClĂ© API SNCF (Gratuit) + +Pour que l'intĂ©gration fonctionne, vous avez besoin d'une clĂ© API officielle fournie par la SNCF : -| Nom | Description | -|-----|-------------| -| `update_interval` | Intervalle de mise Ă  jour **pendant** la plage horaire (dĂ©faut : 2 min) | -| `outside_interval` | Intervalle **hors** plage horaire (dĂ©faut : 60 min) | -| `train_count` | Nombre de trains Ă  afficher | -| `time_start` / `time_end` | Plage horaire de surveillance (ex. : `06:00` → `09:00`) | +1. Rendez-vous sur le [portail API SNCF](https://www.digital.sncf.com/startup/api). +2. CrĂ©ez un compte gratuitement ou connectez-vous. +3. GĂ©nĂ©rez votre clĂ© API (celle-ci autorise jusqu'Ă  5 000 requĂȘtes par jour, ce qui est largement suffisant). +4. Copiez-la et collez-la lors de la configuration dans Home Assistant. -> 🕑 L'intervalle actif s'active automatiquement **2h avant** le dĂ©but de plage. +> _Pour changer de clĂ© plus tard, il vous suffira de cliquer sur **Reconfigurer** depuis la page de l'intĂ©gration._ --- -## 📊 Capteurs créés +## đŸ§© Options et Personnalisation -- `sensor.sncf__` — capteur principal du trajet -- `sensor.sncf_train_X__` — capteur par train -- `calendar.trains` — calendrier des prochains dĂ©parts -- `sensor.sncf_tous_les_trains_ligne_X` +Vous pouvez ajuster le comportement de l'intĂ©gration sans avoir Ă  redĂ©marrer Home Assistant : -### Attributs du capteur principal +**Options globales de l'intĂ©gration :** -- Nombre de trajets -- Informations les inervalles +- ⏱ **Intervalle de rafraĂźchissement (actif) :** FrĂ©quence de mise Ă  jour pendant vos heures de trajet (dĂ©faut : 2 min). +- 🕰 **Intervalle de rafraĂźchissement (repos) :** FrĂ©quence de mise Ă  jour hors de vos heures de trajet (dĂ©faut : 60 min). -### Capteurs secondaires (enfants) pour chaque train +**Options spĂ©cifiques Ă  chaque trajet :** -- Heure de dĂ©part (`device_class: timestamp`) -- Heure d’arrivĂ©e -- Retard estimĂ© -- DurĂ©e totale (`duration_minutes`) -- Mode, direction, numĂ©ro +- 🚆 **Nombre de trains Ă  afficher :** Choisissez combien de dĂ©parts simultanĂ©s vous souhaitez surveiller **(jusqu'Ă  20 trains par ligne maximum !)**. +- 🕗 **Heures exactes de dĂ©but et fin de surveillance.** + +_(Le mode actif se dĂ©clenche automatiquement 2 heures avant l'heure de dĂ©but que vous avez configurĂ©e)._ --- -## 🎹 Carte Lovelace — SNCF Train Card +## 📊 DonnĂ©es et Capteurs -La carte `sncf-train-card` est **automatiquement disponible** dans le sĂ©lecteur de cartes dĂšs l'installation de l'intĂ©gration. +L'intĂ©gration crĂ©e automatiquement plusieurs capteurs pour vos automatisations : -### Ajouter la carte +- `sensor.sncf__` : Le capteur global rĂ©sumant votre trajet. +- `sensor.sncf_train_X__` : Un capteur individuel pour chaque train suivi. +- `calendar.trains` : Un calendrier pratique affichant vos prochains dĂ©parts. -Dans un tableau de bord, cliquer sur **+ Ajouter une carte** → chercher **SNCF Train Card**. +**Informations disponibles pour chaque train :** -Ou en YAML : +- Heure de dĂ©part prĂ©vue et rĂ©elle. +- Heure d’arrivĂ©e. +- DurĂ©e totale du voyage. +- Type de train (TER, TGV...), direction et numĂ©ro de ligne. +- Minutes de retard et cause officielle (si applicable). -```yaml -type: custom:sncf-train-card -device_id: VOTRE_DEVICE_ID -``` +--- -### 🔍 Trouver le `device_id` +## 🎹 Carte pour le Tableau de Bord (Lovelace) -Le `device_id` correspond Ă  l'appareil créé lors de la configuration du trajet. +Une jolie carte visuelle (`sncf-train-card`) est incluse et prĂȘte Ă  l'emploi dĂšs l'installation ! -1. Aller dans **ParamĂštres → Appareils & services → SNCF Trains** -2. Cliquer sur le trajet souhaitĂ© -3. L'URL contient l'identifiant : `.../config/devices/device/XXXX` +### Trouver son `device_id` -> ![Exemple d'identifiant](./assets/device_id_url.png) +Pour que la carte sache quel trajet afficher, elle a besoin de l'identifiant de l'appareil (`device_id`) : -### ⚙ ParamĂštres de la carte +1. Allez dans **ParamĂštres** → **Appareils et services** → **SNCF Trains**. +2. Cliquez sur l'appareil correspondant Ă  votre trajet. +3. Regardez l'URL dans la barre de votre navigateur : la suite de lettres et chiffres Ă  la fin est votre `device_id` (ex: `.../config/devices/device/abc123def456`). -| ParamĂštre | Type | DĂ©faut | Description | -|-----------|------|--------|-------------| -| `device_id` | `string` | **obligatoire** | Identifiant de l'appareil SNCF (voir ci-dessus) | -| `title` | `string` | `'Trains SNCF'` | Titre affichĂ© en haut de la carte | -| `train_lines` | `number` | `3` | Nombre de trains affichĂ©s simultanĂ©ment | -| `train_emoji` | `string` | `'🚅'` | Emoji du train animĂ© sur la barre de progression | -| `train_emoji_axial_symmetry` | `boolean` | `true` | Retourne l'emoji (Ă  utiliser selon son sens) | -| `train_station_emoji` | `string` | `'🚉'` | Emoji affichĂ© Ă  cĂŽtĂ© des gares | -| `animation_duration` | `number` | `30` | Nombre de minutes avant l'arrivĂ©e en gare Ă  partir duquel l'animation du train se dĂ©clenche (ex : `30` = animation active dans les 30 derniĂšres minutes, `60` = dans la derniĂšre heure) | -| `update_interval` | `number` | `30000` | Intervalle de rafraĂźchissement de la carte en **millisecondes** | +### Configuration YAML AvancĂ©e -### Exemple complet +Voici un exemple de configuration complet pour exploiter 100% des capacitĂ©s de la carte : ```yaml type: custom:sncf-train-card -device_id: abc123def456 +device_id: VOTRE_DEVICE_ID title: "Paris → Lyon" -train_lines: 4 +train_lines: 5 train_emoji: "🚆" train_emoji_axial_symmetry: true train_station_emoji: "đŸ™ïž" animation_duration: 45 update_interval: 60000 +show_route_details: true +use_real_duration: true +show_real_stop_times: true +show_delay_cause: true ``` -### Exemple d'affichage - -![Exemple d'affichage](./assets/card_example.png) - ---- - -## 📾 Aperçus - -**Carte capteur :** +**🔍 QUE FAIT CHAQUE OPTION ?** -sensor - -**DĂ©tails du prochain train :** - -image - -**Dashboard Lovelace :** - -dashboard +- train_lines: 5 : Affiche les 5 prochains dĂ©parts sur votre tableau de bord. +- train_emoji: "🚆" : Remplace l'icĂŽne du train par dĂ©faut par l'emoji de votre choix. +- train_emoji_axial_symmetry: true : Retourne l'emoji horizontalement (trĂšs utile si vous voulez donner l'impression que le train roule vers la gauche). +- train_station_emoji: "đŸ™ïž" : Affiche cet emoji Ă  cĂŽtĂ© du nom de la gare. +- animation_duration: 45 : L'animation du train qui avance sur la ligne dĂ©marrera exactement 45 minutes avant le dĂ©part. +- update_interval: 60000 : La carte se rafraĂźchit visuellement toutes les 60 secondes (60000 ms). +- show_route_details: true : Active le Radar de Ligne ! Affiche une timeline sous le trajet principal avec tous les arrĂȘts intermĂ©diaires de votre train. +- use_real_duration: true : Ajuste la vitesse de l'animation en fonction du temps de trajet rĂ©el. Un trajet de 2h paraĂźtra visuellement plus lent qu'un trajet de 15 minutes. +- show_real_stop_times: true : Sur le radar de ligne, en cas de retard, affiche l'heure initiale (barrĂ©e) suivie de la nouvelle heure estimĂ©e (en orange) pour chaque arrĂȘt intermĂ©diaire. +- show_delay_cause: true : Affiche clairement le motif du retard (ex: Panne de signalisation, Obstacle sur les voies) juste en dessous du temps de retard. --- -## 🛠 DĂ©veloppement +## 🔼 Roadmap / À venir -Compatible avec Home Assistant `2025.8+`. +đŸ›€ïž Pour les grands voyageurs : L'ajout de l'affichage des voies de dĂ©part et d'arrivĂ©e est actuellement en cours de rĂ©flexion. C'est une fonctionnalitĂ© qui s'avĂšre beaucoup plus complexe Ă  mettre en place de maniĂšre fiable . Restez Ă  l'Ă©coute ! -Structure : -- `__init__.py` : enregistrement de l'intĂ©gration et de la carte Lovelace -- `calendar.py` : calendrier -- `config_flow.py` : assistant UI de configuration -- `options_flow.py` : formulaire d’options dynamiques -- `sensor.py` : entitĂ©s de capteurs -- `coordinator.py` : logique de rĂ©cupĂ©ration intelligente -- `translations/fr.json` : interface en français -- `manifest.json` : mĂ©tadonnĂ©es et dĂ©pendances -- `www/sncf-train-card.js` : carte Lovelace personnalisĂ©e +## đŸ‘šâ€đŸ’» DĂ©veloppement et Contribution ---- - -## đŸ‘šâ€đŸ’» Auteur +Compatible avec Home Assistant 2025.8 et supĂ©rieur. -DĂ©veloppĂ© par [Master13011](https://github.com/Master13011) -Contributions bienvenues via **Pull Request** ou **Issues** +DĂ©veloppĂ© par Master13011. ---- +Les contributions sont les bienvenues ! N'hĂ©sitez pas Ă  ouvrir une Issue pour signaler un problĂšme ou soumettre une Pull Request. -## 📄 Licence +## 📄 LICENCE -Code open-source sous licence **MIT** +Ce projet est open-source et distribuĂ© sous la licence MIT. diff --git a/custom_components/sncf_trains/api.py b/custom_components/sncf_trains/api.py index dab8516..47d12e0 100644 --- a/custom_components/sncf_trains/api.py +++ b/custom_components/sncf_trains/api.py @@ -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 @@ -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" @@ -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", []) @@ -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, @@ -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 @@ -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 \ No newline at end of file diff --git a/custom_components/sncf_trains/config_flow.py b/custom_components/sncf_trains/config_flow.py index 3014b36..2f2e1db 100644 --- a/custom_components/sncf_trains/config_flow.py +++ b/custom_components/sncf_trains/config_flow.py @@ -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, ) @@ -250,6 +252,8 @@ 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( @@ -257,6 +261,7 @@ async def async_step_time_range( 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, } ), ) @@ -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, } ) @@ -293,4 +302,4 @@ async def async_step_reconfigure( ), ) - async_step_user = async_step_departure_city + async_step_user = async_step_departure_city \ No newline at end of file diff --git a/custom_components/sncf_trains/const.py b/custom_components/sncf_trains/const.py index 74a05e1..545f277 100644 --- a/custom_components/sncf_trains/const.py +++ b/custom_components/sncf_trains/const.py @@ -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" @@ -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 \ No newline at end of file diff --git a/custom_components/sncf_trains/coordinator.py b/custom_components/sncf_trains/coordinator.py index 94b8e7c..4d3fca9 100644 --- a/custom_components/sncf_trains/coordinator.py +++ b/custom_components/sncf_trains/coordinator.py @@ -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 @@ -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( @@ -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(":")) @@ -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 \ No newline at end of file diff --git a/custom_components/sncf_trains/manifest.json b/custom_components/sncf_trains/manifest.json index a89ef6b..3ed24ae 100644 --- a/custom_components/sncf_trains/manifest.json +++ b/custom_components/sncf_trains/manifest.json @@ -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", @@ -17,4 +21,4 @@ "requirements": [], "single_config_entry": true, "version": "1.0.0" -} \ No newline at end of file +} diff --git a/custom_components/sncf_trains/sensor.py b/custom_components/sncf_trains/sensor.py index 78adfc7..18b7648 100644 --- a/custom_components/sncf_trains/sensor.py +++ b/custom_components/sncf_trains/sensor.py @@ -38,22 +38,16 @@ async def async_setup_entry( display_count = min(len(journeys), subentry.data.get("train_count", 0)) sensors = [] - # Capteurs individuels pour chaque train for idx in range(display_count): sensors.append(SncfTrainSensor(coordinator, subentry.subentry_id, idx)) - # Capteur rĂ©sumĂ© ligne par ligne sensors.append(SncfAllTrainsLineSensor(coordinator, subentry.subentry_id)) - # Ajouter tous les capteurs de cette subentry au mĂȘme niveau async_add_entities( sensors, config_subentry_id=subentry.subentry_id, update_before_add=True ) -# --- Sensor Classes --- - - class SncfJourneySensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): """Main SNCF sensor: number of direct journeys & summary.""" @@ -63,7 +57,6 @@ class SncfJourneySensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): _attr_native_unit_of_measurement = "trajets" def __init__(self, coordinator: SncfUpdateCoordinator) -> None: - """Initialize.""" super().__init__(coordinator) self._attr_unique_id = f"sncf_trains_{coordinator.entry.entry_id}" self._attr_device_info = { @@ -74,19 +67,10 @@ def __init__(self, coordinator: SncfUpdateCoordinator) -> None: "entry_type": DeviceEntryType.SERVICE, } self._attr_native_value = len(coordinator.data) - self._attr_extra_state_attributes = { - "update_interval": coordinator.update_interval_minutes, - "outside_interval": coordinator.outside_interval_minutes, - } @callback def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" self._attr_native_value = len(self.coordinator.data) - self._attr_extra_state_attributes = { - "update_interval": self.coordinator.update_interval_minutes, - "outside_interval": self.coordinator.outside_interval_minutes, - } self.async_write_ha_state() @@ -99,70 +83,168 @@ class SncfTrainSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP def __init__(self, coordinator, train_id: str, journey_id: int) -> None: - """Initialize the sensor.""" super().__init__(coordinator) 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.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_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: + """Met Ă  jour les valeurs du capteur de maniĂšre sĂ©curisĂ©e.""" + journeys = self.coordinator.data.get(self.tid, []) + + # SÉCURITÉ : On vĂ©rifie si le train existe bien dans la liste ! + if self.jid < len(journeys): + journey = journeys[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) + 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" proprement + def _extra_attributes(self, journey: dict[str, Any]) -> dict[str, Any]: - """Extra attributes.""" + """Calcul des attributs dĂ©taillĂ©s pour chaque train.""" 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")) - delay = ( - int((arr_dt - base_arr_dt).total_seconds() / 60) - if arr_dt and base_arr_dt - else 0 - ) + + # 1. Gestion des dates avec fallback + base_dep_raw = section.get("base_departure_date_time") + base_arr_raw = section.get("base_arrival_time") or section.get("base_arrival_date_time") + + real_dep_raw = journey.get("departure_date_time") or base_dep_raw + real_arr_raw = journey.get("arrival_date_time") or base_arr_raw + + arrival_time = format_time(real_arr_raw) + departure_time = format_time(real_dep_raw) + + # 2. Calcul du retard (minutes) + arr_dt = parse_datetime(real_arr_raw) + base_arr_dt = parse_datetime(base_arr_raw) + delay = 0 + if arr_dt and base_arr_dt: + delay = int((arr_dt - base_arr_dt).total_seconds() / 60) + + # 3. DĂ©tection d'annulation et Cause + status = journey.get("status", "") + section_status = section.get("status", "") + is_canceled = (status == "NO_SERVICE" or section_status == "NO_SERVICE") + + delay_cause = section.get("cause", "") + + if not delay_cause: + messages = journey.get("messages", []) + if messages: + delay_cause = messages[0].get("text", "") + + if not delay_cause: + disruptions = journey.get("_disruptions", []) + links = section.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 + + # 4. Plan de vol structurĂ© + stops_schedule = [] + route_details = "" + show_routes = self.coordinator.entry.subentries[self.tid].data.get("show_route_details", False) + + if show_routes: + impacted_stops = section.get("impacted_stops", []) + stops_list = [] + + 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 = section.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 { - "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")), + "departure_time": departure_time, + "arrival_time": arrival_time, + "base_departure_time": format_time(base_dep_raw), + "base_arrival_time": arrival_time, "delay_minutes": delay, + "delay_cause": delay_cause, "duration_minutes": get_duration(journey), "has_delay": delay > 0, - "departure_stop_id": self.departure, - "arrival_stop_id": self.arrival, + "canceled": is_canceled, + "route_details": route_details, + "stops_schedule": stops_schedule, "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", "" - ), + "physical_mode": section.get("display_informations", {}).get("physical_mode", ""), "train_num": get_train_num(journey), } @@ -175,7 +257,6 @@ class SncfAllTrainsLineSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEn _attr_attribution = ATTRIBUTION def __init__(self, coordinator: SncfUpdateCoordinator, train_id: str) -> None: - """Initialize the line sensor.""" super().__init__(coordinator) self.tid = train_id self._attr_name = "Tous les trains (ligne)" @@ -190,40 +271,28 @@ def __init__(self, coordinator: SncfUpdateCoordinator, train_id: str) -> None: @callback def _handle_coordinator_update(self) -> None: - """Update all trains values on a single line.""" journeys = self.coordinator.data.get(self.tid, []) departure_times = [] - base_departure_times = [] delays = [] overall_has_delay = False for journey in journeys: 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")) - delay = ( - int((arr_dt - base_arr_dt).total_seconds() / 60) - if arr_dt and base_arr_dt - else 0 - ) + dep_dt = parse_datetime(journey.get("departure_date_time", "")) + base_dep_dt = parse_datetime(section.get("base_departure_date_time")) + delay = 0 + if dep_dt and base_dep_dt: + delay = int((dep_dt - base_dep_dt).total_seconds() / 60) departure_times.append(format_time(journey.get("departure_date_time", ""))) - base_departure_times.append( - format_time(section.get("base_departure_date_time")) - ) delays.append(str(delay)) - if delay > 0: overall_has_delay = True self._attr_extra_state_attributes = { "departure_time": "; ".join(departure_times), - "base_departure_time": "; ".join(base_departure_times), "delay_minutes": "; ".join(delays), "has_delay": overall_has_delay, } - - # On peut mettre un "native_value" arbitraire, par exemple le nombre de trains self._attr_native_value = len(journeys) - - self.async_write_ha_state() + self.async_write_ha_state() \ No newline at end of file diff --git a/custom_components/sncf_trains/strings.json b/custom_components/sncf_trains/strings.json index fe26c0f..2ef8100 100644 --- a/custom_components/sncf_trains/strings.json +++ b/custom_components/sncf_trains/strings.json @@ -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%]" @@ -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": { @@ -61,7 +61,8 @@ "data": { "time_start": "Time start", "time_end": "Time end", - "train_count": "Trains count" + "train_count": "Trains count", + "show_route_details": "Show intermediate stops" } }, "reconfigure": { @@ -69,15 +70,16 @@ "data": { "time_start": "Time start", "time_end": "Time end", - "train_count": "Trains count" + "train_count": "Trains count", + "show_route_details": "Show intermediate stops" } } }, "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%]" } } diff --git a/custom_components/sncf_trains/translations/fr.json b/custom_components/sncf_trains/translations/fr.json index ece14a1..1ef5083 100644 --- a/custom_components/sncf_trains/translations/fr.json +++ b/custom_components/sncf_trains/translations/fr.json @@ -14,7 +14,7 @@ "invalid_api_key": "ClĂ© API invalide. Veuillez vĂ©rifier et rĂ©essayer." }, "abort": { - "reconfigure_successful": "Compose reconfigurĂ© avec succĂšs" + "reconfigure_successful": "IntĂ©gration reconfigurĂ©e avec succĂšs" } }, "options": { @@ -65,16 +65,18 @@ "title": "Choisissez la plage horaire", "data": { "time_start": "Heure de dĂ©part", - "time_end": "Heure d'arrivĂ©", - "train_count": "Nombre de trains" + "time_end": "Heure d'arrivĂ©e", + "train_count": "Nombre de trains", + "show_route_details": "Afficher les arrĂȘts intermĂ©diaires" } }, "reconfigure": { "title": "Choisissez la plage horaire", "data": { "time_start": "Heure de dĂ©part", - "time_end": "Heure d'arrivĂ©", - "train_count": "Nombre de trains" + "time_end": "Heure d'arrivĂ©e", + "train_count": "Nombre de trains", + "show_route_details": "Afficher les arrĂȘts intermĂ©diaires" } } }, diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 5e27af0..d8d810e 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -1,513 +1,319 @@ -// Ajouter au registre des cartes personnalisĂ©es -(window.customCards = window.customCards || []).push({ +// SNCF Train Card V3.5.1 +window.customCards = window.customCards || []; +window.customCards.push({ type: 'sncf-train-card', name: 'SNCF Train Card', - description: 'Carte personnalisĂ©e animĂ©e pour afficher les trains SNCF en temps rĂ©el' + preview: true, + description: 'Version intĂ©grale - Radar, Animation temps rĂ©el et Éditeur visuel.' }); -class SncfTrainCard extends HTMLElement { +// --- ÉDITEUR VISUEL (CODE COMPLET) --- +class SncfTrainCardEditor extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); - this.updateInterval = null; - this.lastTrainSignature = null; - this._lastRenderTime = 0; } setConfig(config) { - if (!config.device_id) { - throw new Error('You need to define device_id'); - } - - const previousDeviceId = this.config ? this.config.device_id : null; - const deviceIdChanged = previousDeviceId && previousDeviceId !== config.device_id; - - this.config = { - device_id: config.device_id, - train_lines: config.train_lines || 3, - title: config.title || 'Trains SNCF', - train_emoji: config.train_emoji || '🚅', - train_emoji_axial_symmetry: config.train_emoji_axial_symmetry || true, - train_station_emoji: config.train_station_emoji || '🚉', - animation_duration: config.animation_duration || 30, - update_interval: config.update_interval || 30000, - ...config - }; - - // Forcer la mise Ă  jour immĂ©diate si device_id a changĂ© - if (deviceIdChanged) { - this.stopUpdateTimer(); - this.startUpdateTimer(); - } - - // Toujours forcer un nouveau rendu + this._config = { ...config }; this.render(); } set hass(hass) { - const previousHass = this._hass; this._hass = hass; - - // VĂ©rifier si les donnĂ©es des trains ont changĂ© - if (this.config && previousHass) { - this.checkForTrainUpdates(previousHass, hass); - } else { - this.render(); - } } - async checkForTrainUpdates(previousHass, currentHass) { - try { - // RĂ©cupĂ©rer les entitĂ©s actuelles - const currentTrains = await this.getTrainEntities(); - - // CrĂ©er une signature des donnĂ©es actuelles - const currentSignature = this.createTrainSignature(currentTrains); - - // Comparer avec la signature prĂ©cĂ©dente - if (currentSignature !== this.lastTrainSignature) { - this.lastTrainSignature = currentSignature; - this.render(); - } - } catch (error) { - // En cas d'erreur, faire un rendu quand mĂȘme - this.render(); - } + render() { + if (!this._config) return; + + this.shadowRoot.innerHTML = ` +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+ + +
+
+ + + `; + + this.shadowRoot.querySelectorAll('input').forEach(input => { + input.addEventListener('change', this.valueChanged.bind(this)); + }); } - createTrainSignature(trains) { - return trains.map(train => - `${train.entity_id}:${train.attributes.departure_time}:${train.attributes.delay_minutes || 0}:${train.attributes.has_delay || false}` - ).join('|'); + valueChanged(ev) { + if (!this._config) return; + const target = ev.target; + let value = target.type === 'checkbox' ? target.checked : (target.type === 'number' ? Number(target.value) : target.value); + this._config = { ...this._config, [target.id]: value }; + this.dispatchEvent(new CustomEvent('config-changed', { detail: { config: this._config }, bubbles: true, composed: true })); } +} +customElements.define('sncf-train-card-editor', SncfTrainCardEditor); - connectedCallback() { - this.startUpdateTimer(); +// --- CARTE PRINCIPALE (LOGIQUE COMPLÈTE) --- +class SncfTrainCard extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.updateInterval = null; + this.lastTrainSignature = null; + this._lastRenderTime = 0; } - disconnectedCallback() { - this.stopUpdateTimer(); + static getConfigElement() { return document.createElement("sncf-train-card-editor"); } + + setConfig(config) { + if (!config.device_id) throw new Error('You need to define device_id'); + this.config = { + title: "Trains SNCF", + train_emoji: "🚅", + train_station_emoji: "🚉", + train_emoji_axial_symmetry: true, + animation_duration: 30, + use_real_duration: true, + speed_factor: 2, + update_interval: 30000, + show_real_stop_times: true, + ...config + }; + this.render(); } + set hass(hass) { + this._hass = hass; + this.render(); + } + + connectedCallback() { this.startUpdateTimer(); } + disconnectedCallback() { this.stopUpdateTimer(); } + startUpdateTimer() { this.stopUpdateTimer(); this.updateInterval = setInterval(async () => { if (this._hass) { - // Force un nouveau rendu Ă  intervalles rĂ©guliers pour capturer les changements - this._lastRenderTime = 0; // Reset du throttle + this._lastRenderTime = 0; await this.render(); } }, this.config.update_interval); } stopUpdateTimer() { - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; - } + if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } } async getTrainEntities() { - if (!this._hass) return []; - + if (!this._hass || !this.config.device_id) return []; try { - // Utiliser l'API Home Assistant pour rĂ©cupĂ©rer toutes les entitĂ©s - const allEntityRegistry = await this._hass.callWS({ - type: 'config/entity_registry/list' - }); - - // Filtrer les entitĂ©s par device_id - const deviceEntities = allEntityRegistry.filter(entityInfo => - entityInfo.device_id === this.config.device_id - ); - - if (!deviceEntities || deviceEntities.length === 0) { - console.warn('⚠ Aucune entitĂ© trouvĂ©e pour ce device_id dans le registre'); - return []; - } - - // RĂ©cupĂ©rer les Ă©tats des entitĂ©s train trouvĂ©es avec donnĂ©es fraĂźches - const trainEntities = deviceEntities - .filter(entityInfo => entityInfo.entity_id.includes('train')) - .map(entityInfo => { - // Forcer la rĂ©cupĂ©ration de l'Ă©tat frais - const freshState = this._hass.states[entityInfo.entity_id]; - return freshState; - }) - .filter(entity => entity && entity.attributes && entity.attributes.departure_time); + const allReg = await this._hass.callWS({ type: 'config/entity_registry/list' }); + const deviceEntities = allReg.filter(e => e.device_id === this.config.device_id); + const trainEntities = deviceEntities.filter(e => e.entity_id.includes('train')) + .map(e => this._hass.states[e.entity_id]) + .filter(e => e && e.attributes && e.attributes.departure_time); - // Filtrer les trains qui ne sont pas encore passĂ©s - const currentTime = new Date(); - const upcomingTrains = trainEntities.filter(entity => { - const departureTime = this.parseTime(entity.attributes.departure_time); - return departureTime >= currentTime; - }); - - const sortedEntities = upcomingTrains - .sort((a, b) => { - const aTime = this.parseTime(a.attributes.departure_time); - const bTime = this.parseTime(b.attributes.departure_time); - return aTime - bTime; - }) - .slice(0, this.config.train_lines); - - return sortedEntities; - - } catch (error) { - console.error('❌ Erreur lors de la rĂ©cupĂ©ration via API:', error); - return []; - } - } - - // MĂ©thode pour parser correctement le format SNCF - parseTime(departureTime) { - if (!departureTime) { - return new Date(0); - } - - // Format SNCF: "19/11/2025 - 08:20" - if (departureTime.includes('/') && departureTime.includes(' - ')) { - const parts = departureTime.split(' - '); - if (parts.length === 2) { - const datePart = parts[0]; // "19/11/2025" - const timePart = parts[1]; // "08:20" - - const dateComponents = datePart.split('/'); - if (dateComponents.length === 3) { - const day = parseInt(dateComponents[0]); - const month = parseInt(dateComponents[1]) - 1; // Mois 0-indexĂ© - const year = parseInt(dateComponents[2]); - - const timeComponents = timePart.split(':'); - if (timeComponents.length === 2) { - const hour = parseInt(timeComponents[0]); - const minute = parseInt(timeComponents[1]); - - return new Date(year, month, day, hour, minute); - } - } - } - } - - // Fallback vers Date classique - return new Date(departureTime); - } - - calculateTrainPosition(departureTime, currentTime) { - if (!departureTime) { - return -10; - } - - const departure = this.parseTime(departureTime); - - if (isNaN(departure.getTime())) { - return -10; - } - - const now = currentTime || new Date(); - const diffMinutes = (departure - now) / (1000 * 60); - - // Train apparaĂźt 30 minutes avant l'heure - const maxMinutes = this.config.animation_duration; - - if (diffMinutes > maxMinutes) { - return -10; // Hors de la barre - } - if (diffMinutes <= 0) { - return 100; // ArrivĂ© Ă  la gare - } - - // Position sur la barre (0% = gauche, 100% = droite) - return ((maxMinutes - diffMinutes) / maxMinutes) * 100; - } - - formatTime(timeString) { - if (!timeString) { - return 'N/A'; - } - - const time = this.parseTime(timeString); - - if (isNaN(time.getTime())) { - return 'Format invalide'; - } - - const result = time.toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit' - }); - - return result; - } - - calculateRealArrivalTime(departureTime, delayMinutes) { - if (!departureTime || !delayMinutes || delayMinutes === 0) { - return null; - } - - const originalTime = this.parseTime(departureTime); - const realTime = new Date(originalTime.getTime() + (delayMinutes * 60000)); // Ajouter les minutes de retard - - return realTime.toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit' - }); + const now = new Date(); + return trainEntities.filter(e => this.parseTime(e.attributes.departure_time) >= now) + .sort((a, b) => this.parseTime(a.attributes.departure_time) - this.parseTime(b.attributes.departure_time)) + .slice(0, this.config.train_lines || 3); + } catch (e) { return []; } } - getTrainColor(delayMinutes, hasDelay) { - if (!hasDelay || delayMinutes === 0) return '#4caf50'; // Vert Ă  l'heure - return '#f44336'; // Rouge en retard (peu importe le nombre de minutes) + parseTime(t) { + if (!t || !t.includes(' - ')) return new Date(0); + const p = t.split(' - '), d = p[0].split('/'), h = p[1].split(':'); + return new Date(d[2], d[1]-1, d[0], h[0], h[1]); } - async render() { - if (!this._hass || !this.config) { - return; - } - - // Éviter les rendus trop frĂ©quents (max 1 par seconde) - const now = Date.now(); - if (now - this._lastRenderTime < 1000) { - return; - } - this._lastRenderTime = now; - - const trains = await this.getTrainEntities(); + render() { + if (!this._hass || !this.config) return; - if (trains.length === 0) { - this.shadowRoot.innerHTML = ` - -
-
Aucun train trouvé pour ce device. Vérifiez la configuration.
-
-
- `; - return; - } + const nowTs = Date.now(); + if (nowTs - this._lastRenderTime < 1000) return; + this._lastRenderTime = nowTs; - const currentTime = new Date(); - - const trainLinesHTML = this.renderTrainLines(trains, currentTime); - this.shadowRoot.innerHTML = ` - - - -
-
-
${this.config.title}
-
- - ${trainLinesHTML} - -
-
- `; - } + let animDur = this.config.use_real_duration && attrs.duration_minutes ? + attrs.duration_minutes * (this.config.speed_factor || 2) : 30; - renderTrainLines(trains, currentTime) { - return trains.map((train, index) => { - const position = this.calculateTrainPosition(train.attributes.departure_time, currentTime); - const delayMinutes = train.attributes.delay_minutes || 0; - const hasDelay = train.attributes.has_delay || false; - const trainColor = this.getTrainColor(delayMinutes, hasDelay); - const formattedTime = this.formatTime(train.attributes.departure_time); - const realArrivalTime = this.calculateRealArrivalTime(train.attributes.departure_time, delayMinutes); - - const html = ` -
-
- ${position >= 0 && position <= 100 ? ` -
- ${this.config.train_emoji} + let timelineHTML = ''; + if (this.config.show_route_details && attrs.stops_schedule) { + timelineHTML = ` +
+
+
+ ${attrs.stops_schedule.map(s => { + const isDeleted = s.effect === 'deleted'; + const isAdded = s.effect === 'added'; + const isStopDelayed = this.config.show_real_stop_times && s.amended_time && s.base_time && (s.amended_time !== s.base_time); + + const displayTime = isStopDelayed ? + `${s.base_time}${s.amended_time}` : + `${s.base_time || s.time}`; + + let statusBadge = ""; + if (isDeleted) statusBadge = ' SUPPRIMÉ'; + else if (isAdded) statusBadge = ' RAJOUTÉ'; + + return ` +
+
+
${displayTime}
+
+ ${s.name}${statusBadge} +
+
`; + }).join('')}
- ` : ` - - `} -
- -
-
${this.config.train_station_emoji}
-
-
- ${hasDelay && realArrivalTime ? ` -
${formattedTime}
-
${realArrivalTime}
- ` : ` -
${formattedTime}
- `} +
`; + } + + const timeOnly = (t) => t ? t.split(' - ')[1] : "--:--"; + + return ` +
+
+
+ ${pos >= 0 && pos <= 100 ? ` +
+ ${isCanceled ? '❌' : this.config.train_emoji} +
+ ` : ''}
-
- ${hasDelay ? `+${delayMinutes}min` : 'À l\'heure'} +
+
${this.config.train_station_emoji}
+
+
${hasDelay ? `${timeOnly(attrs.base_departure_time)}${timeOnly(attrs.departure_time)}` : timeOnly(attrs.departure_time)}
+
+ ${isCanceled ? 'ANNULÉ' : (hasDelay ? `+${attrs.delay_minutes}min` : 'À l\'heure')} +
+ ${attrs.delay_cause ? `
${attrs.delay_cause}
` : ''} +
-
-
- `; - - return html; - }).join(''); - } + ${timelineHTML} +
`; + }).join(''); - getCardSize() { - return Math.max(3, this.config.train_lines + 1); + this.shadowRoot.innerHTML = ` + + +
${this.config.title}
+ ${trainLinesHTML} +
`; + }); } } - -// DĂ©finir l'Ă©lĂ©ment custom customElements.define('sncf-train-card', SncfTrainCard); \ No newline at end of file diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..43578bf --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,90 @@ +import pytest +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from custom_components.sncf_trains.const import DOMAIN, CONF_API_KEY + + +@pytest.mark.asyncio +async def test_config_flow_happy_path(hass): + """Test config flow with valid API key and stations.""" + mock_api = AsyncMock() + mock_api.search_stations = AsyncMock( + side_effect=[ + [{"id": "stop_area:dep", "name": "Paris Gare de Lyon"}], # departure city + [{"id": "stop_area:arr", "name": "Lyon Part Dieu"}], # arrival city + ] + ) + + with patch( + "custom_components.sncf_trains.config_flow.SncfApiClient", return_value=mock_api + ): + # Step 1: saisie API key + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "valid_key"} + ) + assert result["type"] == "form" + assert result["step_id"] == "departure_city" + + # Step 2: ville dĂ©part + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"departure_city": "Paris"} + ) + assert result["type"] == "form" + assert result["step_id"] == "departure_station" + + # Step 3: station dĂ©part + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"departure_station": "stop_area:dep"} + ) + assert result["type"] == "form" + assert result["step_id"] == "arrival_city" + + # Step 4: ville arrivĂ©e + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"arrival_city": "Lyon"} + ) + assert result["type"] == "form" + assert result["step_id"] == "arrival_station" + + # Step 5: station arrivĂ©e + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"arrival_station": "stop_area:arr"} + ) + assert result["type"] == "form" + assert result["step_id"] == "time_range" + + # Step 6: plage horaire + finalisation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"time_start": "07:00", "time_end": "10:00"} + ) + assert result["type"] == "create_entry" + assert result["title"] == "SNCF: Paris Gare de Lyon → Lyon Part Dieu" + assert result["data"]["departure_name"] == "Paris Gare de Lyon" + + +@pytest.mark.asyncio +async def test_config_flow_invalid_api_key(hass): + """Test config flow with invalid API key.""" + mock_api = AsyncMock() + mock_api.search_stations = AsyncMock(return_value=None) + + with patch( + "custom_components.sncf_trains.config_flow.SncfApiClient", return_value=mock_api + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bad_key"} + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "invalid_api_key" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..1e233f1 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import AsyncMock +from datetime import timedelta +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.sncf_trains.coordinator import SncfUpdateCoordinator + + +@pytest.mark.asyncio +async def test_coordinator_success(hass): + """Test coordinator fetches journeys successfully.""" + mock_api = AsyncMock() + mock_api.fetch_journeys = AsyncMock(return_value=[{"id": "j1"}]) + + coordinator = SncfUpdateCoordinator( + hass=hass, + api_client=mock_api, + departure="stop_area:dep", + arrival="stop_area:arr", + time_start="06:00", + time_end="09:00", + update_interval=5, + outside_interval=30, + ) + + data = await coordinator._async_update_data() + assert data == [{"id": "j1"}] + mock_api.fetch_journeys.assert_called_once() + assert isinstance(coordinator.update_interval, timedelta) + + +@pytest.mark.asyncio +async def test_coordinator_api_failure(hass): + """Test coordinator raises UpdateFailed when API fails.""" + mock_api = AsyncMock() + mock_api.fetch_journeys = AsyncMock(side_effect=Exception("API error")) + + coordinator = SncfUpdateCoordinator( + hass=hass, + api_client=mock_api, + departure="stop_area:dep", + arrival="stop_area:arr", + time_start="06:00", + time_end="09:00", + ) + + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +@pytest.mark.asyncio +async def test_coordinator_adjust_interval(hass): + """Test that update interval adjusts inside and outside time range.""" + + mock_api = AsyncMock() + mock_api.fetch_journeys = AsyncMock(return_value=[{"id": "j1"}]) + + coordinator = SncfUpdateCoordinator( + hass=hass, + api_client=mock_api, + departure="stop_area:dep", + arrival="stop_area:arr", + time_start="00:00", + time_end="23:59", + update_interval=5, + outside_interval=30, + ) + + # Forcing inside time range (always true here) + await coordinator._async_update_data() + assert coordinator.update_interval == timedelta(minutes=5) + + # Fake outside range by setting opposite times + coordinator.time_start = "23:59" + coordinator.time_end = "00:00" + + await coordinator._async_update_data() + assert coordinator.update_interval == timedelta(minutes=30)