diff --git a/custom_components/sncf_trains/api.py b/custom_components/sncf_trains/api.py index dab8516..b61f3b1 100644 --- a/custom_components/sncf_trains/api.py +++ b/custom_components/sncf_trains/api.py @@ -1,7 +1,7 @@ import base64 import logging from aiohttp import ClientSession, ClientTimeout, ClientError -from typing import List, Optional, Mapping +from typing import List, Optional, Mapping, Dict, Any from homeassistant.exceptions import ConfigEntryAuthFailed import asyncio @@ -65,7 +65,7 @@ async def fetch_departures( async def fetch_journeys( self, from_id: str, to_id: str, datetime_str: str, count: int = 5 - ) -> Optional[List[dict]]: + ) -> Optional[Dict[str, Any]]: url = f"{API_BASE}/v1/coverage/sncf/journeys" params_raw: dict[str, object] = { "from": from_id, @@ -91,7 +91,7 @@ async def fetch_journeys( raise RuntimeError("Quota exceeded: 429 Too Many Requests.") resp.raise_for_status() data = await resp.json() - return data.get("journeys", []) + return data except (ClientError, asyncio.TimeoutError) as err: _LOGGER.warning("Network error fetching journeys from SNCF API: %s", err) return None diff --git a/custom_components/sncf_trains/coordinator.py b/custom_components/sncf_trains/coordinator.py index 94b8e7c..eaf9482 100644 --- a/custom_components/sncf_trains/coordinator.py +++ b/custom_components/sncf_trains/coordinator.py @@ -135,6 +135,7 @@ async def _async_update_data(self) -> dict[str, Any]: arrival = entry.data[CONF_TO] time_start = entry.data[CONF_TIME_START] time_end = entry.data[CONF_TIME_END] + train_count = entry.data.get("train_count", 10) update_intervals.append(self._adjust_update_interval(time_start, time_end)) datetime_str = self._build_datetime_param(time_start, time_end) @@ -142,7 +143,7 @@ async def _async_update_data(self) -> dict[str, Any]: for attempt in range(1, max_retries + 1): try: journeys = await self.api_client.fetch_journeys( - departure, arrival, datetime_str, count=10 + departure, arrival, datetime_str, count=train_count ) if journeys is not None: break # succès, on sort du retry @@ -155,15 +156,21 @@ async def _async_update_data(self) -> dict[str, Any]: ) await asyncio.sleep(retry_delay) - if journeys is None or not isinstance(journeys, list): + if journeys is None or not isinstance(journeys, dict): _LOGGER.error("Aucune donnée reçue de l'API SNCF pour le trajet ") continue - trains[subentry_id] = [ - j - for j in journeys - if isinstance(j, dict) and len(j.get("sections", [])) == 1 - ] + journeys_list = journeys.get("journeys", []) + disruptions_list = journeys.get("disruptions", []) + + valid_journeys = [] + for j in journeys_list: + if isinstance(j, dict) and len(j.get("sections", [])) == 1: + # On injecte les perturbations dans chaque trajet + j["_disruptions"] = disruptions_list + valid_journeys.append(j) + + trains[subentry_id] = valid_journeys if update_intervals: new_interval = min(update_intervals) diff --git a/custom_components/sncf_trains/sensor.py b/custom_components/sncf_trains/sensor.py index 78adfc7..b9e9186 100644 --- a/custom_components/sncf_trains/sensor.py +++ b/custom_components/sncf_trains/sensor.py @@ -104,68 +104,156 @@ def __init__(self, coordinator, train_id: str, journey_id: int) -> None: self.tid = train_id self.jid = journey_id entry = self.coordinator.entry.subentries[train_id] - journey = coordinator.data[train_id][journey_id] - section = journey.get("sections", [{}])[0] - departure_time = parse_datetime(section.get("base_departure_date_time", "")) - dep_name = entry.data[CONF_DEPARTURE_NAME] - arr_name = entry.data[CONF_ARRIVAL_NAME] + self.journey = coordinator.data[train_id][journey_id] + self.sections = self.journey.get("sections", [{}])[0] + departure_time = parse_datetime(self.sections.get("base_departure_date_time", "")) self.departure = entry.data[CONF_FROM] self.arrival = entry.data[CONF_TO] self._attr_name = f"Train {journey_id + 1}" self._attr_unique_id = f"{entry.subentry_id}_{journey_id}" - self._attr_extra_state_attributes = self._extra_attributes(journey) + self._attr_extra_state_attributes = self._extra_attributes(self.journey) self._attr_device_info = { "identifiers": {(DOMAIN, entry.subentry_id)}, - "name": f"SNCF {dep_name} → {arr_name}", + "name": f"SNCF {entry.data[CONF_DEPARTURE_NAME]} → {entry.data[CONF_ARRIVAL_NAME]}", "manufacturer": "Master13011", "model": "API", "entry_type": DeviceEntryType.SERVICE, } self._attr_native_value = departure_time + # On appelle la fonction de mise à jour dès la création pour mutualiser le code + self._update_state() @callback def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - journey = self.coordinator.data[self.tid][self.jid] - section = journey.get("sections", [{}])[0] - self._attr_native_value = parse_datetime( - section.get("base_departure_date_time", "") - ) - self._attr_extra_state_attributes = self._extra_attributes(journey) + """Appelé à chaque mise à jour de l'API.""" + self._update_state() self.async_write_ha_state() + def _update_state(self) -> None: + """Handle updated data from the coordinator.""" + self.journey = self.coordinator.data[self.tid][self.jid] + if self.jid < len(self.journey): + self.sections = self.journey.get("sections", [{}])[0] + self._attr_native_value = parse_datetime(self.sections.get("base_departure_date_time", "")) + self._attr_extra_state_attributes = self._extra_attributes(self.journey) + self._attr_available = True # Le capteur est actif + else: + # Si l'API est KO ou renvoie moins de trains que prévu + self._attr_native_value = None + self._attr_extra_state_attributes = {} + self._attr_available = False # Le capteur passe en "Indisponible" + def _extra_attributes(self, journey: dict[str, Any]) -> dict[str, Any]: """Extra attributes.""" - section = journey.get("sections", [{}])[0] - arr_dt = parse_datetime(journey.get("arrival_date_time", "")) - base_arr_dt = parse_datetime(section.get("base_arrival_date_time")) + arr_dt = parse_datetime(self.sections.get("arrival_date_time", "")) + base_arr_dt = parse_datetime(self.sections.get("base_arrival_date_time", "")) delay = ( int((arr_dt - base_arr_dt).total_seconds() / 60) if arr_dt and base_arr_dt else 0 ) + + delay_cause = self._get_delay() + + route_details, stops_schedule = self._get_route() + return { "departure_time": format_time(journey.get("departure_date_time", "")), "arrival_time": format_time(journey.get("arrival_date_time", "")), - "base_departure_time": format_time(section.get("base_departure_date_time")), - "base_arrival_time": format_time(section.get("base_arrival_date_time")), + "base_departure_time": format_time(self.sections.get("base_departure_date_time")), + "base_arrival_time": format_time(self.sections.get("base_arrival_date_time")), "delay_minutes": delay, + "delay_cause": delay_cause, "duration_minutes": get_duration(journey), "has_delay": delay > 0, + "route_details": route_details, + "stops_schedule": stops_schedule, "departure_stop_id": self.departure, "arrival_stop_id": self.arrival, - "direction": section.get("display_informations", {}).get("direction", ""), - "physical_mode": section.get("display_informations", {}).get( - "physical_mode", "" - ), - "commercial_mode": section.get("display_informations", {}).get( - "commercial_mode", "" - ), + "direction": self.sections.get("display_informations", {}).get("direction", ""), + "physical_mode": self.sections.get("display_informations", {}).get("physical_mode", ""), + "commercial_mode": self.sections.get("display_informations", {}).get("commercial_mode", ""), "train_num": get_train_num(journey), } + def _get_route(self) -> Any: + impacted_stops = self.sections.get("impacted_stops", []) + stops_list = [] + stops_schedule = [] + + if impacted_stops: + for stop in impacted_stops: + stop_name = stop.get("stop_point", {}).get("name", "") + b_raw = stop.get("base_departure_time") or stop.get("base_arrival_time") + a_raw = stop.get("amended_departure_time") or stop.get("amended_arrival_time") + + b_time = f"{b_raw[:2]}:{b_raw[2:4]}" if b_raw and len(b_raw) >= 4 else "" + a_time = f"{a_raw[:2]}:{a_raw[2:4]}" if a_raw and len(a_raw) >= 4 else "" + + stop_effect = stop.get("stop_time_effect", "unchanged") + prefix = "" + if stop_effect == "deleted": + prefix = "[SUPPRIMÉ] " + elif stop_effect == "added": + prefix = "[NOUVEAU] " + + stops_list.append(f"{prefix}{stop_name} ({a_time if a_time else b_time})") + stops_schedule.append({ + "name": stop_name, + "base_time": b_time, + "amended_time": a_time if a_time != b_time else None, + "effect": stop_effect + }) + else: + stops_data = self.sections.get("stop_date_times", []) + for stop in stops_data: + stop_name = stop.get("stop_point", {}).get("name", "") + raw_time = stop.get("departure_date_time", stop.get("arrival_date_time", "")) + formatted_time = format_time(raw_time) if raw_time else "" + stop_effect = stop.get("stop_time_effect", "unchanged") + + if stop_name and formatted_time: + prefix = "" + if stop_effect == "deleted": + prefix = "[SUPPRIMÉ] " + elif stop_effect == "added": + prefix = "[NOUVEAU] " + + stops_list.append(f"{prefix}{stop_name} ({formatted_time})") + just_time = formatted_time.split(" - ")[-1] if " - " in formatted_time else formatted_time + stops_schedule.append({ + "name": stop_name, + "time": just_time, + "base_time": just_time, + "amended_time": None, + "effect": stop_effect + }) + route_details = " ➔ ".join(stops_list) + return route_details, stops_schedule + + def _get_delay(self) -> Any: + delay_cause = self.sections.get("cause", "") + + if not delay_cause: + messages = self.journey.get("messages", []) + if messages: + delay_cause = messages[0].get("text", "") + + if not delay_cause: + disruptions = self.journey.get("_disruptions", []) + links = self.sections.get("display_informations", {}).get("links", []) + disruption_ids = [link.get("id") for link in links if link.get("type") == "disruption"] + + for disruption in disruptions: + if disruption.get("id") in disruption_ids: + disruption_msgs = disruption.get("messages", []) + if disruption_msgs: + delay_cause = disruption_msgs[0].get("text", "") + break + return delay_cause + class SncfAllTrainsLineSensor(CoordinatorEntity[SncfUpdateCoordinator], SensorEntity): """Sensor that aggregates all trains on a single line per attribute.""" diff --git a/custom_components/sncf_trains/strings.json b/custom_components/sncf_trains/strings.json index fe26c0f..4b30f1c 100644 --- a/custom_components/sncf_trains/strings.json +++ b/custom_components/sncf_trains/strings.json @@ -6,7 +6,7 @@ } }, "error": { - "invalid_api_key": "API Key not found. Please retry." + "invalid_api_key": "API Key not found or invalid. Please retry." }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" @@ -47,13 +47,13 @@ "arrival_city": { "title": "Select arrival city", "data": { - "departure_city": "City name" + "arrival_city": "City name" } }, "arrival_station": { "title": "Select arrival station", "data": { - "departure_station": "Station name" + "arrival_station": "Station name" } }, "time_range": { @@ -74,10 +74,10 @@ } }, "error": { - "no_stations": "No station for this city" + "no_stations": "No station found for this city" }, "abort": { - "already_configured_as_entry": "Already train is configured", + "already_configured_as_entry": "This journey is already configured", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } } diff --git a/custom_components/sncf_trains/tests/test_config_flow.py b/custom_components/sncf_trains/tests/test_config_flow.py index d9f2395..43578bf 100644 --- a/custom_components/sncf_trains/tests/test_config_flow.py +++ b/custom_components/sncf_trains/tests/test_config_flow.py @@ -1,90 +1,90 @@ -import pytest -from unittest.mock import AsyncMock, patch - -from homeassistant import config_entries -from custom_components.sncf_trains.const import DOMAIN, CONF_API_KEY - - -@pytest.mark.asyncio -async def test_config_flow_happy_path(hass): - """Test config flow with valid API key and stations.""" - mock_api = AsyncMock() - mock_api.search_stations = AsyncMock( - side_effect=[ - [{"id": "stop_area:dep", "name": "Paris Gare de Lyon"}], # departure city - [{"id": "stop_area:arr", "name": "Lyon Part Dieu"}], # arrival city - ] - ) - - with patch( - "custom_components.sncf_trains.config_flow.SncfApiClient", return_value=mock_api - ): - # Step 1: saisie API key - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_API_KEY: "valid_key"} - ) - assert result["type"] == "form" - assert result["step_id"] == "departure_city" - - # Step 2: ville départ - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"departure_city": "Paris"} - ) - assert result["type"] == "form" - assert result["step_id"] == "departure_station" - - # Step 3: station départ - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"departure_station": "stop_area:dep"} - ) - assert result["type"] == "form" - assert result["step_id"] == "arrival_city" - - # Step 4: ville arrivée - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"arrival_city": "Lyon"} - ) - assert result["type"] == "form" - assert result["step_id"] == "arrival_station" - - # Step 5: station arrivée - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"arrival_station": "stop_area:arr"} - ) - assert result["type"] == "form" - assert result["step_id"] == "time_range" - - # Step 6: plage horaire + finalisation - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"time_start": "07:00", "time_end": "10:00"} - ) - assert result["type"] == "create_entry" - assert result["title"] == "SNCF: Paris Gare de Lyon → Lyon Part Dieu" - assert result["data"]["departure_name"] == "Paris Gare de Lyon" - - -@pytest.mark.asyncio -async def test_config_flow_invalid_api_key(hass): - """Test config flow with invalid API key.""" - mock_api = AsyncMock() - mock_api.search_stations = AsyncMock(return_value=None) - - with patch( - "custom_components.sncf_trains.config_flow.SncfApiClient", return_value=mock_api - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_API_KEY: "bad_key"} - ) - - assert result["type"] == "form" - assert result["errors"]["base"] == "invalid_api_key" +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 index ca6d964..1e233f1 100644 --- a/custom_components/sncf_trains/tests/test_coordinator.py +++ b/custom_components/sncf_trains/tests/test_coordinator.py @@ -1,78 +1,78 @@ -import pytest -from unittest.mock import AsyncMock -from datetime import timedelta -from homeassistant.helpers.update_coordinator import UpdateFailed - -from custom_components.sncf_trains.coordinator import SncfUpdateCoordinator - - -@pytest.mark.asyncio -async def test_coordinator_success(hass): - """Test coordinator fetches journeys successfully.""" - mock_api = AsyncMock() - mock_api.fetch_journeys = AsyncMock(return_value=[{"id": "j1"}]) - - coordinator = SncfUpdateCoordinator( - hass=hass, - api_client=mock_api, - departure="stop_area:dep", - arrival="stop_area:arr", - time_start="06:00", - time_end="09:00", - update_interval=5, - outside_interval=30, - ) - - data = await coordinator._async_update_data() - assert data == [{"id": "j1"}] - mock_api.fetch_journeys.assert_called_once() - assert isinstance(coordinator.update_interval, timedelta) - - -@pytest.mark.asyncio -async def test_coordinator_api_failure(hass): - """Test coordinator raises UpdateFailed when API fails.""" - mock_api = AsyncMock() - mock_api.fetch_journeys = AsyncMock(side_effect=Exception("API error")) - - coordinator = SncfUpdateCoordinator( - hass=hass, - api_client=mock_api, - departure="stop_area:dep", - arrival="stop_area:arr", - time_start="06:00", - time_end="09:00", - ) - - with pytest.raises(UpdateFailed): - await coordinator._async_update_data() - - -@pytest.mark.asyncio -async def test_coordinator_adjust_interval(hass): - """Test that update interval adjusts inside and outside time range.""" - - mock_api = AsyncMock() - mock_api.fetch_journeys = AsyncMock(return_value=[{"id": "j1"}]) - - coordinator = SncfUpdateCoordinator( - hass=hass, - api_client=mock_api, - departure="stop_area:dep", - arrival="stop_area:arr", - time_start="00:00", - time_end="23:59", - update_interval=5, - outside_interval=30, - ) - - # Forcing inside time range (always true here) - await coordinator._async_update_data() - assert coordinator.update_interval == timedelta(minutes=5) - - # Fake outside range by setting opposite times - coordinator.time_start = "23:59" - coordinator.time_end = "00:00" - - await coordinator._async_update_data() - assert coordinator.update_interval == timedelta(minutes=30) +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/translations/fr.json b/custom_components/sncf_trains/translations/fr.json index ece14a1..ef55b71 100644 --- a/custom_components/sncf_trains/translations/fr.json +++ b/custom_components/sncf_trains/translations/fr.json @@ -14,7 +14,7 @@ "invalid_api_key": "Clé API invalide. Veuillez vérifier et réessayer." }, "abort": { - "reconfigure_successful": "Compose reconfiguré avec succès" + "reconfigure_successful": "Intégration reconfigurée avec succès" } }, "options": { @@ -65,7 +65,7 @@ "title": "Choisissez la plage horaire", "data": { "time_start": "Heure de départ", - "time_end": "Heure d'arrivé", + "time_end": "Heure d'arrivée", "train_count": "Nombre de trains" } }, @@ -73,7 +73,7 @@ "title": "Choisissez la plage horaire", "data": { "time_start": "Heure de départ", - "time_end": "Heure d'arrivé", + "time_end": "Heure d'arrivée", "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 e3e8f1e..512b4d1 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,33 +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)) { - throw new Error('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 => 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() !== '')) { + 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}; } /** @@ -77,89 +130,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 +250,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; }, @@ -188,17 +273,24 @@ class SncfTrainCard extends HTMLElement { */ static getStubConfig() { return { - device_id: ['', ''], - title: 'Trains SNCF', - train_lines: 5, + device_id: [''], + 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 +332,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 +404,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 = []; @@ -359,13 +448,11 @@ 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 +470,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 +494,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 +538,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; } @@ -472,27 +576,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 @@ -539,7 +622,7 @@ class SncfTrainCard extends HTMLElement {
-
${this.config.title}
+
${this.config.settings.title}
${this.renderTrainLines(trains)} @@ -567,30 +650,110 @@ 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; - 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())} +
+
+ `; + } - ${this.config.show_arrival_station ? this.renderArrival(TA) : ''} + /** + * 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'; + 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.time}`; + + let statusBadge = ""; + if (isDeleted) { + statusBadge = ' SUPPRIMÉ'; + } else if (isAdded) { + statusBadge = ' RAJOUTÉ'; + } + + return ` +
+
+
+ ${displayTime} +
+
+ ${stop.name} + ${statusBadge} +
`; }).join(''); @@ -598,15 +761,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 `
@@ -623,26 +786,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 ? ` @@ -789,6 +952,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; @@ -847,6 +1020,28 @@ 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 { 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; } + .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; } `; } @@ -854,4 +1049,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);