From 9c4cb5ebcb3547232718887fa8130036889355c8 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Fri, 15 May 2026 01:42:08 +0200 Subject: [PATCH 01/16] =?UTF-8?q?Am=C3=A9lioration=20v1,=20tests=20=C3=A0?= =?UTF-8?q?=20approfondir=20et=20code=20=C3=A0=20relire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit 16934fd397472018ef0b0e0971eaa7257e665f3e) --- README.md | 24 +- .../sncf_trains/www/sncf-train-card.js | 488 ++++++++++++++---- 2 files changed, 396 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 6691f9f..2289506 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,11 @@ La carte `sncf-train-card` est **automatiquement disponible** dans le sélecteur Dans un tableau de bord, cliquer sur **+ Ajouter une carte** → chercher **SNCF Train Card**. +La configuration peut ensuite se faire : + +- via l'éditeur visuel Lovelace +- ou via YAML + Ou en YAML : ```yaml @@ -124,6 +129,8 @@ device_id: VOTRE_DEVICE_ID ### 🔍 Trouver le `device_id` +_S'obtient dynamiquement via la configuration visuelle._ + Le `device_id` correspond à l'appareil créé lors de la configuration du trajet. 1. Aller dans **Paramètres → Appareils & services → SNCF Trains** @@ -139,11 +146,14 @@ Le `device_id` correspond à l'appareil créé lors de la configuration du traje | `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** | +| `train_emoji_axial_symmetry` | `boolean` | `true` | Retourne l'emoji du train horizontalement | +| `train_emoji` | `string` | `'🚅'` | Emoji du train animé sur la barre | +| `show_departure_station` | `boolean` | `true` | Affiche ou masque les informations de départ | +| `departure_station_emoji` | `string` | `''` | Emoji de la station de départ | +| `show_arrival_station` | `boolean` | `true` | Affiche ou masque les informations d'arrivée | +| `arrival_station_emoji` | `string` | `'🚉'` | Emoji de la station d'arrivée | ### Exemple complet @@ -154,13 +164,17 @@ title: "Paris → Lyon" train_lines: 4 train_emoji: "🚆" train_emoji_axial_symmetry: true -train_station_emoji: "🏙️" -animation_duration: 45 +show_departure_station: true +departure_station_emoji: "🚉" +show_arrival_station: true +arrival_station_emoji: "🏙️" +animation_duration: 0 update_interval: 60000 ``` ### Exemple d'affichage +![Exemple d'affichage](./assets/card_example.png) ![Exemple d'affichage](./assets/card_example.png) --- diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 5e27af0..fe9ee0e 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -1,53 +1,201 @@ // Ajouter au registre des cartes personnalisées -(window.customCards = window.customCards || []).push({ +globalThis.customCards ||= [] +globalThis.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' + description: 'Carte personnalisée animée pour afficher les trains SNCF en temps réel', + preview: true, + configurable: true }); class SncfTrainCard extends HTMLElement { constructor() { super(); - this.attachShadow({ mode: 'open' }); + this.attachShadow({mode: 'open'}); this.updateInterval = null; this.lastTrainSignature = null; this._lastRenderTime = 0; } + /** + * Méthode héritée
+ * Permet de définir la configuration de la carte, avec validation et gestion des changements de device_id pour forcer une mise à jour immédiate + * @param {Object} config - La configuration de la carte, qui doit inclure au minimum un device_id valide pour fonctionner correctement, et peut inclure d'autres paramètres pour personnaliser l'affichage + * @throws {Error} Si le device_id n'est pas défini, une erreur est levée pour informer l'utilisateur de la nécessité de fournir cette information essentielle + */ 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 - }; - + + this.config = config; + // Forcer la mise à jour immédiate si device_id a changé if (deviceIdChanged) { this.stopUpdateTimer(); this.startUpdateTimer(); } - + // Toujours forcer un nouveau rendu this.render(); } + /** + * Méthode héritée
+ * Fournit la configuration du formulaire pour l'éditeur de Lovelace, avec des labels et des aides personnalisés + */ + static getConfigForm() { + return { + schema: [ + { + name: "device_id", + required: true, + selector: { + device: { + filter: { + integration: "sncf_trains" + } + } + } + }, + { + name: "title", + selector: {text: {}}, + }, + { + name: "train_lines", + selector: { + number: { + min: 1, + max: 10, + step: 1, + }, + }, + }, + { + name: "animation_duration", + selector: { + number: { + min: 0, + max: 100, + step: 1, + }, + }, + }, + { + name: "update_interval", + selector: { + number: { + min: 5000, + step: 1000, + }, + }, + }, + { + type: "grid", + name: "", + column_min_width: "150px", + schema: [ + { + name: "train_emoji_axial_symmetry", + selector: {boolean: {}}, + }, + { + name: "train_emoji", + selector: { + icon: {}, + }, + }, + { + name: "show_departure_station", + selector: {boolean: {}}, + }, + { + name: "departure_station_emoji", + selector: { + icon: {}, + }, + }, + { + name: "show_arrival_station", + selector: {boolean: {}}, + }, + { + name: "arrival_station_emoji", + selector: { + icon: {}, + }, + }, + ] + }, + ], + computeLabel: (schema) => { + const labels = { + device_id: "ID du Device (obligatoire)", + title: "Titre de la carte", + train_emoji: "Emoji du train", + train_lines: "Nombre de trains à afficher", + animation_duration: "Durée d'animation (minutes)", + update_interval: "Intervalle de mise à jour (ms)", + departure_station_emoji: "Emoji de la gare de départ", + arrival_station_emoji: "Emoji de la gare d'arrivée", + show_departure_station: "Afficher les informations de départ", + show_arrival_station: "Afficher les informations d'arrivée", + train_emoji_axial_symmetry: "Symétrie axiale du train", + }; + return labels[schema.name] || undefined; + }, + computeHelper: (schema) => { + const helpers = { + device_id: "L'identifiant unique du device SNCF à afficher", + title: "Le titre affiché en haut de la carte", + train_emoji: "L'emoji représentant le train", + train_lines: "Le nombre de trains à afficher (1-10)", + animation_duration: "Nombre de minutes avant le départ pour que le train apparaisse", + update_interval: "Fréquence de rafraîchissement en millisecondes (ex: 30000 pour 30s)", + departure_station_emoji: "L'emoji pour la gare de départ", + arrival_station_emoji: "L'emoji pour la gare d'arrivée", + show_departure_station: "Affiche ou masque la gare de départ", + show_arrival_station: "Affiche ou masque la gare d'arrivée", + train_emoji_axial_symmetry: "Retourner l'emoji du train horizontalement", + }; + return helpers[schema.name] || undefined; + }, + }; + } + + /** + * Méthode héritée
+ * Fournit une configuration par défaut pour le mode aperçu dans l'éditeur de Lovelace + */ + static getStubConfig() { + return { + device_id: '', + title: 'Trains SNCF', + train_lines: 3, + animation_duration: 30, + update_interval: 30000, + train_emoji_axial_symmetry: true, + train_emoji: '🚅', + show_departure_station: true, + departure_station_emoji: '', + show_arrival_station: true, + arrival_station_emoji: '🚉', + }; + } + + /** + * Méthode héritée
+ * Permet de recevoir l'objet Home Assistant et de déclencher une vérification des mises à jour des trains pour éviter les rendus inutiles + * @param {Object} hass - L'objet Home Assistant fourni par le système, utilisé pour accéder aux états et aux services, et pour déclencher des mises à jour de la carte lorsque les données des trains changent + */ 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); @@ -56,14 +204,43 @@ class SncfTrainCard extends HTMLElement { } } + /** + * Méthode héritée
+ * Démarre un timer pour forcer des mises à jour régulières, ce qui est nécessaire pour capturer les changements de données en temps réel + */ + connectedCallback() { + this.startUpdateTimer(); + } + + /** + * Méthode héritée
+ * Arrête le timer de mise à jour pour éviter les fuites de mémoire lorsque la carte est retirée du DOM + */ + disconnectedCallback() { + this.stopUpdateTimer(); + } + + /** + * Méthode héritée
+ * Calcule la taille de la carte en fonction du nombre de lignes de train à afficher, avec une taille minimale pour éviter les problèmes d'affichage + */ + getCardSize() { + return Math.max(3, this.config.train_lines + 1); + } + + /** + * Vérifie si les données des trains ont changé en comparant une signature des données actuelles avec la dernière signature connue, et ne fait un rendu que si nécessaire pour optimiser les performances + * @param {Object} previousHass - L'objet Home Assistant précédent pour comparer les données + * @param {Object} currentHass - L'objet Home Assistant actuel pour récupérer les données fraîches + */ 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; @@ -71,24 +248,25 @@ class SncfTrainCard extends HTMLElement { } } catch (error) { // En cas d'erreur, faire un rendu quand même + console.error(error); this.render(); } } + /** + * Crée une signature unique pour les données des trains en concaténant les informations clés de chaque train, ce qui permet de détecter facilement les changements sans faire un rendu complet à chaque fois + * @param {Array} trains - Un tableau d'entités de train + * @returns {string} Une chaîne de caractères représentant la signature des données des trains + */ createTrainSignature(trains) { - return trains.map(train => + return trains.map(train => `${train.entity_id}:${train.attributes.departure_time}:${train.attributes.delay_minutes || 0}:${train.attributes.has_delay || false}` ).join('|'); } - connectedCallback() { - this.startUpdateTimer(); - } - - disconnectedCallback() { - this.stopUpdateTimer(); - } - + /** + * Démarre un timer qui force un rendu de la carte à intervalles réguliers, ce qui est nécessaire pour capturer les changements de données en temps réel, surtout pour les données de train qui peuvent changer fréquemment + */ startUpdateTimer() { this.stopUpdateTimer(); this.updateInterval = setInterval(async () => { @@ -100,6 +278,9 @@ class SncfTrainCard extends HTMLElement { }, this.config.update_interval); } + /** + * Arrête le timer de mise à jour pour éviter les fuites de mémoire lorsque la carte est retirée du DOM ou lorsque le device_id change, ce qui est important pour maintenir les performances et éviter les rendus inutiles + */ stopUpdateTimer() { if (this.updateInterval) { clearInterval(this.updateInterval); @@ -107,158 +288,193 @@ class SncfTrainCard extends HTMLElement { } } + /** + * Récupère les entités de train associées au device_id configuré en utilisant l'API WebSocket de Home Assistant pour obtenir des données fraîches, filtre les trains qui ne sont pas encore passés, et trie les résultats par heure de départ pour n'afficher que les trains à venir, ce qui garantit que les informations affichées sont toujours à jour et pertinentes pour l'utilisateur + * @returns {Promise} Un tableau d'entités de train avec des données fraîches + */ async getTrainEntities() { if (!this._hass) 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 => + 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; + return this._hass.states[entityInfo.entity_id]; }) - .filter(entity => entity && entity.attributes && entity.attributes.departure_time); - + .filter(entity => entity?.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 + + return 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 + /** + * Parse une chaîne de temps au format spécifique de la SNCF (ex: "19/11/2025 - 08:20") et retourne un objet Date, ou une date par défaut si le format est invalide ou si la chaîne est vide, ce qui permet de gérer correctement les données de temps fournies par les entités de train et d'éviter les erreurs d'affichage + * @param {string} departureTime - La chaîne de temps à parser, qui peut être au format SNCF ou un format standard reconnu par JavaScript + * @returns {Date} Un objet Date représentant le temps de départ, ou une date par défaut si le parsing échoue + */ 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 day = Number.parseInt(dateComponents[0]); + const month = Number.parseInt(dateComponents[1]) - 1; // Mois 0-indexé + const year = Number.parseInt(dateComponents[2]); + const timeComponents = timePart.split(':'); if (timeComponents.length === 2) { - const hour = parseInt(timeComponents[0]); - const minute = parseInt(timeComponents[1]); - + const hour = Number.parseInt(timeComponents[0]); + const minute = Number.parseInt(timeComponents[1]); + return new Date(year, month, day, hour, minute); } } } } - + // Fallback vers Date classique return new Date(departureTime); } + /** + * Calcule la position du train sur la barre de progression en fonction de l'heure actuelle et de l'heure de départ, en affichant le train 30 minutes avant le départ et en le faisant avancer vers la droite à mesure que l'heure de départ approche, ce qui crée une animation visuelle intuitive pour les utilisateurs afin de suivre l'approche du train vers la gare, et retourne une position en pourcentage (0% = train à gauche, 100% = train arrivé) ou une valeur négative pour indiquer que le train n'est pas encore visible, ce qui permet de gérer l'affichage du train de manière dynamique en fonction du temps restant avant le départ + * @param {string} departureTime - La chaîne de temps de départ à utiliser pour calculer la position du train, qui doit être au format reconnu par la méthode parseTime + * @param {Date} [currentTime] - L'heure actuelle à utiliser pour le calcul, qui peut être fournie pour les tests ou les rendus spécifiques, sinon la date actuelle sera utilisée par défaut + * @returns {number} Un nombre représentant la position du train en pourcentage (0-100) ou une valeur négative si le train n'est pas encore visible + */ calculateTrainPosition(departureTime, currentTime) { if (!departureTime) { return -10; } - + const departure = this.parseTime(departureTime); - - if (isNaN(departure.getTime())) { + + if (Number.isNaN(departure.getTime())) { return -10; } - + const now = currentTime || new Date(); const diffMinutes = (departure - now) / (1000 * 60); - + // Train apparaît 30 minutes avant l'heure + // todo : tester et s'assurer de la véracité / nom du param animation_duration const maxMinutes = this.config.animation_duration; - + if (diffMinutes > maxMinutes) { + if(maxMinutes === 0) { + return 0; + } 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; } + /** + * Formate une chaîne de temps en une heure lisible au format français (ex: "08:20"), ou retourne "N/A" si la chaîne est vide, ou "Format invalide" si le parsing échoue, ce qui permet d'afficher les heures de départ et d'arrivée de manière claire et compréhensible pour les utilisateurs, tout en gérant les cas où les données de temps peuvent être manquantes ou mal formatées + * @param {string} timeString - La chaîne de temps à formater, qui doit être au format reconnu par la méthode parseTime + * @returns {string} Une chaîne représentant l'heure formatée ou un message d'erreur si le format est invalide + */ formatTime(timeString) { if (!timeString) { return 'N/A'; } - + const time = this.parseTime(timeString); - - if (isNaN(time.getTime())) { + + if (Number.isNaN(time.getTime())) { return 'Format invalide'; } - - const result = time.toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit' + + return time.toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit' }); - - return result; } + /** + * Calcule l'heure d'arrivée réelle en ajoutant les minutes de retard à l'heure de départ prévue, et retourne une chaîne formatée de l'heure d'arrivée réelle, ou null si les données nécessaires sont manquantes ou si le train n'a pas de retard. + * @param departureTime - L'heure de départ + * @param delayMinutes - Le temps de retard en minutes + * @returns {string} Une chaîne représentant l'heure avec retard formatée ou null + */ + // TODO : tester si encore utile ? 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' + + return realTime.toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit' }); } + /** + * Calcule la couleur du train en fonction du retard + * @param {number} delayMinutes - Le nombre de minutes de retard + * @param {boolean} hasDelay - Indique si le train a du retard ou non + * @returns {string} La couleur correspondante + */ getTrainColor(delayMinutes, hasDelay) { if (!hasDelay || delayMinutes === 0) return '#4caf50'; // Vert à l'heure return '#f44336'; // Rouge en retard (peu importe le nombre de minutes) } + /** + * Méthode héritée
+ * Génération du rendu de l'ensemble de la carte, incluant le css et l'html + */ async render() { if (!this._hass || !this.config) { return; @@ -272,7 +488,7 @@ class SncfTrainCard extends HTMLElement { this._lastRenderTime = now; const trains = await this.getTrainEntities(); - + if (trains.length === 0) { this.shadowRoot.innerHTML = ` @@ -378,7 +594,7 @@ class SncfTrainCard extends HTMLElement { filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); } - .train-emoji-axial-symmetry { + .train-emoji-axial-symmetry-true { transform: translateX(-50%) scaleX(-1); } @@ -387,7 +603,6 @@ class SncfTrainCard extends HTMLElement { flex-direction: row; align-items: center; gap: 8px; - min-width: 120px; } .station-emoji { @@ -459,54 +674,105 @@ class SncfTrainCard extends HTMLElement { `; } + /** + * Rendu des lignes de train en fonction des données fournies, en calculant la position de chaque train sur la barre de progression, en affichant les informations de départ et d'arrivée selon la configuration, et en appliquant des styles différents pour les trains en retards. + * @param {Array} trains - Un tableau d'entités de train à afficher, avec leurs attributs contenant les informations nécessaires pour le rendu + * @param {Date} currentTime - L'heure actuelle à utiliser pour le calcul de la position des trains, ce qui permet de faire avancer les trains vers la droite à mesure que l'heure de départ approche, et d'afficher les informations de retard de manière dynamique en fonction du temps restant avant le départ + * @returns {string} Une chaîne HTML représentant la section complète du train + */ 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 = ` + + let trainPositionHTML = '' + if (position >= 0 && position <= 100) { + trainPositionHTML = ` +
+ ${this.config.train_emoji} +
+ ` + } + + return `
+ ${this.config.show_departure_station ? this.renderDeparture(train.attributes) : ''} +
- ${position >= 0 && position <= 100 ? ` -
- ${this.config.train_emoji} -
- ` : ` - - `} + ${trainPositionHTML}
-
-
${this.config.train_station_emoji}
-
-
- ${hasDelay && realArrivalTime ? ` -
${formattedTime}
-
${realArrivalTime}
- ` : ` -
${formattedTime}
- `} -
-
- ${hasDelay ? `+${delayMinutes}min` : 'À l\'heure'} -
-
-
+ ${this.config.show_arrival_station ? this.renderArrival(train.attributes) : ''}
`; - - return html; }).join(''); } - getCardSize() { - return Math.max(3, this.config.train_lines + 1); + /** + * Rendu de la section de départ pour un train donné, en affichant l'heure de départ prévue, l'heure de départ réelle si le train a du retard. + * @param {object} trainAttributes - Les attributs du train + * @returns {string} Une chaîne HTML représentant la section de départ du train + */ + renderDeparture(trainAttributes) { + const hasDelay = trainAttributes.has_delay || false; + const delayMinutes = trainAttributes.delay_minutes || 0; + const departureTime = this.formatTime(trainAttributes.base_departure_time); + const realDepartureTime = this.formatTime(trainAttributes.departure_time); + + return ` +
+
+
+ ${hasDelay && realDepartureTime ? ` +
${departureTime}
+
${realDepartureTime}
+ ` : ` +
${departureTime}
+ `} +
+
+ ${hasDelay ? `+${delayMinutes}min` : 'À l\'heure'} +
+
+
${this.config.departure_station_emoji}
+
+ ` + } + + /** + * Rendu de la section d'arrivée pour un train donné, en affichant l'heure d'arrivée prévue, l'heure d'arrivée réelle si le train a du retard. + * @param {object} trainAttributes - Les attributs du train + * @returns {string} Une chaîne HTML représentant la section d'arrivée du train + */ + renderArrival(trainAttributes) { + const hasDelay = trainAttributes.has_delay || false; + const delayMinutes = trainAttributes.delay_minutes || 0; + const arrivalTime = this.formatTime(trainAttributes.base_arrival_time); + const realArrivalTime = this.formatTime(trainAttributes.arrival_time); + + return ` +
+
${this.config.arrival_station_emoji}
+
+
+ ${hasDelay && realArrivalTime ? ` +
${arrivalTime}
+
${realArrivalTime}
+ ` : ` +
${arrivalTime}
+ `} +
+
+ ${hasDelay ? `+${delayMinutes}min` : 'À l\'heure'} +
+
+
+ `; } + } // Définir l'élément custom From 9819addf29b4c8d80229724266c1e617d43481fb Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Fri, 15 May 2026 10:28:04 +0200 Subject: [PATCH 02/16] Fix de l'affichage des trains sur la barre --- .../sncf_trains/www/sncf-train-card.js | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index fe9ee0e..04638f1 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -320,17 +320,25 @@ class SncfTrainCard extends HTMLElement { }) .filter(entity => entity?.attributes?.departure_time); + // Source - https://stackoverflow.com/a/1214753 + // Posted by Kip, modified by community. See post 'Timeline' for change history + // Retrieved 2026-05-15, License - CC BY-SA 4.0 + const addMinutes = (date, minutes) => { + return new Date(date.getTime() + minutes*60000); + } + // 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; + // TODO : paramétrer le temps d'affichage max d'un train arrivé en gare + const arrivalTime = addMinutes(this.parseTime(entity.attributes.arrival_time), 30); + return arrivalTime >= currentTime; }); return upcomingTrains .sort((a, b) => { - const aTime = this.parseTime(a.attributes.departure_time); - const bTime = this.parseTime(b.attributes.departure_time); + const aTime = this.parseTime(a.attributes.arrival_time); + const bTime = this.parseTime(b.attributes.arrival_time); return aTime - bTime; }) .slice(0, this.config.train_lines); @@ -381,40 +389,41 @@ class SncfTrainCard extends HTMLElement { /** * Calcule la position du train sur la barre de progression en fonction de l'heure actuelle et de l'heure de départ, en affichant le train 30 minutes avant le départ et en le faisant avancer vers la droite à mesure que l'heure de départ approche, ce qui crée une animation visuelle intuitive pour les utilisateurs afin de suivre l'approche du train vers la gare, et retourne une position en pourcentage (0% = train à gauche, 100% = train arrivé) ou une valeur négative pour indiquer que le train n'est pas encore visible, ce qui permet de gérer l'affichage du train de manière dynamique en fonction du temps restant avant le départ - * @param {string} departureTime - La chaîne de temps de départ à utiliser pour calculer la position du train, qui doit être au format reconnu par la méthode parseTime - * @param {Date} [currentTime] - L'heure actuelle à utiliser pour le calcul, qui peut être fournie pour les tests ou les rendus spécifiques, sinon la date actuelle sera utilisée par défaut + * @param {object} trainAttributes - Les attributs du train, qui doivent inclure au minimum une heure de départ valide pour que le calcul fonctionne correctement, et peuvent inclure d'autres informations pour personnaliser l'affichage * @returns {number} Un nombre représentant la position du train en pourcentage (0-100) ou une valeur négative si le train n'est pas encore visible */ - calculateTrainPosition(departureTime, currentTime) { - if (!departureTime) { + calculateTrainPosition(trainAttributes) { + if (!trainAttributes.departure_time || !trainAttributes.arrival_time) { return -10; } - const departure = this.parseTime(departureTime); + const departure = this.parseTime(trainAttributes.departure_time); + const arrival = this.parseTime(trainAttributes.arrival_time); + const travelTime = (arrival - departure) / (1000 * 60); - if (Number.isNaN(departure.getTime())) { + if (Number.isNaN(departure.getTime()) || Number.isNaN(arrival.getTime()) || travelTime < 0) { return -10; } - const now = currentTime || new Date(); - const diffMinutes = (departure - now) / (1000 * 60); + const now = new Date(); + const diffMinutes = (arrival - now) / (1000 * 60); - // Train apparaît 30 minutes avant l'heure - // todo : tester et s'assurer de la véracité / nom du param animation_duration - const maxMinutes = this.config.animation_duration; - - if (diffMinutes > maxMinutes) { - if(maxMinutes === 0) { + if (diffMinutes > travelTime) { + // todo : tester et s'assurer de la véracité / nom du param animation_duration + if (this.config.animation_duration === 0 || this.config.animation_duration > diffMinutes - travelTime) { + // Train apparaît X minutes avant l'heure return 0; } - return -10; // Hors de la barre + // Hors de la barre + return -10; } if (diffMinutes <= 0) { - return 100; // Arrivé à la gare + // Arrivé à la gare + return 100; } // Position sur la barre (0% = gauche, 100% = droite) - return ((maxMinutes - diffMinutes) / maxMinutes) * 100; + return ((travelTime - diffMinutes) / travelTime) * 100; } /** @@ -500,9 +509,7 @@ class SncfTrainCard extends HTMLElement { return; } - const currentTime = new Date(); - - const trainLinesHTML = this.renderTrainLines(trains, currentTime); + const trainLinesHTML = this.renderTrainLines(trains); this.shadowRoot.innerHTML = ` - - -
-
-
${this.config.title}
-
- - ${trainLinesHTML} - -
-
- `; - } - - /** - * Rendu des lignes de train en fonction des données fournies, en calculant la position de chaque train sur la barre de progression, en affichant les informations de départ et d'arrivée selon la configuration, et en appliquant des styles différents pour les trains en retards. - * @param {Array} trains - Un tableau d'entités de train à afficher, avec leurs attributs contenant les informations nécessaires pour le rendu - * @param {Date} currentTime - L'heure actuelle à utiliser pour le calcul de la position des trains, ce qui permet de faire avancer les trains vers la droite à mesure que l'heure de départ approche, et d'afficher les informations de retard de manière dynamique en fonction du temps restant avant le départ - * @returns {string} Une chaîne HTML représentant la section complète du train - */ - renderTrainLines(trains) { - return trains.map((train, index) => { - const position = this.calculateTrainPosition(train.attributes); - const delayMinutes = train.attributes.delay_minutes || 0; - const hasDelay = train.attributes.has_delay || false; - const trainColor = this.getTrainColor(delayMinutes, hasDelay); - - let trainPositionHTML = '' - if (position >= 0 && position <= 100) { - trainPositionHTML = ` -
- ${this.config.train_emoji} -
- ` - } - - return ` -
- ${this.config.show_departure_station ? this.renderDeparture(train.attributes) : ''} - - -
- ${trainPositionHTML} -
- - ${this.config.show_arrival_station ? this.renderArrival(train.attributes) : ''} -
- `; - }).join(''); - } - - /** - * Rendu de la section de départ pour un train donné, en affichant l'heure de départ prévue, l'heure de départ réelle si le train a du retard. - * @param {object} trainAttributes - Les attributs du train - * @returns {string} Une chaîne HTML représentant la section de départ du train - */ - renderDeparture(trainAttributes) { - const hasDelay = trainAttributes.has_delay || false; - const delayMinutes = trainAttributes.delay_minutes || 0; - const departureTime = this.formatTime(trainAttributes.base_departure_time); - const realDepartureTime = this.formatTime(trainAttributes.departure_time); - - return ` -
-
-
- ${hasDelay && realDepartureTime ? ` -
${departureTime}
-
${realDepartureTime}
- ` : ` -
${departureTime}
- `} -
-
- ${hasDelay ? `+${delayMinutes}min` : 'À l\'heure'} -
-
-
${this.config.departure_station_emoji}
-
- ` - } - - /** - * Rendu de la section d'arrivée pour un train donné, en affichant l'heure d'arrivée prévue, l'heure d'arrivée réelle si le train a du retard. - * @param {object} trainAttributes - Les attributs du train - * @returns {string} Une chaîne HTML représentant la section d'arrivée du train - */ - renderArrival(trainAttributes) { - const hasDelay = trainAttributes.has_delay || false; - const delayMinutes = trainAttributes.delay_minutes || 0; - const arrivalTime = this.formatTime(trainAttributes.base_arrival_time); - const realArrivalTime = this.formatTime(trainAttributes.arrival_time); - - return ` -
-
${this.config.arrival_station_emoji}
-
-
- ${hasDelay && realArrivalTime ? ` -
${arrivalTime}
-
${realArrivalTime}
- ` : ` -
${arrivalTime}
- `} -
-
- ${hasDelay ? `+${delayMinutes}min` : 'À l\'heure'} -
-
-
`; } From ce4c0e9974861c683b88c41c4a9368b2fde107c8 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Fri, 15 May 2026 23:26:55 +0200 Subject: [PATCH 04/16] =?UTF-8?q?Gestion=20des=20ic=C3=B4nes=20g=C3=A9n?= =?UTF-8?q?=C3=A9riques=20HA=20(ex=20:=20mdi:home)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sncf_trains/www/sncf-train-card.js | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index d6b9695..4e40394 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -409,7 +409,7 @@ class SncfTrainCard extends HTMLElement { const diffMinutes = (arrival - now) / (1000 * 60); if (diffMinutes > travelTime) { - // todo : tester et s'assurer de la véracité / nom du param animation_duration + // TODO : tester et s'assurer de la véracité / nom du param animation_duration if (this.config.animation_duration === 0 || this.config.animation_duration > diffMinutes - travelTime) { // Train apparaît X minutes avant l'heure return 0; @@ -525,6 +525,18 @@ class SncfTrainCard extends HTMLElement { `; } + /** + * Rendu des icônes en fonction de la configuration, en vérifiant si l'icône est un emoji simple ou une icône HA (mdi:, fa:, ic:, ...), et en retournant le HTML approprié pour chaque cas. + * @param icone - La chaîne de caractères représentant l'icône configurée, qui peut être un emoji simple ou une icône HA avec un préfixe spécifique, et qui doit être traitée différemment pour s'assurer qu'elle s'affiche correctement dans la carte + * @return {string} Une chaîne HTML représentant l'icône à afficher, soit en utilisant la balise pour les icônes HA, soit en affichant directement l'emoji pour les emojis simples, ce qui permet de gérer une grande variété d'icônes de manière flexible et personnalisable + */ + renderIcone(icone) { + if (icone?.includes(':')) { + return ``; + } + return icone; + } + /** * Rendu des lignes de train en fonction des données fournies, en calculant la position de chaque train sur la barre de progression, en affichant les informations de départ et d'arrivée selon la configuration, et en appliquant des styles différents pour les trains en retards. * @param {Array} trains - Un tableau d'entités de train à afficher, avec leurs attributs contenant les informations nécessaires pour le rendu @@ -540,24 +552,18 @@ class SncfTrainCard extends HTMLElement { const isArrived = new Date() > this.parseTime(TA.arrival_time) const trainColor = this.getTrainColor(delayMinutes, hasDelay); - let trainPositionHTML = '' - if (position >= 0 && position <= 100) { - trainPositionHTML = ` -
- ${this.config.train_emoji} -
- ` - } - const theme = isArrived ? 'arrived' : hasDelay ? 'delayed' : isRunning ? 'running' : ''; return `
${this.config.show_departure_station ? this.renderDeparture(TA) : ''} -
- ${trainPositionHTML} + ${ position >= 0 ? + `
+ ${this.renderIcone(this.config.train_emoji)} +
` : '' + }
${this.config.show_arrival_station ? this.renderArrival(TA) : ''} @@ -593,7 +599,7 @@ class SncfTrainCard extends HTMLElement { ${hasDelay ? `+${delayMinutes}min` : isGone ? 'Parti' : 'À l\'heure'}
-
${this.config.departure_station_emoji}
+
${this.renderIcone(this.config.departure_station_emoji)}
` } @@ -612,7 +618,7 @@ class SncfTrainCard extends HTMLElement { return `
-
${this.config.arrival_station_emoji}
+
${this.renderIcone(this.config.arrival_station_emoji)}
${hasDelay && realArrivalTime ? ` @@ -755,7 +761,7 @@ class SncfTrainCard extends HTMLElement { z-index: 10; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); } - + .train-emoji-axial-symmetry-true { transform: translateX(-50%) scaleX(-1); } @@ -771,7 +777,7 @@ class SncfTrainCard extends HTMLElement { font-size: 1.8em; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); } - + .station-info { display: flex; flex-direction: column; From 5728ffb786ca056856adf5b66113abfd7a262b87 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Sun, 17 May 2026 02:11:38 +0200 Subject: [PATCH 05/16] Fix du chargement du composant Front --- custom_components/sncf_trains/__init__.py | 26 ++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/custom_components/sncf_trains/__init__.py b/custom_components/sncf_trains/__init__.py index e74af9a..87cad1b 100644 --- a/custom_components/sncf_trains/__init__.py +++ b/custom_components/sncf_trains/__init__.py @@ -2,9 +2,10 @@ from pathlib import Path from types import MappingProxyType +from logging import getLogger 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 @@ -30,14 +31,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 From 4a6f81ed57a9c1f11d5a5291c2292abc6c075be8 Mon Sep 17 00:00:00 2001 From: Master13011 <64130004+Master13011@users.noreply.github.com> Date: Sun, 17 May 2026 09:26:08 +0200 Subject: [PATCH 06/16] fix: linter --- custom_components/sncf_trains/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/sncf_trains/__init__.py b/custom_components/sncf_trains/__init__.py index 87cad1b..317ba2b 100644 --- a/custom_components/sncf_trains/__init__.py +++ b/custom_components/sncf_trains/__init__.py @@ -3,6 +3,7 @@ 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, CoreState, EVENT_HOMEASSISTANT_STARTED From 229e949f3a5be71394194b5dbfc13a2b9b3e8a17 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Sun, 17 May 2026 15:13:53 +0200 Subject: [PATCH 07/16] =?UTF-8?q?Ajout=20des=20fichiers=20=C3=A0=20compare?= =?UTF-8?q?r=20pour=20merge=20:D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.new.md | 261 ++++++++++++++ .../sncf_trains/www/sncf-train-card.new.js | 319 ++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 README.new.md create mode 100644 custom_components/sncf_trains/www/sncf-train-card.new.js diff --git a/README.new.md b/README.new.md new file mode 100644 index 0000000..6f31760 --- /dev/null +++ b/README.new.md @@ -0,0 +1,261 @@ +![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) + +# 🚆 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). + +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. + +--- + +## 🧪 Nouveauté en phase de test : Les Trains Supprimés + +> **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 ! + +--- + +## 🚀 Dernières mises à jour (Avril 2026) + +Le système a été lourdement mis à jour pour vous offrir une précision et un confort d'utilisation optimaux : + +- **🕒 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. + +--- + +## 📸 Aperçu Visuel + +| Design Épuré (Classique) | Nouveau Design (Radar de Ligne) | +| :-----------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------: | +| Avant | Radar de Ligne V3 | + +### ⚠️ Zoom sur les Retards et Perturbations + +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 + +--- + +## 📦 Installation + +### 1. Via HACS (Méthode recommandée) + +_Nécessite [HACS](https://hacs.xyz/) installé sur votre Home Assistant._ + +1. Ouvrez **HACS** dans votre menu de gauche. +2. Recherchez **SNCF Trains**. +3. Cliquez sur **Installer**, puis redémarrez Home Assistant. + +### 2. Méthode Manuelle + +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. + +--- + +## ⚙️ Configuration initiale + +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. + +_Astuce : Vous pouvez configurer autant de trajets différents que vous le souhaitez !_ + +--- + +## 🔐 Obtenir sa Clé API SNCF (Gratuit) + +Pour que l'intégration fonctionne, vous avez besoin d'une clé API officielle fournie par la SNCF : + +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. + +> _Pour changer de clé plus tard, il vous suffira de cliquer sur **Reconfigurer** depuis la page de l'intégration._ + +--- + +## 🧩 Options et Personnalisation + +Vous pouvez ajuster le comportement de l'intégration sans avoir à redémarrer Home Assistant : + +**Options globales de l'intégration :** + +- ⏱ **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). + +**Options spécifiques à chaque trajet :** + +- 🚆 **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)._ + +--- + +## 📊 Données et Capteurs + +L'intégration crée automatiquement plusieurs capteurs pour vos automatisations : + +- `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. + +**Informations disponibles pour chaque train :** + +- 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). + +--- + +## 🎨 Carte pour le Tableau de Bord (Lovelace) + +Une jolie carte visuelle (`sncf-train-card`) est incluse et prête à l'emploi dès l'installation ! + +### Trouver son `device_id` + +Pour que la carte sache quel trajet afficher, elle a besoin de l'identifiant de l'appareil (`device_id`) : + +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`). + +### Configuration YAML Avancée + +<<<<<<< HEAD +Voici un exemple de configuration complet pour exploiter 100% des capacités de la carte : +======= +--- + +## 🎨 Carte Lovelace — SNCF Train Card + +La carte `sncf-train-card` est **automatiquement disponible** dans le sélecteur de cartes dès l'installation de l'intégration. + +### Ajouter la carte + +Dans un tableau de bord, cliquer sur **+ Ajouter une carte** → chercher **SNCF Train Card**. + +La configuration peut ensuite se faire : + +- via l'éditeur visuel Lovelace +- ou via YAML + +Ou en YAML : +>>>>>>> origin/fetch-evolves + +```yaml +type: custom:sncf-train-card +device_id: VOTRE_DEVICE_ID +<<<<<<< HEAD +======= +``` + +### 🔍 Trouver le `device_id` + +_S'obtient dynamiquement via la configuration visuelle._ + +Le `device_id` correspond à l'appareil créé lors de la configuration du trajet. + +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` + +> ![Exemple d'identifiant](./assets/device_id_url.png) + +### ⚙️ Paramètres de la carte + +| 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 | +| `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** | +| `train_emoji_axial_symmetry` | `boolean` | `true` | Retourne l'emoji du train horizontalement | +| `train_emoji` | `string` | `'🚅'` | Emoji du train animé sur la barre | +| `show_departure_station` | `boolean` | `true` | Affiche ou masque les informations de départ | +| `departure_station_emoji` | `string` | `''` | Emoji de la station de départ | +| `show_arrival_station` | `boolean` | `true` | Affiche ou masque les informations d'arrivée | +| `arrival_station_emoji` | `string` | `'🚉'` | Emoji de la station d'arrivée | + +### Exemple complet + +```yaml +type: custom:sncf-train-card +device_id: abc123def456 +>>>>>>> origin/fetch-evolves +title: "Paris → Lyon" +train_lines: 5 +train_emoji: "🚆" +train_emoji_axial_symmetry: true +show_departure_station: true +departure_station_emoji: "🚉" +show_arrival_station: true +arrival_station_emoji: "🏙️" +animation_duration: 0 +update_interval: 60000 +show_route_details: true +use_real_duration: true +show_real_stop_times: true +show_delay_cause: true +``` + +**🔍 QUE FAIT CHAQUE OPTION ?** + +<<<<<<< HEAD +- 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. +======= +![Exemple d'affichage](./assets/card_example.png) +![Exemple d'affichage](./assets/card_example.png) +>>>>>>> origin/fetch-evolves + +--- + +## 🔮 Roadmap / À venir + +🛤️ 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 ! + +## 👨‍💻 Développement et Contribution + +Compatible avec Home Assistant 2025.8 et supérieur. + +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 + +Ce projet est open-source et distribué sous la licence MIT. diff --git a/custom_components/sncf_trains/www/sncf-train-card.new.js b/custom_components/sncf_trains/www/sncf-train-card.new.js new file mode 100644 index 0000000..ee20d1f --- /dev/null +++ b/custom_components/sncf_trains/www/sncf-train-card.new.js @@ -0,0 +1,319 @@ +// SNCF Train Card V3.5.1 +window.customCards = window.customCards || []; +window.customCards.push({ + type: 'sncf-train-card', + name: 'SNCF Train Card', + preview: true, + description: 'Version intégrale - Radar, Animation temps réel et Éditeur visuel.' +}); + +// --- ÉDITEUR VISUEL (CODE COMPLET) --- +class SncfTrainCardEditor extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + setConfig(config) { + this._config = { ...config }; + this.render(); + } + + set hass(hass) { + this._hass = hass; + } + + render() { + if (!this._config) return; + + this.shadowRoot.innerHTML = ` +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+ + +
+
+ + + `; + + this.shadowRoot.querySelectorAll('input').forEach(input => { + input.addEventListener('change', this.valueChanged.bind(this)); + }); + } + + 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); + +// --- CARTE PRINCIPALE (LOGIQUE COMPLÈTE) --- +class SncfTrainCard extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.updateInterval = null; + this.lastTrainSignature = null; + this._lastRenderTime = 0; + } + + 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) { + this._lastRenderTime = 0; + await this.render(); + } + }, this.config.update_interval); + } + + stopUpdateTimer() { + if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } + } + + async getTrainEntities() { + if (!this._hass || !this.config.device_id) return []; + try { + 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); + + 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 []; } + } + + 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]); + } + + render() { + if (!this._hass || !this.config) return; + + const nowTs = Date.now(); + if (nowTs - this._lastRenderTime < 1000) return; + this._lastRenderTime = nowTs; + + this.getTrainEntities().then(trains => { + const currentTime = new Date(); + const trainLinesHTML = trains.map(train => { + const attrs = train.attributes; + const dep = this.parseTime(attrs.departure_time); + const diff = (dep - currentTime) / 60000; + const maxAnim = this.config.animation_duration || 30; + + const pos = diff > maxAnim ? -10 : (diff <= 0 ? 100 : ((maxAnim - diff) / maxAnim) * 100); + + const hasDelay = attrs.has_delay || false; + const isCanceled = attrs.canceled || false; + + let animDur = this.config.use_real_duration && attrs.duration_minutes ? + attrs.duration_minutes * (this.config.speed_factor || 2) : 30; + + 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('')} +
+
`; + } + + const timeOnly = (t) => t ? t.split(' - ')[1] : "--:--"; + + return ` +
+
+
+ ${pos >= 0 && pos <= 100 ? ` +
+ ${isCanceled ? '❌' : this.config.train_emoji} +
+ ` : ''} +
+
+
${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}
` : ''} +
+
+
+ ${timelineHTML} +
`; + }).join(''); + + this.shadowRoot.innerHTML = ` + + +
${this.config.title}
+ ${trainLinesHTML} +
`; + }); + } +} +customElements.define('sncf-train-card', SncfTrainCard); \ No newline at end of file From 8332cac42a4a28aa05ec8bcb85569d538b4c8085 Mon Sep 17 00:00:00 2001 From: yad Date: Thu, 21 May 2026 20:53:30 +0200 Subject: [PATCH 08/16] =?UTF-8?q?feat(card):=20Ajout=20d'un=20support=20po?= =?UTF-8?q?ur=20avoir=20plusieurs=20trajets=20sur=20une=20m=C3=AAme=20cart?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 34 ++++- .../sncf_trains/www/sncf-train-card.js | 128 +++++++++++------- 2 files changed, 108 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 2289506..5fa17ab 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,22 @@ type: custom:sncf-train-card device_id: VOTRE_DEVICE_ID ``` +#### ✨ Support Multi-Devices + +Vous pouvez afficher les trajets de **plusieurs appareils sur une seule carte**, avec un tri chronologique par date d'arrivée : + +```yaml +type: custom:sncf-train-card +device_id: + - DEVICE_ID_1 + - DEVICE_ID_2 + - DEVICE_ID_3 +title: 'Tous Mes Trajets' +train_lines: 10 +``` + +> **Rétrocompatibilité** : L'ancien format (`device_id: DEVICE_ID`) continue de fonctionner et est automatiquement converti en tableau. + ### 🔍 Trouver le `device_id` _S'obtient dynamiquement via la configuration visuelle._ @@ -143,7 +159,7 @@ Le `device_id` correspond à l'appareil créé lors de la configuration du traje | Paramètre | Type | Défaut | Description | |-----------|------|--------|-------------| -| `device_id` | `string` | **obligatoire** | Identifiant de l'appareil SNCF (voir ci-dessus) | +| `device_id` | `string` \| `array[string]` | **obligatoire** | Identifiant(s) de l'appareil(s) SNCF. Peut être une chaîne unique ou un tableau de plusieurs IDs (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 | | `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) | @@ -155,8 +171,9 @@ Le `device_id` correspond à l'appareil créé lors de la configuration du traje | `show_arrival_station` | `boolean` | `true` | Affiche ou masque les informations d'arrivée | | `arrival_station_emoji` | `string` | `'🚉'` | Emoji de la station d'arrivée | -### Exemple complet +### Exemples +**Single device :** ```yaml type: custom:sncf-train-card device_id: abc123def456 @@ -172,6 +189,19 @@ animation_duration: 0 update_interval: 60000 ``` +**Multiple devices (fusionnés et triés par date d'arrivée) :** +```yaml +type: custom:sncf-train-card +device_id: + - abc123def456 + - def456ghi789 + - ghi789jkl123 +title: "Tous Mes Trajets SNCF" +train_lines: 10 +train_emoji: "🚄" +update_interval: 30000 +``` + ### Exemple d'affichage ![Exemple d'affichage](./assets/card_example.png) diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 4e40394..e3e8f1e 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -28,10 +28,24 @@ class SncfTrainCard extends HTMLElement { throw new Error('You need to define device_id'); } + // Normaliser device_id en tableau (rétrocompatibilité) + let normalizedDeviceId = config.device_id; + if (typeof normalizedDeviceId === 'string') { + normalizedDeviceId = [normalizedDeviceId]; + } else if (!Array.isArray(normalizedDeviceId)) { + throw new Error('device_id must be a string or an array of strings'); + } + + // Vérifier qu'il y a au moins un device_id non-vide + if (!normalizedDeviceId.length || !normalizedDeviceId.some(id => id)) { + throw new Error('You need to define at least one valid device_id'); + } + const previousDeviceId = this.config ? this.config.device_id : null; - const deviceIdChanged = previousDeviceId && previousDeviceId !== config.device_id; + const deviceIdChanged = previousDeviceId && JSON.stringify(previousDeviceId) !== JSON.stringify(normalizedDeviceId); - this.config = config; + // Créer une copie de la config avec le device_id normalisé + this.config = { ...config, device_id: normalizedDeviceId }; // Forcer la mise à jour immédiate si device_id a changé if (deviceIdChanged) { @@ -55,6 +69,7 @@ class SncfTrainCard extends HTMLElement { required: true, selector: { device: { + multiple: true, filter: { integration: "sncf_trains" } @@ -134,7 +149,7 @@ class SncfTrainCard extends HTMLElement { ], computeLabel: (schema) => { const labels = { - device_id: "ID du Device (obligatoire)", + device_id: "IDs des Devices (obligatoire - tableau de devices)", title: "Titre de la carte", train_emoji: "Emoji du train", train_lines: "Nombre de trains à afficher", @@ -150,7 +165,7 @@ class SncfTrainCard extends HTMLElement { }, computeHelper: (schema) => { const helpers = { - device_id: "L'identifiant unique du device SNCF à afficher", + device_id: "Les identifiants uniques des devices SNCF à afficher (tableau de devices)", title: "Le titre affiché en haut de la carte", train_emoji: "L'emoji représentant le train", train_lines: "Le nombre de trains à afficher (1-10)", @@ -173,9 +188,9 @@ class SncfTrainCard extends HTMLElement { */ static getStubConfig() { return { - device_id: '', + device_id: ['', ''], title: 'Trains SNCF', - train_lines: 3, + train_lines: 5, animation_duration: 30, update_interval: 30000, train_emoji_axial_symmetry: true, @@ -289,11 +304,11 @@ class SncfTrainCard extends HTMLElement { } /** - * Récupère les entités de train associées au device_id configuré en utilisant l'API WebSocket de Home Assistant pour obtenir des données fraîches, filtre les trains qui ne sont pas encore passés, et trie les résultats par heure de départ pour n'afficher que les trains à venir, ce qui garantit que les informations affichées sont toujours à jour et pertinentes pour l'utilisateur - * @returns {Promise} Un tableau d'entités de train avec des données fraîches + * Récupère les entités de train associées aux device_id configurés en utilisant l'API WebSocket de Home Assistant pour obtenir des données fraîches, filtre les trains qui ne sont pas encore passés, et trie les résultats par heure de départ pour n'afficher que les trains à venir, ce qui garantit que les informations affichées sont toujours à jour et pertinentes pour l'utilisateur + * @returns {Promise} Un tableau d'entités de train avec des données fraîches, fusionnées de tous les devices et triées par date d'arrivée */ 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 @@ -301,24 +316,33 @@ class SncfTrainCard extends HTMLElement { type: 'config/entity_registry/list' }); - // Filtrer les entités par device_id - const deviceEntities = allEntityRegistry.filter(entityInfo => - entityInfo.device_id === this.config.device_id - ); + // Récupérer les entités pour tous les device_id + const allTrainEntities = []; - if (!deviceEntities || deviceEntities.length === 0) { - console.warn('⚠️ Aucune entité trouvée pour ce device_id dans le registre'); - return []; - } + for (const deviceId of this.config.device_id) { + if (!deviceId) continue; - // 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 - return this._hass.states[entityInfo.entity_id]; - }) - .filter(entity => entity?.attributes?.departure_time); + // Filtrer les entités par device_id + const deviceEntities = allEntityRegistry.filter(entityInfo => + entityInfo.device_id === deviceId + ); + + if (!deviceEntities || deviceEntities.length === 0) { + console.warn(`⚠️ Aucune entité trouvée pour le device_id: ${deviceId}`); + continue; + } + + // 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 + return this._hass.states[entityInfo.entity_id]; + }) + .filter(entity => entity?.attributes?.departure_time); + + allTrainEntities.push(...trainEntities); + } // Source - https://stackoverflow.com/a/1214753 // Posted by Kip, modified by community. See post 'Timeline' for change history @@ -329,7 +353,7 @@ class SncfTrainCard extends HTMLElement { // Filtrer les trains qui ne sont pas encore passés const currentTime = new Date(); - const upcomingTrains = trainEntities.filter(entity => { + const upcomingTrains = allTrainEntities.filter(entity => { // TODO : paramétrer le temps d'affichage max d'un train arrivé en gare const arrivalTime = addMinutes(this.parseTime(entity.attributes.arrival_time), 30); return arrivalTime >= currentTime; @@ -511,15 +535,15 @@ class SncfTrainCard extends HTMLElement { this.shadowRoot.innerHTML = ` ${this.renderCss()} - +
${this.config.title}
- + ${this.renderTrainLines(trains)} - +
`; @@ -556,7 +580,7 @@ class SncfTrainCard extends HTMLElement { return `
${this.config.show_departure_station ? this.renderDeparture(TA) : ''} - +
${ position >= 0 ? `
` : '' }
- + ${this.config.show_arrival_station ? this.renderArrival(TA) : ''}
`; @@ -651,13 +675,13 @@ class SncfTrainCard extends HTMLElement { box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0,0,0,0.1)); overflow: hidden; } - + .train-card { display: flex; flex-direction: column; gap: 20px; } - + .train-header { display: flex; align-items: center; @@ -668,7 +692,7 @@ class SncfTrainCard extends HTMLElement { border-bottom: 2px solid var(--divider-color, #e0e0e0); padding-bottom: 10px; } - + .train-line { display: flex; align-items: center; @@ -676,7 +700,7 @@ class SncfTrainCard extends HTMLElement { position: relative; min-height: 60px; } - + .train-track { position: relative; flex: 1; @@ -688,22 +712,22 @@ class SncfTrainCard extends HTMLElement { box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); transition: background 0.3s ease; } - + .train-track.delayed { background: linear-gradient(90deg, #ffcdd2 0%, #e57373 50%, #ffcdd2 100%); box-shadow: inset 0 2px 4px rgba(244,67,54,0.3); } - + .train-track.running { background: linear-gradient(90deg, #d2cdff 0%, #7373e5 50%, #d2cdff 100%); box-shadow: inset 0 2px 4px rgba(54,67,244,0.3); } - + .train-track.arrived { background: linear-gradient(90deg, #cdffd2 0%, #73e573 50%, #cdffd2 100%); box-shadow: inset 0 2px 4px rgba(67,244,54,0.3); } - + .train-track::before { content: ''; position: absolute; @@ -721,7 +745,7 @@ class SncfTrainCard extends HTMLElement { transform: translateY(-50%); transition: background 0.3s ease; } - + .train-track.delayed::before { background: repeating-linear-gradient( 90deg, @@ -731,7 +755,7 @@ class SncfTrainCard extends HTMLElement { transparent 20px ); } - + .train-track.running::before { background: repeating-linear-gradient( 90deg, @@ -741,7 +765,7 @@ class SncfTrainCard extends HTMLElement { transparent 20px ); } - + .train-track.arrived::before { background: repeating-linear-gradient( 90deg, @@ -751,7 +775,7 @@ class SncfTrainCard extends HTMLElement { transparent 20px ); } - + .train-emoji { position: absolute; top: -37px; @@ -765,14 +789,14 @@ class SncfTrainCard extends HTMLElement { .train-emoji-axial-symmetry-true { transform: translateX(-50%) scaleX(-1); } - + .station { display: flex; flex-direction: row; align-items: center; gap: 8px; } - + .station-emoji { font-size: 1.8em; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2)); @@ -783,40 +807,40 @@ class SncfTrainCard extends HTMLElement { flex-direction: column; gap: 2px; } - + .arrival-time { font-size: 1.1em; font-weight: 600; color: var(--primary-color, #00539c); } - + .arrival-time-container { display: flex; flex-direction: column; gap: 2px; } - + .original-time { text-decoration: line-through; color: var(--secondary-text-color, #666); font-size: 0.9em; } - + .real-time { color: var(--error-color, #f44336); font-weight: 700; } - + .delay-info { font-size: 0.8em; font-weight: 600; margin-top: 2px; } - + .on-time { color: var(--success-color, #4caf50); } - + .error { color: var(--error-color, #f44336); text-align: center; From 87157e9866e861258bb61966c303f3421abcf05b Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Sat, 23 May 2026 16:28:54 +0200 Subject: [PATCH 09/16] =?UTF-8?q?Homog=C3=A9n=C3=A9isation=20des=20devs=20?= =?UTF-8?q?de=20PB35=20avec=20ceux=20de=20la=20branche=20main.=20TODO=20:?= =?UTF-8?q?=20tester=20avec=20son=20backend=20python?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sncf_trains/www/sncf-train-card.js | 27 +- .../sncf_trains/www/sncf-train-card.new.js | 1119 +++++++++++++---- 2 files changed, 859 insertions(+), 287 deletions(-) diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index e3e8f1e..9ec73da 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -1,5 +1,5 @@ // Ajouter au registre des cartes personnalisées -globalThis.customCards ||= [] +globalThis.customCards = globalThis.customCards || [] globalThis.customCards.push({ type: 'sncf-train-card', name: 'SNCF Train Card', @@ -472,27 +472,6 @@ class SncfTrainCard extends HTMLElement { }); } - /** - * Calcule l'heure d'arrivée réelle en ajoutant les minutes de retard à l'heure de départ prévue, et retourne une chaîne formatée de l'heure d'arrivée réelle, ou null si les données nécessaires sont manquantes ou si le train n'a pas de retard. - * @param departureTime - L'heure de départ - * @param delayMinutes - Le temps de retard en minutes - * @returns {string} Une chaîne représentant l'heure avec retard formatée ou null - */ - // TODO : tester si encore utile ? - 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' - }); - } - /** * Calcule la couleur du train en fonction du retard * @param {number} delayMinutes - Le nombre de minutes de retard @@ -567,7 +546,7 @@ class SncfTrainCard extends HTMLElement { * @returns {string} Une chaîne HTML représentant la section complète du train */ renderTrainLines(trains) { - return trains.map((train, index) => { + return trains.map(train => { const TA = train.attributes; const position = this.calculateTrainPosition(TA); const delayMinutes = TA.delay_minutes || 0; @@ -854,4 +833,4 @@ class SncfTrainCard extends HTMLElement { } // Définir l'élément custom -customElements.define('sncf-train-card', SncfTrainCard); \ No newline at end of file +customElements.define('sncf-train-card', SncfTrainCard); diff --git a/custom_components/sncf_trains/www/sncf-train-card.new.js b/custom_components/sncf_trains/www/sncf-train-card.new.js index ee20d1f..6b7da79 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.new.js +++ b/custom_components/sncf_trains/www/sncf-train-card.new.js @@ -1,319 +1,912 @@ -// SNCF Train Card V3.5.1 -window.customCards = window.customCards || []; -window.customCards.push({ +// Ajouter au registre des cartes personnalisées +globalThis.customCards = globalThis.customCards || [] +globalThis.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.' + configurable: true }); -// --- ÉDITEUR VISUEL (CODE COMPLET) --- -class SncfTrainCardEditor extends HTMLElement { +class SncfTrainCard extends HTMLElement { constructor() { super(); - this.attachShadow({ mode: 'open' }); + this.attachShadow({mode: 'open'}); + this.updateInterval = null; + this.lastTrainSignature = null; + this._lastRenderTime = 0; } + /** + * Méthode héritée
+ * Permet de définir la configuration de la carte, avec validation et gestion des changements de device_id pour forcer une mise à jour immédiate + * @param {Object} config - La configuration de la carte, qui doit inclure au minimum un device_id valide pour fonctionner correctement, et peut inclure d'autres paramètres pour personnaliser l'affichage + * @throws {Error} Si le device_id n'est pas défini, une erreur est levée pour informer l'utilisateur de la nécessité de fournir cette information essentielle + */ setConfig(config) { - this._config = { ...config }; + if (!config.device_id) { + throw new Error('You need to define device_id'); + } + + // Normaliser device_id en tableau (rétrocompatibilité) + let normalizedDeviceId = config.device_id; + if (typeof normalizedDeviceId === 'string') { + normalizedDeviceId = [normalizedDeviceId]; + } else if (!Array.isArray(normalizedDeviceId)) { + // FIXME : custom error ? + throw new TypeError('device_id must be a string or an array of strings'); + } + + // Vérifier qu'il y a au moins un device_id non-vide + if (!normalizedDeviceId.length || !normalizedDeviceId.some(id => typeof id === 'string' && id.trim() !== '')) { + // FIXME : custom error ? + throw new TypeError('You need to define at least one valid device_id'); + } + + const previousDeviceId = this.config ? this.config.device_id : null; + const deviceIdChanged = previousDeviceId && JSON.stringify(previousDeviceId) !== JSON.stringify(normalizedDeviceId); + + // Créer une copie de la config avec le device_id normalisé + this.config = { ...config, device_id: normalizedDeviceId }; + + // Forcer la mise à jour immédiate si device_id a changé + if (deviceIdChanged) { + this.stopUpdateTimer(); + this.startUpdateTimer(); + } + + // Toujours forcer un nouveau rendu this.render(); } - set hass(hass) { - this._hass = hass; + /** + * Méthode héritée
+ * Fournit la configuration du formulaire pour l'éditeur de Lovelace, avec des labels et des aides personnalisés + */ + static getConfigForm() { + return { + schema: [ + { + name: "device_id", + required: true, + selector: { + device: { + multiple: true, + filter: { + integration: "sncf_trains" + } + } + } + }, + { + name: "title", + selector: {text: {}}, + }, + { + name: "train_lines", + selector: { + number: { + min: 1, + max: 10, + step: 1, + }, + }, + }, + { + name: "animation_duration", + selector: { + number: { + min: 0, + max: 100, + step: 1, + }, + }, + }, + { + name: "update_interval", + selector: { + number: { + min: 5000, + step: 1000, + }, + }, + }, + { + name: "show_route_details", + selector: { boolean: {} } + }, + { + type: "grid", + name: "", + column_min_width: "150px", + schema: [ + { + name: "train_emoji_axial_symmetry", + selector: {boolean: {}}, + }, + { + name: "train_emoji", + selector: { + icon: {}, + }, + }, + { + name: "show_departure_station", + selector: {boolean: {}}, + }, + { + name: "departure_station_emoji", + selector: { + icon: {}, + }, + }, + { + name: "show_arrival_station", + selector: {boolean: {}}, + }, + { + name: "arrival_station_emoji", + selector: { + icon: {}, + }, + }, + ] + }, + ], + computeLabel: (schema) => { + const labels = { + device_id: "IDs des Devices (obligatoire - tableau de devices)", + title: "Titre de la carte", + train_emoji: "Emoji du train", + train_lines: "Nombre de trains à afficher", + animation_duration: "Durée d'animation (minutes)", + update_interval: "Intervalle de mise à jour (ms)", + departure_station_emoji: "Emoji de la gare de départ", + arrival_station_emoji: "Emoji de la gare d'arrivée", + show_departure_station: "Afficher les informations de départ", + show_arrival_station: "Afficher les informations d'arrivée", + train_emoji_axial_symmetry: "Symétrie axiale du train", + }; + return labels[schema.name] || undefined; + }, + computeHelper: (schema) => { + const helpers = { + device_id: "Les identifiants uniques des devices SNCF à afficher (tableau de devices)", + title: "Le titre affiché en haut de la carte", + train_emoji: "L'emoji représentant le train", + train_lines: "Le nombre de trains à afficher (1-10)", + animation_duration: "Nombre de minutes avant le départ pour que le train apparaisse", + update_interval: "Fréquence de rafraîchissement en millisecondes (ex: 30000 pour 30s)", + departure_station_emoji: "L'emoji pour la gare de départ", + arrival_station_emoji: "L'emoji pour la gare d'arrivée", + show_departure_station: "Affiche ou masque la gare de départ", + show_arrival_station: "Affiche ou masque la gare d'arrivée", + train_emoji_axial_symmetry: "Retourner l'emoji du train horizontalement", + }; + return helpers[schema.name] || undefined; + }, + }; } - render() { - if (!this._config) return; - - this.shadowRoot.innerHTML = ` -
-
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- -
-
-
-
+ /** + * Méthode héritée
+ * Fournit une configuration par défaut pour le mode aperçu dans l'éditeur de Lovelace + */ + static getStubConfig() { + return { + device_id: ['', ''], + title: 'Trains SNCF', + train_lines: 5, + animation_duration: 30, + update_interval: 30000, + train_emoji_axial_symmetry: true, + train_emoji: '🚅', + show_departure_station: true, + departure_station_emoji: '', + show_arrival_station: true, + arrival_station_emoji: '🚉', + // TODO rename to show_route or show_timeline ? + show_route_details: false + }; + } -
- - -
- -
- - -
-
+ /** + * Méthode héritée
+ * Permet de recevoir l'objet Home Assistant et de déclencher une vérification des mises à jour des trains pour éviter les rendus inutiles + * @param {Object} hass - L'objet Home Assistant fourni par le système, utilisé pour accéder aux états et aux services, et pour déclencher des mises à jour de la carte lorsque les données des trains changent + */ + 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(); + } + } - this.shadowRoot.querySelectorAll('input').forEach(input => { - input.addEventListener('change', this.valueChanged.bind(this)); - }); + /** + * Méthode héritée
+ * Démarre un timer pour forcer des mises à jour régulières, ce qui est nécessaire pour capturer les changements de données en temps réel + */ + connectedCallback() { + this.startUpdateTimer(); } - 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 })); + /** + * Méthode héritée
+ * Arrête le timer de mise à jour pour éviter les fuites de mémoire lorsque la carte est retirée du DOM + */ + disconnectedCallback() { + this.stopUpdateTimer(); } -} -customElements.define('sncf-train-card-editor', SncfTrainCardEditor); -// --- CARTE PRINCIPALE (LOGIQUE COMPLÈTE) --- -class SncfTrainCard extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - this.updateInterval = null; - this.lastTrainSignature = null; - this._lastRenderTime = 0; + /** + * Méthode héritée
+ * Calcule la taille de la carte en fonction du nombre de lignes de train à afficher, avec une taille minimale pour éviter les problèmes d'affichage + */ + getCardSize() { + return Math.max(3, this.config.train_lines + 1); } - static getConfigElement() { return document.createElement("sncf-train-card-editor"); } + /** + * Vérifie si les données des trains ont changé en comparant une signature des données actuelles avec la dernière signature connue, et ne fait un rendu que si nécessaire pour optimiser les performances + * @param {Object} previousHass - L'objet Home Assistant précédent pour comparer les données + * @param {Object} currentHass - L'objet Home Assistant actuel pour récupérer les données fraîches + */ + async checkForTrainUpdates(previousHass, currentHass) { + try { + // Récupérer les entités actuelles + const currentTrains = await this.getTrainEntities(); - 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(); - } + // Créer une signature des données actuelles + const currentSignature = this.createTrainSignature(currentTrains); - set hass(hass) { - this._hass = hass; - this.render(); + // 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 + console.error(error); + this.render(); + } } - connectedCallback() { this.startUpdateTimer(); } - disconnectedCallback() { this.stopUpdateTimer(); } + /** + * Crée une signature unique pour les données des trains en concaténant les informations clés de chaque train, ce qui permet de détecter facilement les changements sans faire un rendu complet à chaque fois + * @param {Array} trains - Un tableau d'entités de train + * @returns {string} Une chaîne de caractères représentant la signature des données des trains + */ + createTrainSignature(trains) { + return trains.map(train => + `${train.entity_id}:${train.attributes.departure_time}:${train.attributes.delay_minutes || 0}:${train.attributes.has_delay || false}` + ).join('|'); + } + /** + * Démarre un timer qui force un rendu de la carte à intervalles réguliers, ce qui est nécessaire pour capturer les changements de données en temps réel, surtout pour les données de train qui peuvent changer fréquemment + */ startUpdateTimer() { this.stopUpdateTimer(); this.updateInterval = setInterval(async () => { if (this._hass) { - this._lastRenderTime = 0; + // Force un nouveau rendu à intervalles réguliers pour capturer les changements + this._lastRenderTime = 0; // Reset du throttle await this.render(); } }, this.config.update_interval); } + /** + * Arrête le timer de mise à jour pour éviter les fuites de mémoire lorsque la carte est retirée du DOM ou lorsque le device_id change, ce qui est important pour maintenir les performances et éviter les rendus inutiles + */ stopUpdateTimer() { - if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } } + /** + * Récupère les entités de train associées aux device_id configurés en utilisant l'API WebSocket de Home Assistant pour obtenir des données fraîches, filtre les trains qui ne sont pas encore passés, et trie les résultats par heure de départ pour n'afficher que les trains à venir, ce qui garantit que les informations affichées sont toujours à jour et pertinentes pour l'utilisateur + * @returns {Promise} Un tableau d'entités de train avec des données fraîches, fusionnées de tous les devices et triées par date d'arrivée + */ async getTrainEntities() { if (!this._hass || !this.config.device_id) return []; + try { - 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); - - 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 []; } - } + // Utiliser l'API Home Assistant pour récupérer toutes les entités + const allEntityRegistry = await this._hass.callWS({ type: 'config/entity_registry/list' }); + // Récupérer les entités pour tous les device_id + const allTrainEntities = []; + + for (const deviceId of this.config.device_id) { + if (!deviceId) continue; + + // Filtrer les entités par device_id + const deviceEntities = allEntityRegistry.filter(entityInfo => + entityInfo.device_id === deviceId + ); + + if (!deviceEntities || deviceEntities.length === 0) { + console.warn(`⚠️ Aucune entité trouvée pour le device_id: ${deviceId}`); + continue; + } - 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]); - } + // 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 + return this._hass.states[entityInfo.entity_id]; + }) + .filter(entity => entity?.attributes?.departure_time); - render() { - if (!this._hass || !this.config) return; + allTrainEntities.push(...trainEntities); + } - const nowTs = Date.now(); - if (nowTs - this._lastRenderTime < 1000) return; - this._lastRenderTime = nowTs; + // Source - https://stackoverflow.com/a/1214753 + // Posted by Kip, modified by community. See post 'Timeline' for change history + // Retrieved 2026-05-15, License - CC BY-SA 4.0 + const addMinutes = (date, minutes) => { + return new Date(date.getTime() + minutes*60000); + } - this.getTrainEntities().then(trains => { + // Filtrer les trains qui ne sont pas encore passés const currentTime = new Date(); - const trainLinesHTML = trains.map(train => { - const attrs = train.attributes; - const dep = this.parseTime(attrs.departure_time); - const diff = (dep - currentTime) / 60000; - const maxAnim = this.config.animation_duration || 30; - - const pos = diff > maxAnim ? -10 : (diff <= 0 ? 100 : ((maxAnim - diff) / maxAnim) * 100); - - const hasDelay = attrs.has_delay || false; - const isCanceled = attrs.canceled || false; - - let animDur = this.config.use_real_duration && attrs.duration_minutes ? - attrs.duration_minutes * (this.config.speed_factor || 2) : 30; - - 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('')} -
-
`; + const upcomingTrains = allTrainEntities.filter(entity => { + // TODO : paramétrer le temps d'affichage max d'un train arrivé en gare + const arrivalTime = addMinutes(this.parseTime(entity.attributes.arrival_time), 30); + return arrivalTime >= currentTime; + }); + + return upcomingTrains.toSorted((a, b) => { + const aTime = this.parseTime(a.attributes.arrival_time); + const bTime = this.parseTime(b.attributes.arrival_time); + return aTime - bTime; + }) + .slice(0, this.config.train_lines); + + } catch (error) { + console.error('❌ Erreur lors de la récupération via API:', error); + return []; + } + } + + /** + * Parse une chaîne de temps au format spécifique de la SNCF (ex: "19/11/2025 - 08:20") et retourne un objet Date, ou une date par défaut si le format est invalide ou si la chaîne est vide, ce qui permet de gérer correctement les données de temps fournies par les entités de train et d'éviter les erreurs d'affichage + * @param {string} departureTime - La chaîne de temps à parser, qui peut être au format SNCF ou un format standard reconnu par JavaScript + * @returns {Date} Un objet Date représentant le temps de départ, ou une date par défaut si le parsing échoue + */ + 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 = Number.parseInt(dateComponents[0]); + const month = Number.parseInt(dateComponents[1]) - 1; // Mois 0-indexé + const year = Number.parseInt(dateComponents[2]); + + const timeComponents = timePart.split(':'); + if (timeComponents.length === 2) { + const hour = Number.parseInt(timeComponents[0]); + const minute = Number.parseInt(timeComponents[1]); + + return new Date(year, month, day, hour, minute); + } } + } + } + + // Fallback vers Date classique + return new Date(departureTime); + } + + /** + * Calcule la position du train sur la barre de progression en fonction de l'heure actuelle et de l'heure de départ, en affichant le train 30 minutes avant le départ et en le faisant avancer vers la droite à mesure que l'heure de départ approche, ce qui crée une animation visuelle intuitive pour les utilisateurs afin de suivre l'approche du train vers la gare, et retourne une position en pourcentage (0% = train à gauche, 100% = train arrivé) ou une valeur négative pour indiquer que le train n'est pas encore visible, ce qui permet de gérer l'affichage du train de manière dynamique en fonction du temps restant avant le départ + * @param {object} trainAttributes - Les attributs du train, qui doivent inclure au minimum une heure de départ valide pour que le calcul fonctionne correctement, et peuvent inclure d'autres informations pour personnaliser l'affichage + * @returns {number} Un nombre représentant la position du train en pourcentage (0-100) ou une valeur négative si le train n'est pas encore visible + */ + calculateTrainPosition(trainAttributes) { + if (!trainAttributes.departure_time || !trainAttributes.arrival_time) { + return -10; + } + + const departure = this.parseTime(trainAttributes.departure_time); + const arrival = this.parseTime(trainAttributes.arrival_time); + const travelTime = (arrival - departure) / (1000 * 60); + + if (Number.isNaN(departure.getTime()) || Number.isNaN(arrival.getTime()) || travelTime < 0) { + return -10; + } + + const now = new Date(); + const diffMinutes = (arrival - now) / (1000 * 60); + + if (diffMinutes > travelTime) { + // TODO : tester et s'assurer de la véracité / nom du param animation_duration + if (this.config.animation_duration === 0 || this.config.animation_duration > diffMinutes - travelTime) { + // Train apparaît X minutes avant l'heure + return 0; + } + // Hors de la barre + return -10; + } + if (diffMinutes <= 0) { + // Arrivé à la gare + return 100; + } + + // Position sur la barre (0% = gauche, 100% = droite) + return ((travelTime - diffMinutes) / travelTime) * 100; + } + + /** + * Formate une chaîne de temps en une heure lisible au format français (ex: "08:20"), ou retourne "N/A" si la chaîne est vide, ou "Format invalide" si le parsing échoue, ce qui permet d'afficher les heures de départ et d'arrivée de manière claire et compréhensible pour les utilisateurs, tout en gérant les cas où les données de temps peuvent être manquantes ou mal formatées + * @param {string} timeString - La chaîne de temps à formater, qui doit être au format reconnu par la méthode parseTime + * @returns {string} Une chaîne représentant l'heure formatée ou un message d'erreur si le format est invalide + */ + formatTime(timeString) { + if (!timeString) { + return 'N/A'; + } + + const time = this.parseTime(timeString); + + if (Number.isNaN(time.getTime())) { + return 'Format invalide'; + } + + return time.toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit' + }); + } - const timeOnly = (t) => t ? t.split(' - ')[1] : "--:--"; - - return ` -
-
-
- ${pos >= 0 && pos <= 100 ? ` -
- ${isCanceled ? '❌' : this.config.train_emoji} -
- ` : ''} -
-
-
${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}
` : ''} -
-
-
- ${timelineHTML} -
`; - }).join(''); + /** + * Calcule la couleur du train en fonction du retard + * @param {number} delayMinutes - Le nombre de minutes de retard + * @param {boolean} hasDelay - Indique si le train a du retard ou non + * @returns {string} La couleur correspondante + */ + getTrainColor(delayMinutes, hasDelay) { + if (!hasDelay || delayMinutes === 0) return '#4caf50'; // Vert à l'heure + return '#f44336'; // Rouge en retard (peu importe le nombre de minutes) + } + /** + * Méthode héritée
+ * Génération du rendu de l'ensemble de la carte, incluant le css et l'html + */ + 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(); + + if (trains.length === 0) { this.shadowRoot.innerHTML = ` - -
${this.config.title}
- ${trainLinesHTML} -
`; - }); +
+
Aucun train trouvé pour ce device. Vérifiez la configuration.
+
+ + `; + return; + } + + this.shadowRoot.innerHTML = ` + ${this.renderCss()} + + +
+
+
${this.config.title}
+
+ + ${this.renderTrainLines(trains)} + +
+
+ `; + } + + /** + * Rendu des icônes en fonction de la configuration, en vérifiant si l'icône est un emoji simple ou une icône HA (mdi:, fa:, ic:, ...), et en retournant le HTML approprié pour chaque cas. + * @param icone - La chaîne de caractères représentant l'icône configurée, qui peut être un emoji simple ou une icône HA avec un préfixe spécifique, et qui doit être traitée différemment pour s'assurer qu'elle s'affiche correctement dans la carte + * @return {string} Une chaîne HTML représentant l'icône à afficher, soit en utilisant la balise pour les icônes HA, soit en affichant directement l'emoji pour les emojis simples, ce qui permet de gérer une grande variété d'icônes de manière flexible et personnalisable + */ + renderIcone(icone) { + if (icone?.includes(':')) { + return ``; + } + return icone; + } + + /** + * Rendu des lignes de train en fonction des données fournies, en calculant la position de chaque train sur la barre de progression, en affichant les informations de départ et d'arrivée selon la configuration, et en appliquant des styles différents pour les trains en retards. + * @param {Array} trains - Un tableau d'entités de train à afficher, avec leurs attributs contenant les informations nécessaires pour le rendu + * @returns {string} Une chaîne HTML représentant la section complète du train + */ + renderTrainLines(trains) { + return trains.map(train => { + const TA = train.attributes; + const position = this.calculateTrainPosition(TA); + const delayMinutes = TA.delay_minutes || 0; + const hasDelay = TA.has_delay; + const isRunning = this.parseTime(TA.departure_time) < new Date() && new Date() < this.parseTime(TA.arrival_time) + const isArrived = new Date() > this.parseTime(TA.arrival_time) + const trainColor = this.getTrainColor(delayMinutes, hasDelay); + + const theme = isArrived ? 'arrived' : hasDelay ? 'delayed' : isRunning ? 'running' : ''; + return ` +
+ ${this.config.show_departure_station ? this.renderDeparture(TA) : ''} + +
+ ${ position >= 0 ? + `
+ ${this.renderIcone(this.config.train_emoji)} +
` : '' + } +
+ + ${this.config.show_arrival_station ? this.renderArrival(TA) : ''} + + ${this.config.show_route_details && TA.stops_schedule ? this.renderTimeline(TA) : ''} +
`; + }).join(''); + } + + /** + * + * @param trainAttributes + * @return {string} + */ + renderTimeline(trainAttributes) { + return ` +
+
+
+ ${this.renderStops(trainAttributes.stops_schedule)} +
+
+ `; + } + + /** + * + * @param stops + * @return {*} + */ + renderStops(stops) { + return stops.map(stop => { + const isDeleted = stop.effect === 'deleted'; + const isAdded = stop.effect === 'added'; + const isStopDelayed = this.config.show_route_details && stop.amended_time && stop.base_time && (stop.amended_time !== stop.base_time); + + const displayTime = isStopDelayed ? + `${stop.base_time}${stop.amended_time}` : + `${stop.base_time || stop.time}`; + + let statusBadge = ""; + if (isDeleted) { + statusBadge = ' SUPPRIMÉ'; + } else if (isAdded) { + statusBadge = ' RAJOUTÉ'; + } + + return ` +
+
+
+ ${displayTime} +
+
+ ${stop.name}${statusBadge} +
+
+ `; + }).join(''); + } + + /** + * Rendu de la section de départ pour un train donné, en affichant l'heure de départ prévue, l'heure de départ réelle si le train a du retard. + * @param {object} trainAttributes - Les attributs du train + * @returns {string} Une chaîne HTML représentant la section de départ du train + */ + renderDeparture(trainAttributes) { + const hasDelay = trainAttributes.has_delay || false; + const isGone = new Date() > this.parseTime(trainAttributes.departure_time) + const delayMinutes = trainAttributes.delay_minutes || 0; + const departureTime = this.formatTime(trainAttributes.base_departure_time); + const realDepartureTime = this.formatTime(trainAttributes.departure_time); + + return ` +
+
+
+ ${hasDelay && realDepartureTime ? ` +
${departureTime}
+
${realDepartureTime}
+ ` : ` +
${departureTime}
+ `} +
+
+ ${hasDelay ? `+${delayMinutes}min` : isGone ? 'Parti' : 'À l\'heure'} +
+
+
${this.renderIcone(this.config.departure_station_emoji)}
+
+ ` + } + + /** + * Rendu de la section d'arrivée pour un train donné, en affichant l'heure d'arrivée prévue, l'heure d'arrivée réelle si le train a du retard. + * @param {object} trainAttributes - Les attributs du train + * @returns {string} Une chaîne HTML représentant la section d'arrivée du train + */ + renderArrival(trainAttributes) { + const hasDelay = trainAttributes.has_delay || false; + const isArrived = new Date() > this.parseTime(trainAttributes.arrival_time) + const delayMinutes = trainAttributes.delay_minutes || 0; + const arrivalTime = this.formatTime(trainAttributes.base_arrival_time); + const realArrivalTime = this.formatTime(trainAttributes.arrival_time); + + return ` +
+
${this.renderIcone(this.config.arrival_station_emoji)}
+
+
+ ${hasDelay && realArrivalTime ? ` +
${arrivalTime}
+
${realArrivalTime}
+ ` : ` +
${arrivalTime}
+ `} +
+
+ ${hasDelay ? `+${delayMinutes}min` : isArrived ? 'Arrivé' : 'À l\'heure'} +
+ ${trainAttributes.delay_cause ? `
${trainAttributes.delay_cause}
` : ''} +
+
+ `; + } + + /** + * Rendu du CSS pour la carte, en définissant les styles de base pour la carte, les lignes de train, les barres de progression, les emojis, et les informations de station + * @return {string} Une chaîne HTML contenant les styles CSS pour la carte. + */ + renderCss() { + return ` + + `; } + } -customElements.define('sncf-train-card', SncfTrainCard); \ No newline at end of file + +// Définir l'élément custom +customElements.define('sncf-train-card', SncfTrainCard); From 290e24cc3da333cf6371e108a95f1e6a8ac9dc8e Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 01:19:05 +0200 Subject: [PATCH 10/16] =?UTF-8?q?Reprise=20des=20devs=20python=20de=20PB35?= =?UTF-8?q?=20Diminution=20des=20impacts=20inutiles=20et=20des=20modificat?= =?UTF-8?q?ions=20anodines.=20Am=C3=A9lioration=20du=20frontend=20pour=20?= =?UTF-8?q?=C3=AAtre=20plus=20flexible=20Suppression=20de=20la=20notion=20?= =?UTF-8?q?de=20train=20annul=C3=A9=20pour=20le=20moment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/sncf_trains/api.py | 40 +- custom_components/sncf_trains/config_flow.py | 25 +- custom_components/sncf_trains/const.py | 2 - custom_components/sncf_trains/coordinator.py | 80 +-- custom_components/sncf_trains/manifest.json | 10 +- custom_components/sncf_trains/sensor.py | 259 +++++----- custom_components/sncf_trains/strings.json | 6 +- .../sncf_trains/translations/fr.json | 6 +- .../sncf_trains/www/sncf-train-card.js | 476 +++++++++++++----- 9 files changed, 585 insertions(+), 319 deletions(-) diff --git a/custom_components/sncf_trains/api.py b/custom_components/sncf_trains/api.py index 47d12e0..85a4dd8 100644 --- a/custom_components/sncf_trains/api.py +++ b/custom_components/sncf_trains/api.py @@ -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" @@ -41,16 +41,20 @@ async def fetch_departures( try: async with self._session.get( - url, - headers=headers, - params=params, - timeout=ClientTimeout(total=self._timeout), + url, + headers=headers, + params=params, + 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", []) @@ -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, @@ -76,10 +80,10 @@ async def fetch_journeys( headers = {"Authorization": f"Basic {self._token}"} try: async with self._session.get( - url, - headers=headers, - params=params, - timeout=ClientTimeout(total=self._timeout), + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), ) as resp: if resp.status == 401: raise ConfigEntryAuthFailed("Unauthorized: check your API key.") @@ -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 @@ -102,14 +106,14 @@ async def search_stations(self, query: str) -> Optional[List[dict]]: headers = {"Authorization": f"Basic {self._token}"} try: async with self._session.get( - url, - headers=headers, - params=params, - timeout=ClientTimeout(total=self._timeout), + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), ) as resp: resp.raise_for_status() data = await resp.json() return data.get("places", []) except (ClientError, asyncio.TimeoutError) as err: _LOGGER.error("Network error searching stations from SNCF API: %s", err) - return None \ No newline at end of file + return None diff --git a/custom_components/sncf_trains/config_flow.py b/custom_components/sncf_trains/config_flow.py index 2f2e1db..27941f4 100644 --- a/custom_components/sncf_trains/config_flow.py +++ b/custom_components/sncf_trains/config_flow.py @@ -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, ) @@ -83,7 +81,7 @@ async def _validate_api_key(self, api: SncfApiClient): @classmethod @callback def async_get_supported_subentry_types( - cls, config_entry: ConfigEntry + cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" return { @@ -142,7 +140,7 @@ class TrainSubentryFlowHandler(ConfigSubentryFlow): config_entry: ConfigEntry | None = None async def async_step_departure_city( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the departure city step.""" errors = {} @@ -168,7 +166,7 @@ async def async_step_departure_city( ) async def async_step_departure_station( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the departure station step.""" if user_input is not None: @@ -186,7 +184,7 @@ async def async_step_departure_station( ) async def async_step_arrival_city( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the arrival city step.""" errors = {} @@ -205,7 +203,7 @@ async def async_step_arrival_city( ) async def async_step_arrival_station( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the arrival station step.""" if user_input is not None: @@ -223,7 +221,7 @@ async def async_step_arrival_station( ) async def async_step_time_range( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the time range step.""" if user_input is not None: @@ -252,8 +250,6 @@ 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( @@ -261,13 +257,12 @@ 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, } ), ) async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """User flow to modify an existing entry.""" config_subentry = self._get_reconfigure_subentry() @@ -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, } ) @@ -302,4 +293,4 @@ async def async_step_reconfigure( ), ) - async_step_user = async_step_departure_city \ No newline at end of file + async_step_user = async_step_departure_city diff --git a/custom_components/sncf_trains/const.py b/custom_components/sncf_trains/const.py index 545f277..74a05e1 100644 --- a/custom_components/sncf_trains/const.py +++ b/custom_components/sncf_trains/const.py @@ -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" @@ -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 \ No newline at end of file diff --git a/custom_components/sncf_trains/coordinator.py b/custom_components/sncf_trains/coordinator.py index 4d3fca9..1d68190 100644 --- a/custom_components/sncf_trains/coordinator.py +++ b/custom_components/sncf_trains/coordinator.py @@ -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 @@ -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( @@ -50,22 +50,24 @@ 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é.""" + """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) @@ -76,8 +78,8 @@ def _build_datetime_param(self, time_start: str, time_end: str) -> str: 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(":")) @@ -102,19 +104,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] @@ -123,31 +141,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) @@ -157,6 +178,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 \ No newline at end of file + return trains diff --git a/custom_components/sncf_trains/manifest.json b/custom_components/sncf_trains/manifest.json index 3ed24ae..a89ef6b 100644 --- a/custom_components/sncf_trains/manifest.json +++ b/custom_components/sncf_trains/manifest.json @@ -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", @@ -21,4 +17,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 18b7648..d879fd6 100644 --- a/custom_components/sncf_trains/sensor.py +++ b/custom_components/sncf_trains/sensor.py @@ -22,9 +22,9 @@ async def async_setup_entry( - hass: HomeAssistant, - entry: SncfDataConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + entry: SncfDataConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up SNCF entities from a config entry.""" @@ -38,16 +38,22 @@ 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.""" @@ -57,6 +63,7 @@ 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 = { @@ -67,10 +74,19 @@ 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() @@ -83,16 +99,23 @@ 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] + 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", "")) + 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(self.journey) self._attr_device_info = { "identifiers": {(DOMAIN, entry.subentry_id)}, "name": f"SNCF {entry.data[CONF_DEPARTURE_NAME]} → {entry.data[CONF_ARRIVAL_NAME]}", @@ -100,7 +123,7 @@ def __init__(self, coordinator, train_id: str, journey_id: int) -> None: "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() @@ -111,59 +134,118 @@ def _handle_coordinator_update(self) -> None: 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) + """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" proprement + self._attr_available = False # Le capteur passe en "Indisponible" def _extra_attributes(self, journey: dict[str, Any]) -> dict[str, Any]: - """Calcul des attributs détaillés pour chaque train.""" - section = journey.get("sections", [{}])[0] + """Extra attributes.""" + 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 + ) - # 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") + delay_cause = self._get_delay() - real_dep_raw = journey.get("departure_date_time") or base_dep_raw - real_arr_raw = journey.get("arrival_date_time") or base_arr_raw + route_details, stops_schedule = self._get_route() - arrival_time = format_time(real_arr_raw) - departure_time = format_time(real_dep_raw) + return { + "departure_time": format_time(journey.get("departure_date_time", "")), + "arrival_time": format_time(journey.get("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": 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), + } - # 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) + def _get_route(self) -> Any: + impacted_stops = self.sections.get("impacted_stops", []) + stops_list = [] + stops_schedule = [] - # 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") + 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] " - delay_cause = section.get("cause", "") + 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 = journey.get("messages", []) + messages = self.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", []) + 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: @@ -172,81 +254,7 @@ def _extra_attributes(self, journey: dict[str, Any]) -> dict[str, Any]: 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": 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, - "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", ""), - "train_num": get_train_num(journey), - } + return delay_cause class SncfAllTrainsLineSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): @@ -257,6 +265,7 @@ 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)" @@ -271,28 +280,40 @@ 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] - 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) + 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 + ) 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() \ No newline at end of file + + self.async_write_ha_state() diff --git a/custom_components/sncf_trains/strings.json b/custom_components/sncf_trains/strings.json index 2ef8100..4b30f1c 100644 --- a/custom_components/sncf_trains/strings.json +++ b/custom_components/sncf_trains/strings.json @@ -61,8 +61,7 @@ "data": { "time_start": "Time start", "time_end": "Time end", - "train_count": "Trains count", - "show_route_details": "Show intermediate stops" + "train_count": "Trains count" } }, "reconfigure": { @@ -70,8 +69,7 @@ "data": { "time_start": "Time start", "time_end": "Time end", - "train_count": "Trains count", - "show_route_details": "Show intermediate stops" + "train_count": "Trains count" } } }, diff --git a/custom_components/sncf_trains/translations/fr.json b/custom_components/sncf_trains/translations/fr.json index 1ef5083..ef55b71 100644 --- a/custom_components/sncf_trains/translations/fr.json +++ b/custom_components/sncf_trains/translations/fr.json @@ -66,8 +66,7 @@ "data": { "time_start": "Heure de départ", "time_end": "Heure d'arrivée", - "train_count": "Nombre de trains", - "show_route_details": "Afficher les arrêts intermédiaires" + "train_count": "Nombre de trains" } }, "reconfigure": { @@ -75,8 +74,7 @@ "data": { "time_start": "Heure de départ", "time_end": "Heure d'arrivée", - "train_count": "Nombre de trains", - "show_route_details": "Afficher les arrêts intermédiaires" + "train_count": "Nombre de trains" } } }, diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 9ec73da..487b07d 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -33,12 +33,14 @@ class SncfTrainCard extends HTMLElement { if (typeof normalizedDeviceId === 'string') { normalizedDeviceId = [normalizedDeviceId]; } else if (!Array.isArray(normalizedDeviceId)) { - throw new Error('device_id must be a string or an array of strings'); + // FIXME : custom error ? + throw new TypeError('device_id must be a string or an array of strings'); } // Vérifier qu'il y a au moins un device_id non-vide - if (!normalizedDeviceId.length || !normalizedDeviceId.some(id => id)) { - throw new Error('You need to define at least one valid device_id'); + if (!normalizedDeviceId.length || !normalizedDeviceId.some(id => typeof id === 'string' && id.trim() !== '')) { + // FIXME : custom error ? + throw new TypeError('You need to define at least one valid device_id'); } const previousDeviceId = this.config ? this.config.device_id : null; @@ -77,89 +79,119 @@ class SncfTrainCard extends HTMLElement { } }, { - name: "title", - selector: {text: {}}, - }, - { - name: "train_lines", - selector: { - number: { - min: 1, - max: 10, - step: 1, - }, - }, - }, - { - name: "animation_duration", - selector: { - number: { - min: 0, - max: 100, - step: 1, - }, - }, - }, - { - name: "update_interval", - selector: { - number: { - min: 5000, - step: 1000, - }, - }, - }, - { - type: "grid", - name: "", - column_min_width: "150px", + type: 'expandable', + name: 'settings', + title: 'Options de personalisation', schema: [ { - name: "train_emoji_axial_symmetry", - selector: {boolean: {}}, + name: "title", + selector: {text: {}}, }, { - name: "train_emoji", + name: "train_lines", selector: { - icon: {}, + number: { + min: 1, + max: 10, + step: 1, + }, }, }, { - name: "show_departure_station", - selector: {boolean: {}}, + name: "show_route_details", + selector: {boolean: {}} }, { - name: "departure_station_emoji", + name: "animation_duration", selector: { - icon: {}, + number: { + min: 0, + max: 100, + step: 1, + }, }, }, + ] + }, + { + type: 'expandable', + name: 'display', + title: 'Options d\'affichage avancées', + schema: [ { - name: "show_arrival_station", - selector: {boolean: {}}, - }, - { - name: "arrival_station_emoji", + name: "number_of_stops", selector: { - icon: {}, + number: { + min: 2, + max: 10, + step: 1, + }, }, }, - ] + { + type: "grid", + name: "", + column_min_width: "150px", + schema: [ + { + name: "train_emoji_axial_symmetry", + selector: {boolean: {}}, + }, + { + name: "train_emoji", + selector: { + icon: {}, + }, + }, + { + name: "show_departure_station", + selector: {boolean: {}}, + }, + { + name: "departure_station_emoji", + selector: { + icon: {}, + }, + }, + { + name: "show_arrival_station", + selector: {boolean: {}}, + }, + { + name: "arrival_station_emoji", + selector: { + icon: {}, + }, + }, + ] + } + ], + }, + { + name: "update_interval", + selector: { + number: { + min: 5000, + step: 1000, + }, + }, }, ], computeLabel: (schema) => { const labels = { device_id: "IDs des Devices (obligatoire - tableau de devices)", title: "Titre de la carte", - train_emoji: "Emoji du train", train_lines: "Nombre de trains à afficher", + show_route_details: "Afficher les arrêts", animation_duration: "Durée d'animation (minutes)", - update_interval: "Intervalle de mise à jour (ms)", - departure_station_emoji: "Emoji de la gare de départ", - arrival_station_emoji: "Emoji de la gare d'arrivée", + number_of_stops: "Nombre d'arrêts à afficher", + train_emoji_axial_symmetry: "Symétrie axiale du train", + train_emoji: "Emoji du train", show_departure_station: "Afficher les informations de départ", + departure_station_emoji: "Emoji de la gare de départ", show_arrival_station: "Afficher les informations d'arrivée", - train_emoji_axial_symmetry: "Symétrie axiale du train", + arrival_station_emoji: "Emoji de la gare d'arrivée", + update_interval: "Intervalle de mise à jour (ms)", }; return labels[schema.name] || undefined; }, @@ -167,15 +199,17 @@ class SncfTrainCard extends HTMLElement { const helpers = { device_id: "Les identifiants uniques des devices SNCF à afficher (tableau de devices)", title: "Le titre affiché en haut de la carte", - train_emoji: "L'emoji représentant le train", train_lines: "Le nombre de trains à afficher (1-10)", + show_route_details: "Affiche ou masque la frise des arrêts du train", animation_duration: "Nombre de minutes avant le départ pour que le train apparaisse", - update_interval: "Fréquence de rafraîchissement en millisecondes (ex: 30000 pour 30s)", - departure_station_emoji: "L'emoji pour la gare de départ", - arrival_station_emoji: "L'emoji pour la gare d'arrivée", + number_of_stops: "Le nombre d'arrêts à afficher dans la frise (2-10)", + train_emoji_axial_symmetry: "Retourner l'emoji du train horizontalement", + train_emoji: "L'emoji représentant le train", show_departure_station: "Affiche ou masque la gare de départ", + departure_station_emoji: "L'emoji pour la gare de départ", show_arrival_station: "Affiche ou masque la gare d'arrivée", - train_emoji_axial_symmetry: "Retourner l'emoji du train horizontalement", + arrival_station_emoji: "L'emoji pour la gare d'arrivée", + update_interval: "Fréquence de rafraîchissement en millisecondes (ex: 30000 pour 30s)", }; return helpers[schema.name] || undefined; }, @@ -189,16 +223,23 @@ class SncfTrainCard extends HTMLElement { static getStubConfig() { return { device_id: ['', ''], - title: 'Trains SNCF', - train_lines: 5, + settings: { + title: 'Trains SNCF', + train_lines: 5, + // TODO rename to show_route or show_timeline ? + show_route_details: false, + }, + display: { + number_of_stops: 7, + train_emoji_axial_symmetry: true, + train_emoji: '🚅', + show_departure_station: true, + departure_station_emoji: '', + show_arrival_station: true, + arrival_station_emoji: '🚉', + }, animation_duration: 30, update_interval: 30000, - train_emoji_axial_symmetry: true, - train_emoji: '🚅', - show_departure_station: true, - departure_station_emoji: '', - show_arrival_station: true, - arrival_station_emoji: '🚉', }; } @@ -240,7 +281,7 @@ class SncfTrainCard extends HTMLElement { * Calcule la taille de la carte en fonction du nombre de lignes de train à afficher, avec une taille minimale pour éviter les problèmes d'affichage */ getCardSize() { - return Math.max(3, this.config.train_lines + 1); + return Math.max(3, this.config.settings.train_lines + 1); } /** @@ -312,10 +353,7 @@ class SncfTrainCard extends HTMLElement { 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' - }); - + const allEntityRegistry = await this._hass.callWS({ type: 'config/entity_registry/list' }); // Récupérer les entités pour tous les device_id const allTrainEntities = []; @@ -344,6 +382,83 @@ class SncfTrainCard extends HTMLElement { allTrainEntities.push(...trainEntities); } + const d = new Date(); + if (d.getMinutes() < 30) d.setHours(d.getHours()-1) + allTrainEntities.push({ + entity_id: "sensor.nantes_le_pouliguen_train_11", + attributes: { + arrival_stop_id : "stop_point:SNCF:87481002:Train", + arrival_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()+1}:32`, + attribution : "Data provided by api.sncf.com", + base_arrival_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()+1}:02`, + base_departure_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()}:00`, + commercial_mode : "Aléop", + delay_cause: "Perturbation de lignes férroviaires", + delay_minutes : 24, + departure_stop_id : "stop_point:SNCF:87481762:Train", + departure_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()}:30`, + device_class : "timestamp", + direction : "Nantes (Nantes)", + duration_minutes : 67, + friendly_name : "Le Pouliguen → Nantes Train 7", + has_delay : true, + icon : "mdi:train", + physical_mode : "TER / Intercités", + train_num: "858060", + stops_schedule: [ + { + "name": "Le Pouliguen", + "time": `${d.getHours()}:30`, + "base_time": `${d.getHours()}:00`, + "amended_time": `${d.getHours()}:30`, + "effect": "unchanged" + }, + { + "name": "La Baule-Escoublac", + "time": `${d.getHours()}:35`, + "base_time": `${d.getHours()}:05`, + "amended_time": `${d.getHours()}:35`, + "effect": "unchanged" + }, + { + "name": "La Baule Les Pins", + "time": `${d.getHours()}:38`, + "base_time": `${d.getHours()}:08`, + "amended_time": `${d.getHours()}:38`, + "effect": "unchanged" + }, + { + "name": "Pornichet", + "time": `${d.getHours()}:42`, + "base_time": `${d.getHours()}:12`, + "amended_time": `${d.getHours()}:42`, + "effect": "unchanged" + }, + { + "name": "Saint-Nazaire", + "time": `${d.getHours()}:54`, + "base_time": `${d.getHours()}:24`, + "amended_time": `${d.getHours()}:54`, + "effect": "unchanged" + }, + { + "name": "Savenay", + "time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:08`, + "base_time": `${d.getHours()}:38`, + "amended_time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:08`, + "effect": "unchanged" + }, + { + "name": "Nantes", + "time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:32`, + "base_time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:02`, + "amended_time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:32`, + "effect": "unchanged" + } + ] + } + }); + // Source - https://stackoverflow.com/a/1214753 // Posted by Kip, modified by community. See post 'Timeline' for change history // Retrieved 2026-05-15, License - CC BY-SA 4.0 @@ -359,13 +474,12 @@ class SncfTrainCard extends HTMLElement { return arrivalTime >= currentTime; }); - return upcomingTrains - .sort((a, b) => { - const aTime = this.parseTime(a.attributes.arrival_time); - const bTime = this.parseTime(b.attributes.arrival_time); - return aTime - bTime; - }) - .slice(0, this.config.train_lines); + return upcomingTrains.toSorted((a, b) => { + const aTime = this.parseTime(a.attributes.arrival_time); + const bTime = this.parseTime(b.attributes.arrival_time); + return aTime - bTime; + }) + .slice(0, this.config.settings.train_lines); } catch (error) { console.error('❌ Erreur lors de la récupération via API:', error); @@ -383,7 +497,7 @@ class SncfTrainCard extends HTMLElement { return new Date(0); } - // Format SNCF: "19/11/2025 - 08:20" + // Format date et heure : "19/11/2025 - 08:20" if (departureTime.includes('/') && departureTime.includes(' - ')) { const parts = departureTime.split(' - '); if (parts.length === 2) { @@ -407,22 +521,39 @@ class SncfTrainCard extends HTMLElement { } } + // Format heure only : "08:20" + if (departureTime.includes(':')) { + const parts = departureTime.split(':'); + if (parts.length === 2) { + const hour = Number.parseInt(parts[0]); + const minute = Number.parseInt(parts[1]); + + const date = new Date(); + if (hour > 20 && date.getHours() < 4) { + date.setDate(date.getDate() - 1); + } + date.setHours(hour); + date.setMinutes(minute); + return date; + } + } + // Fallback vers Date classique return new Date(departureTime); } /** * Calcule la position du train sur la barre de progression en fonction de l'heure actuelle et de l'heure de départ, en affichant le train 30 minutes avant le départ et en le faisant avancer vers la droite à mesure que l'heure de départ approche, ce qui crée une animation visuelle intuitive pour les utilisateurs afin de suivre l'approche du train vers la gare, et retourne une position en pourcentage (0% = train à gauche, 100% = train arrivé) ou une valeur négative pour indiquer que le train n'est pas encore visible, ce qui permet de gérer l'affichage du train de manière dynamique en fonction du temps restant avant le départ - * @param {object} trainAttributes - Les attributs du train, qui doivent inclure au minimum une heure de départ valide pour que le calcul fonctionne correctement, et peuvent inclure d'autres informations pour personnaliser l'affichage + * @param {object} TA - Les attributs du train, qui doivent inclure au minimum une heure de départ valide pour que le calcul fonctionne correctement, et peuvent inclure d'autres informations pour personnaliser l'affichage * @returns {number} Un nombre représentant la position du train en pourcentage (0-100) ou une valeur négative si le train n'est pas encore visible */ - calculateTrainPosition(trainAttributes) { - if (!trainAttributes.departure_time || !trainAttributes.arrival_time) { + calculateTrainPosition(TA) { + if (!TA.departure_time || !TA.arrival_time) { return -10; } - const departure = this.parseTime(trainAttributes.departure_time); - const arrival = this.parseTime(trainAttributes.arrival_time); + const departure = this.parseTime(TA.departure_time); + const arrival = this.parseTime(TA.arrival_time); const travelTime = (arrival - departure) / (1000 * 60); if (Number.isNaN(departure.getTime()) || Number.isNaN(arrival.getTime()) || travelTime < 0) { @@ -434,7 +565,7 @@ class SncfTrainCard extends HTMLElement { if (diffMinutes > travelTime) { // TODO : tester et s'assurer de la véracité / nom du param animation_duration - if (this.config.animation_duration === 0 || this.config.animation_duration > diffMinutes - travelTime) { + if (this.config.settings.animation_duration === 0 || this.config.settings.animation_duration > diffMinutes - travelTime) { // Train apparaît X minutes avant l'heure return 0; } @@ -518,7 +649,7 @@ class SncfTrainCard extends HTMLElement {
-
${this.config.title}
+
${this.config.settings.title}
${this.renderTrainLines(trains)} @@ -550,26 +681,103 @@ class SncfTrainCard extends HTMLElement { const TA = train.attributes; const position = this.calculateTrainPosition(TA); const delayMinutes = TA.delay_minutes || 0; - const hasDelay = TA.has_delay || false; + const hasDelay = TA.has_delay; const isRunning = this.parseTime(TA.departure_time) < new Date() && new Date() < this.parseTime(TA.arrival_time) const isArrived = new Date() > this.parseTime(TA.arrival_time) const trainColor = this.getTrainColor(delayMinutes, hasDelay); const theme = isArrived ? 'arrived' : hasDelay ? 'delayed' : isRunning ? 'running' : ''; return ` -
- ${this.config.show_departure_station ? this.renderDeparture(TA) : ''} - -
- ${ position >= 0 ? - `
- ${this.renderIcone(this.config.train_emoji)} -
` : '' - } +
+
+ ${this.config.display.show_departure_station ? this.renderDeparture(TA) : ''} + +
+ ${ position >= 0 ? + `
+ ${this.renderIcone(this.config.display.train_emoji)} +
` : '' + } + ${TA.delay_cause ? `
${TA.delay_cause}
` : ''} +
+ + ${this.config.display.show_arrival_station ? this.renderArrival(TA) : ''}
+ ${this.config.settings.show_route_details && TA.stops_schedule ? this.renderTimeline(TA) : ''} +
`; + }).join(''); + } + + /** + * Rendu de la timeline des arrêts d'un train, en affichant une ligne horizontale avec des points représentant les arrêts + * @param {object} TA - Les attributs du train + * @return {string} Une chaîne HTML représentant la timeline des arrêts du train + */ + renderTimeline(TA) { + return ` +
+
+
+ ${this.renderStops(TA.stops_schedule, this.parseTime(TA.departure_time) < Date.now())} +
+
+ `; + } + + /** + * Rendu de la timeline des arrêts d'un train, en affichant les arrêts futurs et les arrêts passés jusqu'à une limite + * de 5 arrêts, avec des styles différents pour les arrêts supprimés, ajoutés ou retardés, ce qui permet de visualiser + * facilement le parcours du train et les éventuels changements ou perturbations sur sa route, tout en évitant de + * surcharger l'affichage avec trop d'informations + * @param {Array} stops - Un tableau d'objets représentant les arrêts du train + * @param {boolean} hasStarted - Indique si le train a déjà commencé son trajet + * @return {string} Une chaîne HTML représentant les différents arrêts du train + */ + renderStops(stops, hasStarted) { + const now = new Date(); + const maxStops = this.config.display.number_of_stops; + let i = 0; + return stops.filter((stop, index) => { + // On conserve tous les arrêts si le nombre d'arrêts est inférieur à la limite + if (stops.length < maxStops) return true; + + // Si le train est parti + if (hasStarted) { + // On récupère les arrêts futurs + if (this.parseTime(stop.time) >= now) return i++ < maxStops + // et les arrêts precedents jusqu'à la limite max en partant de la fin + if (stops.length - index <= maxStops) return i++ <= maxStops; + } else { + // Sinon, on prend les prochains arrêt jusqu'à la limite + return i++ < maxStops + } + }).map(stop => { + const isDeleted = stop.effect === 'deleted'; + const isAdded = stop.effect === 'added'; + // TODO : s'assurer de l'utilité de amended_time par rapport à time / base_time + const isStopDelayed = this.config.settings.show_route_details && stop.amended_time && stop.base_time && (stop.amended_time !== stop.base_time); + + const displayTime = isStopDelayed ? + `${stop.base_time}${stop.amended_time}` : + `${stop.base_time || stop.time}`; + + let statusBadge = ""; + if (isDeleted) { + statusBadge = ' SUPPRIMÉ'; + } else if (isAdded) { + statusBadge = ' RAJOUTÉ'; + } - ${this.config.show_arrival_station ? this.renderArrival(TA) : ''} + return ` +
+
+
+ ${displayTime} +
+
+ ${stop.name}${statusBadge} +
`; }).join(''); @@ -577,15 +785,15 @@ class SncfTrainCard extends HTMLElement { /** * Rendu de la section de départ pour un train donné, en affichant l'heure de départ prévue, l'heure de départ réelle si le train a du retard. - * @param {object} trainAttributes - Les attributs du train + * @param {object} TA - Les attributs du train * @returns {string} Une chaîne HTML représentant la section de départ du train */ - renderDeparture(trainAttributes) { - const hasDelay = trainAttributes.has_delay || false; - const isGone = new Date() > this.parseTime(trainAttributes.departure_time) - const delayMinutes = trainAttributes.delay_minutes || 0; - const departureTime = this.formatTime(trainAttributes.base_departure_time); - const realDepartureTime = this.formatTime(trainAttributes.departure_time); + renderDeparture(TA) { + const hasDelay = TA.has_delay || false; + const isGone = new Date() > this.parseTime(TA.departure_time) + const delayMinutes = TA.delay_minutes || 0; + const departureTime = this.formatTime(TA.base_departure_time); + const realDepartureTime = this.formatTime(TA.departure_time); return `
@@ -602,26 +810,26 @@ class SncfTrainCard extends HTMLElement { ${hasDelay ? `+${delayMinutes}min` : isGone ? 'Parti' : 'À l\'heure'}
-
${this.renderIcone(this.config.departure_station_emoji)}
+
${this.renderIcone(this.config.display.departure_station_emoji)}
` } /** * Rendu de la section d'arrivée pour un train donné, en affichant l'heure d'arrivée prévue, l'heure d'arrivée réelle si le train a du retard. - * @param {object} trainAttributes - Les attributs du train + * @param {object} TA - Les attributs du train * @returns {string} Une chaîne HTML représentant la section d'arrivée du train */ - renderArrival(trainAttributes) { - const hasDelay = trainAttributes.has_delay || false; - const isArrived = new Date() > this.parseTime(trainAttributes.arrival_time) - const delayMinutes = trainAttributes.delay_minutes || 0; - const arrivalTime = this.formatTime(trainAttributes.base_arrival_time); - const realArrivalTime = this.formatTime(trainAttributes.arrival_time); + renderArrival(TA) { + const hasDelay = TA.has_delay || false; + const isArrived = new Date() > this.parseTime(TA.arrival_time) + const delayMinutes = TA.delay_minutes || 0; + const arrivalTime = this.formatTime(TA.base_arrival_time); + const realArrivalTime = this.formatTime(TA.arrival_time); return `
-
${this.renderIcone(this.config.arrival_station_emoji)}
+
${this.renderIcone(this.config.display.arrival_station_emoji)}
${hasDelay && realArrivalTime ? ` @@ -768,6 +976,16 @@ class SncfTrainCard extends HTMLElement { .train-emoji-axial-symmetry-true { transform: translateX(-50%) scaleX(-1); } + + .delay-cause { + position: absolute; + top: 10px; + width: stretch; + text-align: center; + overflow:hidden; + white-space:nowrap; + text-overflow: ellipsis; + } .station { display: flex; @@ -826,6 +1044,24 @@ class SncfTrainCard extends HTMLElement { padding: 20px; font-weight: 500; } + + /* Radar Styles */ + .timeline-wrapper { position: relative; margin-top: 15px; padding: 0 10px; } + .timeline-line { position: absolute; top: 7px; left: 35px; right: 35px; height: 2px; background: var(--primary-color); opacity: 0.2; } + .timeline-line.delayed-line { background: #ff9800; opacity: 0.5; } + .timeline-container { display: flex; justify-content: space-between; position: relative; z-index: 2; } + .timeline-stop { display: flex; flex-direction: column; align-items: center; width: 90px; } + .timeline-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--card-background-color); border: 3px solid var(--primary-color); margin-bottom: 6px; box-sizing: border-box; } + .timeline-dot.delayed-dot { border-color: #ff9800; } + .timeline-dot.deleted-dot { background: #f44336; border-color: #f44336; } + .timeline-dot.added-dot { border-color: #ff9800; border-style: dashed; } + .timeline-time { font-size: 0.75em; font-weight: bold; display: contents; } + .base-time-radar { text-decoration: line-through; opacity: 0.5; font-size: 0.9em; } + .amended-time-radar { color: #ff9800; font-weight: bold; } + .timeline-name { font-size: 0.65em; text-align: center; color: var(--secondary-text-color); line-height: 1.2; } + .badge-stop { font-size: 0.8em; font-weight: bold; padding: 1px 3px; border-radius: 3px; color: white; } + .badge-stop.deleted { background: #f44336; } + .badge-stop.added { background: #ff9800; } `; } From fb4219ccac2a7b7922d4ba2bc4c07f5226233777 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 01:21:08 +0200 Subject: [PATCH 11/16] cleanup --- README.new.md | 261 ----- .../sncf_trains/www/sncf-train-card.new.js | 912 ------------------ 2 files changed, 1173 deletions(-) delete mode 100644 README.new.md delete mode 100644 custom_components/sncf_trains/www/sncf-train-card.new.js diff --git a/README.new.md b/README.new.md deleted file mode 100644 index 6f31760..0000000 --- a/README.new.md +++ /dev/null @@ -1,261 +0,0 @@ -![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) - -# 🚆 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). - -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. - ---- - -## 🧪 Nouveauté en phase de test : Les Trains Supprimés - -> **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 ! - ---- - -## 🚀 Dernières mises à jour (Avril 2026) - -Le système a été lourdement mis à jour pour vous offrir une précision et un confort d'utilisation optimaux : - -- **🕒 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. - ---- - -## 📸 Aperçu Visuel - -| Design Épuré (Classique) | Nouveau Design (Radar de Ligne) | -| :-----------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------: | -| Avant | Radar de Ligne V3 | - -### ⚠️ Zoom sur les Retards et Perturbations - -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 - ---- - -## 📦 Installation - -### 1. Via HACS (Méthode recommandée) - -_Nécessite [HACS](https://hacs.xyz/) installé sur votre Home Assistant._ - -1. Ouvrez **HACS** dans votre menu de gauche. -2. Recherchez **SNCF Trains**. -3. Cliquez sur **Installer**, puis redémarrez Home Assistant. - -### 2. Méthode Manuelle - -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. - ---- - -## ⚙️ Configuration initiale - -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. - -_Astuce : Vous pouvez configurer autant de trajets différents que vous le souhaitez !_ - ---- - -## 🔐 Obtenir sa Clé API SNCF (Gratuit) - -Pour que l'intégration fonctionne, vous avez besoin d'une clé API officielle fournie par la SNCF : - -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. - -> _Pour changer de clé plus tard, il vous suffira de cliquer sur **Reconfigurer** depuis la page de l'intégration._ - ---- - -## 🧩 Options et Personnalisation - -Vous pouvez ajuster le comportement de l'intégration sans avoir à redémarrer Home Assistant : - -**Options globales de l'intégration :** - -- ⏱ **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). - -**Options spécifiques à chaque trajet :** - -- 🚆 **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)._ - ---- - -## 📊 Données et Capteurs - -L'intégration crée automatiquement plusieurs capteurs pour vos automatisations : - -- `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. - -**Informations disponibles pour chaque train :** - -- 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). - ---- - -## 🎨 Carte pour le Tableau de Bord (Lovelace) - -Une jolie carte visuelle (`sncf-train-card`) est incluse et prête à l'emploi dès l'installation ! - -### Trouver son `device_id` - -Pour que la carte sache quel trajet afficher, elle a besoin de l'identifiant de l'appareil (`device_id`) : - -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`). - -### Configuration YAML Avancée - -<<<<<<< HEAD -Voici un exemple de configuration complet pour exploiter 100% des capacités de la carte : -======= ---- - -## 🎨 Carte Lovelace — SNCF Train Card - -La carte `sncf-train-card` est **automatiquement disponible** dans le sélecteur de cartes dès l'installation de l'intégration. - -### Ajouter la carte - -Dans un tableau de bord, cliquer sur **+ Ajouter une carte** → chercher **SNCF Train Card**. - -La configuration peut ensuite se faire : - -- via l'éditeur visuel Lovelace -- ou via YAML - -Ou en YAML : ->>>>>>> origin/fetch-evolves - -```yaml -type: custom:sncf-train-card -device_id: VOTRE_DEVICE_ID -<<<<<<< HEAD -======= -``` - -### 🔍 Trouver le `device_id` - -_S'obtient dynamiquement via la configuration visuelle._ - -Le `device_id` correspond à l'appareil créé lors de la configuration du trajet. - -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` - -> ![Exemple d'identifiant](./assets/device_id_url.png) - -### ⚙️ Paramètres de la carte - -| 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 | -| `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** | -| `train_emoji_axial_symmetry` | `boolean` | `true` | Retourne l'emoji du train horizontalement | -| `train_emoji` | `string` | `'🚅'` | Emoji du train animé sur la barre | -| `show_departure_station` | `boolean` | `true` | Affiche ou masque les informations de départ | -| `departure_station_emoji` | `string` | `''` | Emoji de la station de départ | -| `show_arrival_station` | `boolean` | `true` | Affiche ou masque les informations d'arrivée | -| `arrival_station_emoji` | `string` | `'🚉'` | Emoji de la station d'arrivée | - -### Exemple complet - -```yaml -type: custom:sncf-train-card -device_id: abc123def456 ->>>>>>> origin/fetch-evolves -title: "Paris → Lyon" -train_lines: 5 -train_emoji: "🚆" -train_emoji_axial_symmetry: true -show_departure_station: true -departure_station_emoji: "🚉" -show_arrival_station: true -arrival_station_emoji: "🏙️" -animation_duration: 0 -update_interval: 60000 -show_route_details: true -use_real_duration: true -show_real_stop_times: true -show_delay_cause: true -``` - -**🔍 QUE FAIT CHAQUE OPTION ?** - -<<<<<<< HEAD -- 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. -======= -![Exemple d'affichage](./assets/card_example.png) -![Exemple d'affichage](./assets/card_example.png) ->>>>>>> origin/fetch-evolves - ---- - -## 🔮 Roadmap / À venir - -🛤️ 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 ! - -## 👨‍💻 Développement et Contribution - -Compatible avec Home Assistant 2025.8 et supérieur. - -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 - -Ce projet est open-source et distribué sous la licence MIT. diff --git a/custom_components/sncf_trains/www/sncf-train-card.new.js b/custom_components/sncf_trains/www/sncf-train-card.new.js deleted file mode 100644 index 6b7da79..0000000 --- a/custom_components/sncf_trains/www/sncf-train-card.new.js +++ /dev/null @@ -1,912 +0,0 @@ -// Ajouter au registre des cartes personnalisées -globalThis.customCards = globalThis.customCards || [] -globalThis.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, - configurable: true -}); - -class SncfTrainCard extends HTMLElement { - constructor() { - super(); - this.attachShadow({mode: 'open'}); - this.updateInterval = null; - this.lastTrainSignature = null; - this._lastRenderTime = 0; - } - - /** - * Méthode héritée
- * Permet de définir la configuration de la carte, avec validation et gestion des changements de device_id pour forcer une mise à jour immédiate - * @param {Object} config - La configuration de la carte, qui doit inclure au minimum un device_id valide pour fonctionner correctement, et peut inclure d'autres paramètres pour personnaliser l'affichage - * @throws {Error} Si le device_id n'est pas défini, une erreur est levée pour informer l'utilisateur de la nécessité de fournir cette information essentielle - */ - setConfig(config) { - if (!config.device_id) { - throw new Error('You need to define device_id'); - } - - // Normaliser device_id en tableau (rétrocompatibilité) - let normalizedDeviceId = config.device_id; - if (typeof normalizedDeviceId === 'string') { - normalizedDeviceId = [normalizedDeviceId]; - } else if (!Array.isArray(normalizedDeviceId)) { - // FIXME : custom error ? - throw new TypeError('device_id must be a string or an array of strings'); - } - - // Vérifier qu'il y a au moins un device_id non-vide - if (!normalizedDeviceId.length || !normalizedDeviceId.some(id => typeof id === 'string' && id.trim() !== '')) { - // FIXME : custom error ? - throw new TypeError('You need to define at least one valid device_id'); - } - - const previousDeviceId = this.config ? this.config.device_id : null; - const deviceIdChanged = previousDeviceId && JSON.stringify(previousDeviceId) !== JSON.stringify(normalizedDeviceId); - - // Créer une copie de la config avec le device_id normalisé - this.config = { ...config, device_id: normalizedDeviceId }; - - // Forcer la mise à jour immédiate si device_id a changé - if (deviceIdChanged) { - this.stopUpdateTimer(); - this.startUpdateTimer(); - } - - // Toujours forcer un nouveau rendu - this.render(); - } - - /** - * Méthode héritée
- * Fournit la configuration du formulaire pour l'éditeur de Lovelace, avec des labels et des aides personnalisés - */ - static getConfigForm() { - return { - schema: [ - { - name: "device_id", - required: true, - selector: { - device: { - multiple: true, - filter: { - integration: "sncf_trains" - } - } - } - }, - { - name: "title", - selector: {text: {}}, - }, - { - name: "train_lines", - selector: { - number: { - min: 1, - max: 10, - step: 1, - }, - }, - }, - { - name: "animation_duration", - selector: { - number: { - min: 0, - max: 100, - step: 1, - }, - }, - }, - { - name: "update_interval", - selector: { - number: { - min: 5000, - step: 1000, - }, - }, - }, - { - name: "show_route_details", - selector: { boolean: {} } - }, - { - type: "grid", - name: "", - column_min_width: "150px", - schema: [ - { - name: "train_emoji_axial_symmetry", - selector: {boolean: {}}, - }, - { - name: "train_emoji", - selector: { - icon: {}, - }, - }, - { - name: "show_departure_station", - selector: {boolean: {}}, - }, - { - name: "departure_station_emoji", - selector: { - icon: {}, - }, - }, - { - name: "show_arrival_station", - selector: {boolean: {}}, - }, - { - name: "arrival_station_emoji", - selector: { - icon: {}, - }, - }, - ] - }, - ], - computeLabel: (schema) => { - const labels = { - device_id: "IDs des Devices (obligatoire - tableau de devices)", - title: "Titre de la carte", - train_emoji: "Emoji du train", - train_lines: "Nombre de trains à afficher", - animation_duration: "Durée d'animation (minutes)", - update_interval: "Intervalle de mise à jour (ms)", - departure_station_emoji: "Emoji de la gare de départ", - arrival_station_emoji: "Emoji de la gare d'arrivée", - show_departure_station: "Afficher les informations de départ", - show_arrival_station: "Afficher les informations d'arrivée", - train_emoji_axial_symmetry: "Symétrie axiale du train", - }; - return labels[schema.name] || undefined; - }, - computeHelper: (schema) => { - const helpers = { - device_id: "Les identifiants uniques des devices SNCF à afficher (tableau de devices)", - title: "Le titre affiché en haut de la carte", - train_emoji: "L'emoji représentant le train", - train_lines: "Le nombre de trains à afficher (1-10)", - animation_duration: "Nombre de minutes avant le départ pour que le train apparaisse", - update_interval: "Fréquence de rafraîchissement en millisecondes (ex: 30000 pour 30s)", - departure_station_emoji: "L'emoji pour la gare de départ", - arrival_station_emoji: "L'emoji pour la gare d'arrivée", - show_departure_station: "Affiche ou masque la gare de départ", - show_arrival_station: "Affiche ou masque la gare d'arrivée", - train_emoji_axial_symmetry: "Retourner l'emoji du train horizontalement", - }; - return helpers[schema.name] || undefined; - }, - }; - } - - /** - * Méthode héritée
- * Fournit une configuration par défaut pour le mode aperçu dans l'éditeur de Lovelace - */ - static getStubConfig() { - return { - device_id: ['', ''], - title: 'Trains SNCF', - train_lines: 5, - animation_duration: 30, - update_interval: 30000, - train_emoji_axial_symmetry: true, - train_emoji: '🚅', - show_departure_station: true, - departure_station_emoji: '', - show_arrival_station: true, - arrival_station_emoji: '🚉', - // TODO rename to show_route or show_timeline ? - show_route_details: false - }; - } - - /** - * Méthode héritée
- * Permet de recevoir l'objet Home Assistant et de déclencher une vérification des mises à jour des trains pour éviter les rendus inutiles - * @param {Object} hass - L'objet Home Assistant fourni par le système, utilisé pour accéder aux états et aux services, et pour déclencher des mises à jour de la carte lorsque les données des trains changent - */ - 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(); - } - } - - /** - * Méthode héritée
- * Démarre un timer pour forcer des mises à jour régulières, ce qui est nécessaire pour capturer les changements de données en temps réel - */ - connectedCallback() { - this.startUpdateTimer(); - } - - /** - * Méthode héritée
- * Arrête le timer de mise à jour pour éviter les fuites de mémoire lorsque la carte est retirée du DOM - */ - disconnectedCallback() { - this.stopUpdateTimer(); - } - - /** - * Méthode héritée
- * Calcule la taille de la carte en fonction du nombre de lignes de train à afficher, avec une taille minimale pour éviter les problèmes d'affichage - */ - getCardSize() { - return Math.max(3, this.config.train_lines + 1); - } - - /** - * Vérifie si les données des trains ont changé en comparant une signature des données actuelles avec la dernière signature connue, et ne fait un rendu que si nécessaire pour optimiser les performances - * @param {Object} previousHass - L'objet Home Assistant précédent pour comparer les données - * @param {Object} currentHass - L'objet Home Assistant actuel pour récupérer les données fraîches - */ - 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 - console.error(error); - this.render(); - } - } - - /** - * Crée une signature unique pour les données des trains en concaténant les informations clés de chaque train, ce qui permet de détecter facilement les changements sans faire un rendu complet à chaque fois - * @param {Array} trains - Un tableau d'entités de train - * @returns {string} Une chaîne de caractères représentant la signature des données des trains - */ - createTrainSignature(trains) { - return trains.map(train => - `${train.entity_id}:${train.attributes.departure_time}:${train.attributes.delay_minutes || 0}:${train.attributes.has_delay || false}` - ).join('|'); - } - - /** - * Démarre un timer qui force un rendu de la carte à intervalles réguliers, ce qui est nécessaire pour capturer les changements de données en temps réel, surtout pour les données de train qui peuvent changer fréquemment - */ - 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 - await this.render(); - } - }, this.config.update_interval); - } - - /** - * Arrête le timer de mise à jour pour éviter les fuites de mémoire lorsque la carte est retirée du DOM ou lorsque le device_id change, ce qui est important pour maintenir les performances et éviter les rendus inutiles - */ - stopUpdateTimer() { - if (this.updateInterval) { - clearInterval(this.updateInterval); - this.updateInterval = null; - } - } - - /** - * Récupère les entités de train associées aux device_id configurés en utilisant l'API WebSocket de Home Assistant pour obtenir des données fraîches, filtre les trains qui ne sont pas encore passés, et trie les résultats par heure de départ pour n'afficher que les trains à venir, ce qui garantit que les informations affichées sont toujours à jour et pertinentes pour l'utilisateur - * @returns {Promise} Un tableau d'entités de train avec des données fraîches, fusionnées de tous les devices et triées par date d'arrivée - */ - async getTrainEntities() { - 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' }); - // Récupérer les entités pour tous les device_id - const allTrainEntities = []; - - for (const deviceId of this.config.device_id) { - if (!deviceId) continue; - - // Filtrer les entités par device_id - const deviceEntities = allEntityRegistry.filter(entityInfo => - entityInfo.device_id === deviceId - ); - - if (!deviceEntities || deviceEntities.length === 0) { - console.warn(`⚠️ Aucune entité trouvée pour le device_id: ${deviceId}`); - continue; - } - - // 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 - return this._hass.states[entityInfo.entity_id]; - }) - .filter(entity => entity?.attributes?.departure_time); - - allTrainEntities.push(...trainEntities); - } - - // Source - https://stackoverflow.com/a/1214753 - // Posted by Kip, modified by community. See post 'Timeline' for change history - // Retrieved 2026-05-15, License - CC BY-SA 4.0 - const addMinutes = (date, minutes) => { - return new Date(date.getTime() + minutes*60000); - } - - // Filtrer les trains qui ne sont pas encore passés - const currentTime = new Date(); - const upcomingTrains = allTrainEntities.filter(entity => { - // TODO : paramétrer le temps d'affichage max d'un train arrivé en gare - const arrivalTime = addMinutes(this.parseTime(entity.attributes.arrival_time), 30); - return arrivalTime >= currentTime; - }); - - return upcomingTrains.toSorted((a, b) => { - const aTime = this.parseTime(a.attributes.arrival_time); - const bTime = this.parseTime(b.attributes.arrival_time); - return aTime - bTime; - }) - .slice(0, this.config.train_lines); - - } catch (error) { - console.error('❌ Erreur lors de la récupération via API:', error); - return []; - } - } - - /** - * Parse une chaîne de temps au format spécifique de la SNCF (ex: "19/11/2025 - 08:20") et retourne un objet Date, ou une date par défaut si le format est invalide ou si la chaîne est vide, ce qui permet de gérer correctement les données de temps fournies par les entités de train et d'éviter les erreurs d'affichage - * @param {string} departureTime - La chaîne de temps à parser, qui peut être au format SNCF ou un format standard reconnu par JavaScript - * @returns {Date} Un objet Date représentant le temps de départ, ou une date par défaut si le parsing échoue - */ - 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 = Number.parseInt(dateComponents[0]); - const month = Number.parseInt(dateComponents[1]) - 1; // Mois 0-indexé - const year = Number.parseInt(dateComponents[2]); - - const timeComponents = timePart.split(':'); - if (timeComponents.length === 2) { - const hour = Number.parseInt(timeComponents[0]); - const minute = Number.parseInt(timeComponents[1]); - - return new Date(year, month, day, hour, minute); - } - } - } - } - - // Fallback vers Date classique - return new Date(departureTime); - } - - /** - * Calcule la position du train sur la barre de progression en fonction de l'heure actuelle et de l'heure de départ, en affichant le train 30 minutes avant le départ et en le faisant avancer vers la droite à mesure que l'heure de départ approche, ce qui crée une animation visuelle intuitive pour les utilisateurs afin de suivre l'approche du train vers la gare, et retourne une position en pourcentage (0% = train à gauche, 100% = train arrivé) ou une valeur négative pour indiquer que le train n'est pas encore visible, ce qui permet de gérer l'affichage du train de manière dynamique en fonction du temps restant avant le départ - * @param {object} trainAttributes - Les attributs du train, qui doivent inclure au minimum une heure de départ valide pour que le calcul fonctionne correctement, et peuvent inclure d'autres informations pour personnaliser l'affichage - * @returns {number} Un nombre représentant la position du train en pourcentage (0-100) ou une valeur négative si le train n'est pas encore visible - */ - calculateTrainPosition(trainAttributes) { - if (!trainAttributes.departure_time || !trainAttributes.arrival_time) { - return -10; - } - - const departure = this.parseTime(trainAttributes.departure_time); - const arrival = this.parseTime(trainAttributes.arrival_time); - const travelTime = (arrival - departure) / (1000 * 60); - - if (Number.isNaN(departure.getTime()) || Number.isNaN(arrival.getTime()) || travelTime < 0) { - return -10; - } - - const now = new Date(); - const diffMinutes = (arrival - now) / (1000 * 60); - - if (diffMinutes > travelTime) { - // TODO : tester et s'assurer de la véracité / nom du param animation_duration - if (this.config.animation_duration === 0 || this.config.animation_duration > diffMinutes - travelTime) { - // Train apparaît X minutes avant l'heure - return 0; - } - // Hors de la barre - return -10; - } - if (diffMinutes <= 0) { - // Arrivé à la gare - return 100; - } - - // Position sur la barre (0% = gauche, 100% = droite) - return ((travelTime - diffMinutes) / travelTime) * 100; - } - - /** - * Formate une chaîne de temps en une heure lisible au format français (ex: "08:20"), ou retourne "N/A" si la chaîne est vide, ou "Format invalide" si le parsing échoue, ce qui permet d'afficher les heures de départ et d'arrivée de manière claire et compréhensible pour les utilisateurs, tout en gérant les cas où les données de temps peuvent être manquantes ou mal formatées - * @param {string} timeString - La chaîne de temps à formater, qui doit être au format reconnu par la méthode parseTime - * @returns {string} Une chaîne représentant l'heure formatée ou un message d'erreur si le format est invalide - */ - formatTime(timeString) { - if (!timeString) { - return 'N/A'; - } - - const time = this.parseTime(timeString); - - if (Number.isNaN(time.getTime())) { - return 'Format invalide'; - } - - return time.toLocaleTimeString('fr-FR', { - hour: '2-digit', - minute: '2-digit' - }); - } - - /** - * Calcule la couleur du train en fonction du retard - * @param {number} delayMinutes - Le nombre de minutes de retard - * @param {boolean} hasDelay - Indique si le train a du retard ou non - * @returns {string} La couleur correspondante - */ - getTrainColor(delayMinutes, hasDelay) { - if (!hasDelay || delayMinutes === 0) return '#4caf50'; // Vert à l'heure - return '#f44336'; // Rouge en retard (peu importe le nombre de minutes) - } - - /** - * Méthode héritée
- * Génération du rendu de l'ensemble de la carte, incluant le css et l'html - */ - 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(); - - if (trains.length === 0) { - this.shadowRoot.innerHTML = ` - -
-
Aucun train trouvé pour ce device. Vérifiez la configuration.
-
-
- `; - return; - } - - this.shadowRoot.innerHTML = ` - ${this.renderCss()} - - -
-
-
${this.config.title}
-
- - ${this.renderTrainLines(trains)} - -
-
- `; - } - - /** - * Rendu des icônes en fonction de la configuration, en vérifiant si l'icône est un emoji simple ou une icône HA (mdi:, fa:, ic:, ...), et en retournant le HTML approprié pour chaque cas. - * @param icone - La chaîne de caractères représentant l'icône configurée, qui peut être un emoji simple ou une icône HA avec un préfixe spécifique, et qui doit être traitée différemment pour s'assurer qu'elle s'affiche correctement dans la carte - * @return {string} Une chaîne HTML représentant l'icône à afficher, soit en utilisant la balise pour les icônes HA, soit en affichant directement l'emoji pour les emojis simples, ce qui permet de gérer une grande variété d'icônes de manière flexible et personnalisable - */ - renderIcone(icone) { - if (icone?.includes(':')) { - return ``; - } - return icone; - } - - /** - * Rendu des lignes de train en fonction des données fournies, en calculant la position de chaque train sur la barre de progression, en affichant les informations de départ et d'arrivée selon la configuration, et en appliquant des styles différents pour les trains en retards. - * @param {Array} trains - Un tableau d'entités de train à afficher, avec leurs attributs contenant les informations nécessaires pour le rendu - * @returns {string} Une chaîne HTML représentant la section complète du train - */ - renderTrainLines(trains) { - return trains.map(train => { - const TA = train.attributes; - const position = this.calculateTrainPosition(TA); - const delayMinutes = TA.delay_minutes || 0; - const hasDelay = TA.has_delay; - const isRunning = this.parseTime(TA.departure_time) < new Date() && new Date() < this.parseTime(TA.arrival_time) - const isArrived = new Date() > this.parseTime(TA.arrival_time) - const trainColor = this.getTrainColor(delayMinutes, hasDelay); - - const theme = isArrived ? 'arrived' : hasDelay ? 'delayed' : isRunning ? 'running' : ''; - return ` -
- ${this.config.show_departure_station ? this.renderDeparture(TA) : ''} - -
- ${ position >= 0 ? - `
- ${this.renderIcone(this.config.train_emoji)} -
` : '' - } -
- - ${this.config.show_arrival_station ? this.renderArrival(TA) : ''} - - ${this.config.show_route_details && TA.stops_schedule ? this.renderTimeline(TA) : ''} -
`; - }).join(''); - } - - /** - * - * @param trainAttributes - * @return {string} - */ - renderTimeline(trainAttributes) { - return ` -
-
-
- ${this.renderStops(trainAttributes.stops_schedule)} -
-
- `; - } - - /** - * - * @param stops - * @return {*} - */ - renderStops(stops) { - return stops.map(stop => { - const isDeleted = stop.effect === 'deleted'; - const isAdded = stop.effect === 'added'; - const isStopDelayed = this.config.show_route_details && stop.amended_time && stop.base_time && (stop.amended_time !== stop.base_time); - - const displayTime = isStopDelayed ? - `${stop.base_time}${stop.amended_time}` : - `${stop.base_time || stop.time}`; - - let statusBadge = ""; - if (isDeleted) { - statusBadge = ' SUPPRIMÉ'; - } else if (isAdded) { - statusBadge = ' RAJOUTÉ'; - } - - return ` -
-
-
- ${displayTime} -
-
- ${stop.name}${statusBadge} -
-
- `; - }).join(''); - } - - /** - * Rendu de la section de départ pour un train donné, en affichant l'heure de départ prévue, l'heure de départ réelle si le train a du retard. - * @param {object} trainAttributes - Les attributs du train - * @returns {string} Une chaîne HTML représentant la section de départ du train - */ - renderDeparture(trainAttributes) { - const hasDelay = trainAttributes.has_delay || false; - const isGone = new Date() > this.parseTime(trainAttributes.departure_time) - const delayMinutes = trainAttributes.delay_minutes || 0; - const departureTime = this.formatTime(trainAttributes.base_departure_time); - const realDepartureTime = this.formatTime(trainAttributes.departure_time); - - return ` -
-
-
- ${hasDelay && realDepartureTime ? ` -
${departureTime}
-
${realDepartureTime}
- ` : ` -
${departureTime}
- `} -
-
- ${hasDelay ? `+${delayMinutes}min` : isGone ? 'Parti' : 'À l\'heure'} -
-
-
${this.renderIcone(this.config.departure_station_emoji)}
-
- ` - } - - /** - * Rendu de la section d'arrivée pour un train donné, en affichant l'heure d'arrivée prévue, l'heure d'arrivée réelle si le train a du retard. - * @param {object} trainAttributes - Les attributs du train - * @returns {string} Une chaîne HTML représentant la section d'arrivée du train - */ - renderArrival(trainAttributes) { - const hasDelay = trainAttributes.has_delay || false; - const isArrived = new Date() > this.parseTime(trainAttributes.arrival_time) - const delayMinutes = trainAttributes.delay_minutes || 0; - const arrivalTime = this.formatTime(trainAttributes.base_arrival_time); - const realArrivalTime = this.formatTime(trainAttributes.arrival_time); - - return ` -
-
${this.renderIcone(this.config.arrival_station_emoji)}
-
-
- ${hasDelay && realArrivalTime ? ` -
${arrivalTime}
-
${realArrivalTime}
- ` : ` -
${arrivalTime}
- `} -
-
- ${hasDelay ? `+${delayMinutes}min` : isArrived ? 'Arrivé' : 'À l\'heure'} -
- ${trainAttributes.delay_cause ? `
${trainAttributes.delay_cause}
` : ''} -
-
- `; - } - - /** - * Rendu du CSS pour la carte, en définissant les styles de base pour la carte, les lignes de train, les barres de progression, les emojis, et les informations de station - * @return {string} Une chaîne HTML contenant les styles CSS pour la carte. - */ - renderCss() { - return ` - - `; - } - -} - -// Définir l'élément custom -customElements.define('sncf-train-card', SncfTrainCard); From 4b16f224839832e36d91944eeab786c1a4588dab Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 01:32:21 +0200 Subject: [PATCH 12/16] cleanup 2 --- custom_components/sncf_trains/api.py | 28 +++--- custom_components/sncf_trains/config_flow.py | 14 +-- custom_components/sncf_trains/coordinator.py | 6 +- custom_components/sncf_trains/sensor.py | 6 +- .../sncf_trains/tests/test_config_flow.py | 90 ------------------- .../sncf_trains/tests/test_coordinator.py | 78 ---------------- .../sncf_trains/www/sncf-train-card.js | 82 +---------------- 7 files changed, 29 insertions(+), 275 deletions(-) delete mode 100644 custom_components/sncf_trains/tests/test_config_flow.py delete mode 100644 custom_components/sncf_trains/tests/test_coordinator.py diff --git a/custom_components/sncf_trains/api.py b/custom_components/sncf_trains/api.py index 85a4dd8..b61f3b1 100644 --- a/custom_components/sncf_trains/api.py +++ b/custom_components/sncf_trains/api.py @@ -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 = 10 ) -> Optional[List[dict]]: if stop_id.startswith("stop_area:"): url = f"{API_BASE}/v1/coverage/sncf/stop_areas/{stop_id}/departures" @@ -41,10 +41,10 @@ async def fetch_departures( try: async with self._session.get( - url, - headers=headers, - params=params, - timeout=ClientTimeout(total=self._timeout), + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), ) as resp: if resp.status == 401: # vrai problème d'auth @@ -64,7 +64,7 @@ async def fetch_departures( return None async def fetch_journeys( - self, from_id: str, to_id: str, datetime_str: str, count: int = 5 + 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] = { @@ -80,10 +80,10 @@ async def fetch_journeys( headers = {"Authorization": f"Basic {self._token}"} try: async with self._session.get( - url, - headers=headers, - params=params, - timeout=ClientTimeout(total=self._timeout), + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), ) as resp: if resp.status == 401: raise ConfigEntryAuthFailed("Unauthorized: check your API key.") @@ -106,10 +106,10 @@ async def search_stations(self, query: str) -> Optional[List[dict]]: headers = {"Authorization": f"Basic {self._token}"} try: async with self._session.get( - url, - headers=headers, - params=params, - timeout=ClientTimeout(total=self._timeout), + url, + headers=headers, + params=params, + timeout=ClientTimeout(total=self._timeout), ) as resp: resp.raise_for_status() data = await resp.json() diff --git a/custom_components/sncf_trains/config_flow.py b/custom_components/sncf_trains/config_flow.py index 27941f4..3014b36 100644 --- a/custom_components/sncf_trains/config_flow.py +++ b/custom_components/sncf_trains/config_flow.py @@ -81,7 +81,7 @@ async def _validate_api_key(self, api: SncfApiClient): @classmethod @callback def async_get_supported_subentry_types( - cls, config_entry: ConfigEntry + cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" return { @@ -140,7 +140,7 @@ class TrainSubentryFlowHandler(ConfigSubentryFlow): config_entry: ConfigEntry | None = None async def async_step_departure_city( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the departure city step.""" errors = {} @@ -166,7 +166,7 @@ async def async_step_departure_city( ) async def async_step_departure_station( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the departure station step.""" if user_input is not None: @@ -184,7 +184,7 @@ async def async_step_departure_station( ) async def async_step_arrival_city( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the arrival city step.""" errors = {} @@ -203,7 +203,7 @@ async def async_step_arrival_city( ) async def async_step_arrival_station( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the arrival station step.""" if user_input is not None: @@ -221,7 +221,7 @@ async def async_step_arrival_station( ) async def async_step_time_range( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Handle the time range step.""" if user_input is not None: @@ -262,7 +262,7 @@ async def async_step_time_range( ) async def async_step_reconfigure( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """User flow to modify an existing entry.""" config_subentry = self._get_reconfigure_subentry() diff --git a/custom_components/sncf_trains/coordinator.py b/custom_components/sncf_trains/coordinator.py index 1d68190..bbb67be 100644 --- a/custom_components/sncf_trains/coordinator.py +++ b/custom_components/sncf_trains/coordinator.py @@ -63,8 +63,8 @@ async def _async_setup(self) -> None: _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""" + 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(":")) @@ -181,6 +181,6 @@ async def _async_update_data(self) -> dict[str, Any]: _LOGGER.debug( "Coordinator update interval set to %s minutes", self.update_interval.total_seconds() / 60, - ) + ) return trains diff --git a/custom_components/sncf_trains/sensor.py b/custom_components/sncf_trains/sensor.py index d879fd6..8a2ef33 100644 --- a/custom_components/sncf_trains/sensor.py +++ b/custom_components/sncf_trains/sensor.py @@ -22,9 +22,9 @@ async def async_setup_entry( - hass: HomeAssistant, - entry: SncfDataConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + entry: SncfDataConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up SNCF entities from a config entry.""" diff --git a/custom_components/sncf_trains/tests/test_config_flow.py b/custom_components/sncf_trains/tests/test_config_flow.py deleted file mode 100644 index d9f2395..0000000 --- a/custom_components/sncf_trains/tests/test_config_flow.py +++ /dev/null @@ -1,90 +0,0 @@ -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/custom_components/sncf_trains/tests/test_coordinator.py b/custom_components/sncf_trains/tests/test_coordinator.py deleted file mode 100644 index ca6d964..0000000 --- a/custom_components/sncf_trains/tests/test_coordinator.py +++ /dev/null @@ -1,78 +0,0 @@ -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) diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 487b07d..86ed3f8 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -1,5 +1,5 @@ // Ajouter au registre des cartes personnalisées -globalThis.customCards = globalThis.customCards || [] +globalThis.customCards ||= [] globalThis.customCards.push({ type: 'sncf-train-card', name: 'SNCF Train Card', @@ -382,83 +382,6 @@ class SncfTrainCard extends HTMLElement { allTrainEntities.push(...trainEntities); } - const d = new Date(); - if (d.getMinutes() < 30) d.setHours(d.getHours()-1) - allTrainEntities.push({ - entity_id: "sensor.nantes_le_pouliguen_train_11", - attributes: { - arrival_stop_id : "stop_point:SNCF:87481002:Train", - arrival_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()+1}:32`, - attribution : "Data provided by api.sncf.com", - base_arrival_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()+1}:02`, - base_departure_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()}:00`, - commercial_mode : "Aléop", - delay_cause: "Perturbation de lignes férroviaires", - delay_minutes : 24, - departure_stop_id : "stop_point:SNCF:87481762:Train", - departure_time : `${d.getDate()}/0${d.getMonth()+1}/${d.getFullYear()} - ${d.getHours()}:30`, - device_class : "timestamp", - direction : "Nantes (Nantes)", - duration_minutes : 67, - friendly_name : "Le Pouliguen → Nantes Train 7", - has_delay : true, - icon : "mdi:train", - physical_mode : "TER / Intercités", - train_num: "858060", - stops_schedule: [ - { - "name": "Le Pouliguen", - "time": `${d.getHours()}:30`, - "base_time": `${d.getHours()}:00`, - "amended_time": `${d.getHours()}:30`, - "effect": "unchanged" - }, - { - "name": "La Baule-Escoublac", - "time": `${d.getHours()}:35`, - "base_time": `${d.getHours()}:05`, - "amended_time": `${d.getHours()}:35`, - "effect": "unchanged" - }, - { - "name": "La Baule Les Pins", - "time": `${d.getHours()}:38`, - "base_time": `${d.getHours()}:08`, - "amended_time": `${d.getHours()}:38`, - "effect": "unchanged" - }, - { - "name": "Pornichet", - "time": `${d.getHours()}:42`, - "base_time": `${d.getHours()}:12`, - "amended_time": `${d.getHours()}:42`, - "effect": "unchanged" - }, - { - "name": "Saint-Nazaire", - "time": `${d.getHours()}:54`, - "base_time": `${d.getHours()}:24`, - "amended_time": `${d.getHours()}:54`, - "effect": "unchanged" - }, - { - "name": "Savenay", - "time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:08`, - "base_time": `${d.getHours()}:38`, - "amended_time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:08`, - "effect": "unchanged" - }, - { - "name": "Nantes", - "time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:32`, - "base_time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:02`, - "amended_time": `${d.getHours()+1 >= 24 ? '0' + (d.getHours()-23) : d.getHours()+1}:32`, - "effect": "unchanged" - } - ] - } - }); - // Source - https://stackoverflow.com/a/1214753 // Posted by Kip, modified by community. See post 'Timeline' for change history // Retrieved 2026-05-15, License - CC BY-SA 4.0 @@ -478,8 +401,7 @@ class SncfTrainCard extends HTMLElement { const aTime = this.parseTime(a.attributes.arrival_time); const bTime = this.parseTime(b.attributes.arrival_time); return aTime - bTime; - }) - .slice(0, this.config.settings.train_lines); + }).slice(0, this.config.settings.train_lines); } catch (error) { console.error('❌ Erreur lors de la récupération via API:', error); From b9c95e7aa83af7f52808cf6569e96cd180972a15 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 01:35:06 +0200 Subject: [PATCH 13/16] rollback tests --- .../sncf_trains/tests}/test_config_flow.py | 0 .../sncf_trains/tests}/test_coordinator.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename {tests => custom_components/sncf_trains/tests}/test_config_flow.py (100%) rename {tests => custom_components/sncf_trains/tests}/test_coordinator.py (100%) diff --git a/tests/test_config_flow.py b/custom_components/sncf_trains/tests/test_config_flow.py similarity index 100% rename from tests/test_config_flow.py rename to custom_components/sncf_trains/tests/test_config_flow.py diff --git a/tests/test_coordinator.py b/custom_components/sncf_trains/tests/test_coordinator.py similarity index 100% rename from tests/test_coordinator.py rename to custom_components/sncf_trains/tests/test_coordinator.py From e7825cd006794e1b69e41fc900ea5ecade504f52 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 01:43:42 +0200 Subject: [PATCH 14/16] Linter --- custom_components/sncf_trains/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/sncf_trains/sensor.py b/custom_components/sncf_trains/sensor.py index 8a2ef33..b9e9186 100644 --- a/custom_components/sncf_trains/sensor.py +++ b/custom_components/sncf_trains/sensor.py @@ -107,8 +107,6 @@ def __init__(self, coordinator, train_id: str, journey_id: int) -> None: 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", "")) - 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] From ba4e7a7535e357f41736605fe09f2540ecbddd1a Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 14:14:56 +0200 Subject: [PATCH 15/16] =?UTF-8?q?Gestion=20des=20erreurs=20+=20r=C3=A9troc?= =?UTF-8?q?ompatibilit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sncf_trains/www/sncf-train-card.js | 85 +++++++++++++++---- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 86ed3f8..9859162 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -8,6 +8,9 @@ globalThis.customCards.push({ configurable: true }); +class MissingConfigError extends Error {} +class InvalidConfigTypeError extends TypeError {} + class SncfTrainCard extends HTMLElement { constructor() { super(); @@ -28,35 +31,83 @@ class SncfTrainCard extends HTMLElement { throw new Error('You need to define device_id'); } + config = this.migrateConfig(config); + + const previousDeviceId = this.config?.device_id ?? null; + const deviceIdChanged = previousDeviceId && JSON.stringify(previousDeviceId) !== JSON.stringify(config.device_id); + + this.config = { ...config }; + + // Forcer la mise à jour immédiate si device_id a changé + if (deviceIdChanged) { + this.stopUpdateTimer(); + this.startUpdateTimer(); + } + + // Toujours forcer un nouveau rendu + this.render(); + } + + /** + * Permet de migrer une configuration ancienne vers la nouvelle structure attendue. + * Pour le moment, la migration gère uniquement le bon affichage de la carte sans mettre à jour le code yaml de la carte. + * À voir s'il est possible de gérer une vraie migration des données. + * @param {object} config - La configuration actuelle de la carte à adapter + * @return {object} La configuration adaptée. + */ + migrateConfig(config) { + const migrated = {...config}; + + // Créer `settings` si absent et transférer les champs legacy + if (!migrated.settings) { + const maybeSettings = {}; + maybeSettings.title = migrated.title; + maybeSettings.train_lines = migrated.train_lines; + maybeSettings.show_route_details = migrated.show_route_details ?? false; + + migrated.settings = {...maybeSettings}; + } + + // Créer `display` si absent et transférer les champs legacy + if (!migrated.display) { + const maybeDisplay = {}; + maybeDisplay.number_of_stops = migrated.number_of_stops ?? 7; + maybeDisplay.train_emoji_axial_symmetry = migrated.train_emoji_axial_symmetry; + maybeDisplay.train_emoji = migrated.train_emoji; + maybeDisplay.show_departure_station = migrated.show_departure_station; + maybeDisplay.departure_station_emoji = migrated.departure_station_emoji; + maybeDisplay.show_arrival_station = migrated.show_arrival_station; + maybeDisplay.arrival_station_emoji = migrated.arrival_station_emoji; + + migrated.display = {...maybeDisplay}; + } + // Normaliser device_id en tableau (rétrocompatibilité) let normalizedDeviceId = config.device_id; if (typeof normalizedDeviceId === 'string') { normalizedDeviceId = [normalizedDeviceId]; } else if (!Array.isArray(normalizedDeviceId)) { - // FIXME : custom error ? - throw new TypeError('device_id must be a string or an array of strings'); + throw new InvalidConfigTypeError('device_id must be a string or an array of strings'); } // Vérifier qu'il y a au moins un device_id non-vide if (!normalizedDeviceId.length || !normalizedDeviceId.some(id => typeof id === 'string' && id.trim() !== '')) { - // FIXME : custom error ? - throw new TypeError('You need to define at least one valid device_id'); + throw new MissingConfigError('You need to define at least one valid device_id'); } - const previousDeviceId = this.config ? this.config.device_id : null; - const deviceIdChanged = previousDeviceId && JSON.stringify(previousDeviceId) !== JSON.stringify(normalizedDeviceId); - - // Créer une copie de la config avec le device_id normalisé - this.config = { ...config, device_id: normalizedDeviceId }; - - // Forcer la mise à jour immédiate si device_id a changé - if (deviceIdChanged) { - this.stopUpdateTimer(); - this.startUpdateTimer(); + // Nettoyer les clés legacy si nous les avons migrées + const legacyKeys = [ + 'title', 'train_lines', 'show_route_details', + 'number_of_stops', 'train_emoji_axial_symmetry', 'train_emoji', + 'show_departure_station', 'departure_station_emoji', 'show_arrival_station', 'arrival_station_emoji' + ]; + for (const k of legacyKeys) { + if (k in migrated && (migrated.settings || migrated.display)) { + delete migrated[k]; + } } - // Toujours forcer un nouveau rendu - this.render(); + return { ...migrated, device_id: normalizedDeviceId}; } /** @@ -222,7 +273,7 @@ class SncfTrainCard extends HTMLElement { */ static getStubConfig() { return { - device_id: ['', ''], + device_id: [''], settings: { title: 'Trains SNCF', train_lines: 5, From ee7c4c5bf8ae2fcbce8b5d31dc9bfa31bbb5c855 Mon Sep 17 00:00:00 2001 From: Pierre-Alexandre MARTIN Date: Mon, 25 May 2026 14:46:44 +0200 Subject: [PATCH 16/16] =?UTF-8?q?Ajout=20des=20arr=C3=AAts=20pass=C3=A9s?= =?UTF-8?q?=20+=20disparition=20des=20trains=20d=C3=A9j=C3=A0=20partis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- custom_components/sncf_trains/coordinator.py | 2 -- .../sncf_trains/www/sncf-train-card.js | 25 ++++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/custom_components/sncf_trains/coordinator.py b/custom_components/sncf_trains/coordinator.py index bbb67be..eaf9482 100644 --- a/custom_components/sncf_trains/coordinator.py +++ b/custom_components/sncf_trains/coordinator.py @@ -73,8 +73,6 @@ def _build_datetime_param(self, time_start, time_end) -> str: 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") diff --git a/custom_components/sncf_trains/www/sncf-train-card.js b/custom_components/sncf_trains/www/sncf-train-card.js index 9859162..512b4d1 100644 --- a/custom_components/sncf_trains/www/sncf-train-card.js +++ b/custom_components/sncf_trains/www/sncf-train-card.js @@ -728,11 +728,13 @@ class SncfTrainCard extends HTMLElement { }).map(stop => { const isDeleted = stop.effect === 'deleted'; const isAdded = stop.effect === 'added'; + const isPassed = now > this.parseTime(stop.time); // TODO : s'assurer de l'utilité de amended_time par rapport à time / base_time const isStopDelayed = this.config.settings.show_route_details && stop.amended_time && stop.base_time && (stop.amended_time !== stop.base_time); + const theme = isPassed ? 'passed' : isDeleted ? 'deleted' : isAdded ? 'added' : isStopDelayed ? 'delayed' : ''; const displayTime = isStopDelayed ? - `${stop.base_time}${stop.amended_time}` : + `${stop.base_time}${stop.amended_time}` : `${stop.base_time || stop.time}`; let statusBadge = ""; @@ -744,12 +746,13 @@ class SncfTrainCard extends HTMLElement { return `
-
+
${displayTime}
-
- ${stop.name}${statusBadge} +
+ ${stop.name} + ${statusBadge}
`; @@ -1025,14 +1028,18 @@ class SncfTrainCard extends HTMLElement { .timeline-container { display: flex; justify-content: space-between; position: relative; z-index: 2; } .timeline-stop { display: flex; flex-direction: column; align-items: center; width: 90px; } .timeline-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--card-background-color); border: 3px solid var(--primary-color); margin-bottom: 6px; box-sizing: border-box; } - .timeline-dot.delayed-dot { border-color: #ff9800; } - .timeline-dot.deleted-dot { background: #f44336; border-color: #f44336; } - .timeline-dot.added-dot { border-color: #ff9800; border-style: dashed; } + .timeline-dot.delayed { border-color: #ff9800; } + .timeline-dot.passed { border-color: #747474; } + .timeline-dot.deleted { background: #f44336; border-color: #f44336; } + .timeline-dot.added { border-color: #ff9800; border-style: dashed; } .timeline-time { font-size: 0.75em; font-weight: bold; display: contents; } .base-time-radar { text-decoration: line-through; opacity: 0.5; font-size: 0.9em; } + .base-time-radar.passed { display: none; } .amended-time-radar { color: #ff9800; font-weight: bold; } - .timeline-name { font-size: 0.65em; text-align: center; color: var(--secondary-text-color); line-height: 1.2; } - .badge-stop { font-size: 0.8em; font-weight: bold; padding: 1px 3px; border-radius: 3px; color: white; } + .amended-time-radar.passed { color: #747474; font-weight: bold; } + .timeline-name { font-size: 0.65em; text-align: center; color: var(--secondary-text-color); line-height: 1.2; display: flex; flex-direction: column; align-items: center; } + .timeline-name.deleted { text-decoration: line-through; opacity: 0.5; } + .badge-stop { font-size: 0.8em; font-weight: bold; padding: 1px 3px; border-radius: 3px; color: white; width: fit-content; } .badge-stop.deleted { background: #f44336; } .badge-stop.added { background: #ff9800; }